diff --git a/docs/media-navigation.md b/docs/media-navigation.md index 334912b..8b10e68 100644 --- a/docs/media-navigation.md +++ b/docs/media-navigation.md @@ -4,7 +4,7 @@ 全应用只有一套导航:手机为底部标签栏,701px 起为左侧边栏,包含首页(#/)、书架(#/shelf)、影音(#/media/video)和我的(#/me)。宽屏侧边栏在「影音」下列出影视、音乐、有声书三个频道;手机在频道分类页顶部用影视/音乐/有声书三段切换频道(旧的频道菜单和频道导轨已移除)。 -频道下方是分类标签:影视(首页、电影、剧集)、音乐(专辑、歌手、曲目)、有声书(有声书、演播者)。手机上左右滑动切换相邻分类,越过频道最后一个分类即进入下一频道;同频道内保留 library 和 sort 参数,跨频道重置。频道尚无媒体库时滑动按整个频道切换。分类页右上角 ⋯ 菜单提供收藏、历史、队列和媒体库管理,工具栏提供文件夹入口。 +频道下方是分类标签:影视(首页、电影、剧集)、音乐(专辑、歌手、曲目)、有声书(有声书、演播者)。手机上左右滑动切换相邻分类,越过频道最后一个分类即进入下一频道;同频道内保留 library 和 sort 参数,跨频道重置。频道尚无媒体库时滑动按整个频道切换。分类页右上角 ⋯ 菜单提供收藏、历史、队列,管理员另有媒体库管理(身份确认前按成员处理);演播者目录页也显示同一排分类标签。工具栏提供文件夹入口。 二级页显示返回、页面标题和当前页面的操作,不显示频道切换;手机上二级页以自己的返回栏替代底部标签栏,宽屏侧边栏保留。播放器与阅读页隐藏应用导航。设置、收藏、资源详情、播放器各自拥有路由。媒体库筛选位于列表工具栏左侧,版本、排序等选择器使用统一主题弹层。阅读界面和书架设置维持原有结构。 @@ -22,7 +22,7 @@ channel 表示 video、music 或 audiobook;itemId、part、libraryId 均为真 | 收藏 | #/media/favorites,scope 和 offset 保存筛选、分页;#/media/:channel/favorites 预选该频道 | | 搜索 | #/media/search?q=…&scope=… | | 历史、待播队列 | #/media/:channel/history;#/media/queue、#/media/:channel/queue | -| 偏好设置(我的) | #/me/settings(外观与主题、书架、影音浏览、播放四个分区,`?section=` 定位);#/me/account;#/me/opds | +| 偏好设置(我的) | #/me/settings(外观与主题、书架、影音浏览、播放四个标签,`?section=` 选中);#/me/account;#/me/opds | | 媒体库管理 | #/media/:channel/settings/libraries | | 新建、编辑、权限 | #/media/:channel/settings/libraries/new、#/media/:channel/settings/libraries/:libraryId/edit、#/media/:channel/settings/libraries/:libraryId/permissions | | 扫描与刮削 | #/media/:channel/settings/tasks?tab=scan\|ai\|scrape&library=… | diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1e91857..5308f0f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,4 @@ -import { combineAbortSignals } from '../core/abort.ts'; +import { abortedError, abortFailure, combineAbortSignals, isAbortError } from '../core/abort.ts'; import type { ChapterQuality, CredentialStatus, OpdsCredential } from './sources.ts'; import type { ReadingOverrides } from './types.ts'; import { eventStream } from './event-stream.ts'; @@ -29,7 +29,8 @@ import type { } from './types.ts'; import type { Platform } from '../core/platform.ts'; import type { SourceType, SourceInstance, SourcePlugin, SourceEntry, SourcePage, SourceAcquisition, ChapterSubscription } from './sources.ts'; -import { affectedNavigationQuery, createReaderQueryClient, isNavigationQuery, readerQueryKey } from './query.ts'; +import { isCancelledError } from '@tanstack/query-core'; +import { affectedNavigationQuery, createReaderQueryClient, isLiveNavigationQuery, isNavigationQuery, readerQueryKey } from './query.ts'; export interface SessionStore { load(): Promise; @@ -790,12 +791,19 @@ export class ReaderApi { // signal would turn normal auth/session transitions into CancelledError. queryFn: () => this.request(path, 'GET', undefined, options) .then(response => response.json as T), + // Availability-bearing media resources are shared while in flight but + // always revalidated, so an explicit refresh reaches the server. + ...(isLiveNavigationQuery(path) ? { staleTime: 0 } : {}), }); } catch (error) { + // A QueryClient cancellation (cache cleared on account change) or a raw + // AbortError is the same outcome as an aborted transport request. + const aborted = error instanceof ApiError ? error.kind === 'aborted' : isCancelledError(error) || isAbortError(error); + if (!aborted) throw error; // fetchQuery shares one in-flight request per path, run with the *first* // caller's signal. When that caller goes away (a screen replaced right after // login), a later caller that is still live must not inherit its abort. - if (!(error instanceof ApiError && error.kind === 'aborted') || options.signal?.aborted) throw error; + if (options.signal?.aborted) throw error instanceof ApiError ? error : abortedError(); this.assertSession(generation); result = (await this.request(path, 'GET', undefined, options)).json as T; } @@ -862,9 +870,13 @@ export class ReaderApi { if (generation !== this.sessionGeneration) await response.stream?.cancel().catch(() => {}); assertCurrent(); return response; - } catch (err) { + } catch (caught) { assertCurrent(); + // Transports other than FetchTransport may still reject with the signal's + // raw reason; callers only ever see ApiError('aborted') for cancellation. + const err = caught instanceof ApiError ? caught : abortFailure(caught, options.signal) ?? caught; if (!(err instanceof ApiError)) throw err; + if (err.kind === 'aborted') throw err; this.platform.reportError?.(err); // Only an expired access token is worth a retry, and only if there is a // refresh token to spend. `TOKEN_INVALID` means the signature or payload diff --git a/web/src/api/query.ts b/web/src/api/query.ts index 66208a5..4fe7f51 100644 --- a/web/src/api/query.ts +++ b/web/src/api/query.ts @@ -31,32 +31,62 @@ export function isNavigationQuery(path: string): boolean { || /^\/api\/v1\/media\/items\/[^/]+(?:\/(children|favorite|tracks|chapters))?$/.test(resource); } -function mediaItemId(path: string): string | null { - return path.match(/^\/api\/v1\/media\/items\/([^/]+)/)?.[1] ?? null; +/** + * Media item resources that carry file availability (`parts[].available`, + * editions, tracks). Rescans, mounts coming back and other admins change them + * without any write from this client, so a cached copy must never answer an + * explicit refetch such as 刷新资源状态. Concurrent reads are still shared, and + * React pages keep their own screen-level cache in the app QueryClient. + */ +export function isLiveNavigationQuery(path: string): boolean { + return /^\/api\/v1\/media\/items\/[^/]+(?:\/(children|tracks|chapters))?$/.test(path.split('?')[0]!); } function queryIsMediaItem(query: string, id: string): boolean { return query.startsWith('/api/v1/media/items/' + id) || query.includes('/items/' + id + '/'); } +/** + * Media writes that change nothing a cached navigation query shows: POST reads + * (match search, recognition and queue previews) and job/grant bookkeeping. + * Starting a scan or scrape is *not* listed: it rewrites the catalog. + */ +const MEDIA_WRITES_WITHOUT_CATALOG_CHANGES = [ + /^\/api\/v1\/media\/background-grants(?:\/|$)/, + /^\/api\/v1\/media\/items\/[^/]+\/matches$/, + /^\/api\/v1\/media\/libraries\/[^/]+\/recognition-preview$/, + /^\/api\/v1\/media\/queue\/preview$/, + /^\/api\/v1\/media\/jobs\/[^/]+\/cancel$/, + /^\/api\/v1\/media\/(scan-jobs|scrape-jobs|ai-scan-jobs)\/[^/]+(?:\/cancel)?$/, + /^\/api\/v1\/media\/ai-scan-jobs$/, +]; + +function affectedByMediaWrite(mutation: string, query: string): boolean { + if (!query.startsWith('/api/v1/media/')) return false; + const resource = mutation.split('?')[0]!; + if (MEDIA_WRITES_WITHOUT_CATALOG_CHANGES.some(pattern => pattern.test(resource))) return false; + // Playback sessions and progress heartbeats only move history and 继续播放. + if (/^\/api\/v1\/media\/(background\/)?playback(?:\/|$)/.test(resource)) { + return /^\/api\/v1\/media\/(history|continue)(?:\?|$)/.test(query); + } + if (/^\/api\/v1\/media\/queue(?:\/|$)/.test(resource)) return /^\/api\/v1\/media\/queue(?:\?|$)/.test(query); + const favorite = resource.match(/^\/api\/v1\/media\/items\/([^/]+)\/favorite$/); + if (favorite) return queryIsMediaItem(query, favorite[1]!) || /^\/api\/v1\/media\/favorites(?:\?|$)/.test(query); + // A new library has no items until it is scanned. + if (resource === '/api/v1/media/libraries') return query === '/api/v1/media/libraries'; + // Everything else rewrites catalog facts that many cached reads embed: editions + // (rename/move/split/merge/order) live inside item details and their parents' + // children, metadata and matches change titles in browse/search/favorites/history, + // hierarchy edits move items between parents, and library access, recognition, + // missing-resource cleanup and scans change what every list contains. Edition ids + // do not name their item, so there is no narrower set that is still correct. + return true; +} + /** Invalidate only the navigation resources a mutation can change. */ export function affectedNavigationQuery(mutation: string, query: string): boolean { if (/^\/api\/v1\/sync(?:\/|$)/.test(mutation)) return query.startsWith('/api/v1/library/continue'); - if (mutation.startsWith('/api/v1/media/playback')) { - return /^\/api\/v1\/media\/(history|continue)(?:\?|$)/.test(query); - } - if (mutation.startsWith('/api/v1/media/queue')) return query.startsWith('/api/v1/media/queue'); - if (mutation.startsWith('/api/v1/media/items/')) { - const id = mediaItemId(mutation); - if (!id) return false; - if (mutation.includes('/favorite')) return queryIsMediaItem(query, id) || /^\/api\/v1\/media\/favorites(?:\?|$)/.test(query); - return queryIsMediaItem(query, id); - } - if (mutation.startsWith('/api/v1/media/libraries/')) { - const id = mutation.match(/^\/api\/v1\/media\/libraries\/([^/]+)/)?.[1]; - return query === '/api/v1/media/libraries' || (!!id && query.startsWith('/api/v1/media/libraries/' + id + '/')); - } - if (mutation.startsWith('/api/v1/media/libraries')) return query === '/api/v1/media/libraries'; + if (mutation.startsWith('/api/v1/media/')) return affectedByMediaWrite(mutation, query); if (/^\/api\/v1\/(notes|tts|ai|auth)(?:\/|$)/.test(mutation)) return false; if (mutation === '/api/v1/library/upload') return query.startsWith('/api/v1/books') || query.startsWith('/api/v1/library/browse'); if (mutation === '/api/v1/library/browse/shelf') return query.startsWith('/api/v1/books') || query.startsWith('/api/v1/library/continue'); diff --git a/web/src/core/abort.ts b/web/src/core/abort.ts index 6bd9a89..9f3d28b 100644 --- a/web/src/core/abort.ts +++ b/web/src/core/abort.ts @@ -1,3 +1,35 @@ +import { ApiError } from '../api/errors.ts'; + +/** + * True for the failures cancellation produces: a DOMException or Error named + * `AbortError` (fetch, body reads, media/stream APIs) — including the reason an + * `AbortController.abort()` without arguments leaves on its signal ("signal is + * aborted without reason"). + */ +export function isAbortError(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'AbortError'; +} + +/** The one shape cancellation takes once it leaves the transport. */ +export function abortedError(message = 'request aborted'): ApiError { + return new ApiError('aborted', message); +} + +/** + * Map a failure seen while `signal` was in use to ApiError('aborted') when it is + * a cancellation, or null when it is a real failure. A signal aborted with a + * custom reason rejects fetch with that reason rather than an AbortError, so the + * signal's own state decides. A timeout signal is a connectivity failure. + */ +export function abortFailure(error: unknown, signal?: AbortSignal): ApiError | null { + if (error instanceof ApiError) return error.kind === 'aborted' ? error : null; + const reason: unknown = signal?.aborted ? signal.reason : undefined; + if (typeof reason === 'object' && reason !== null && (reason as { name?: unknown }).name === 'TimeoutError') { + return new ApiError('offline', 'request timed out', 'TIMEOUT'); + } + return signal?.aborted || isAbortError(error) ? abortedError() : null; +} + /** Compose cancellation without AbortSignal.any, which older Android browsers lack. * Call dispose when the operation settles to release listeners on long-lived signals. */ diff --git a/web/src/core/fetch-transport.ts b/web/src/core/fetch-transport.ts index 149a911..ebad76a 100644 --- a/web/src/core/fetch-transport.ts +++ b/web/src/core/fetch-transport.ts @@ -1,5 +1,6 @@ import { ApiError, errorForStatus, parseErrorBody } from '../api/errors.ts'; import type { HttpRequest, HttpResponse, Transport } from './platform.ts'; +import { abortFailure } from './abort.ts'; /** * `fetch`-based transport used by the browser and by the Android shell for @@ -12,7 +13,26 @@ import type { HttpRequest, HttpResponse, Transport } from './platform.ts'; export class FetchTransport implements Transport { constructor(private readonly baseUrl: () => string) {} + /** + * Cancellation surfaces as ApiError('aborted') however it happens: before the + * headers arrive, while the body is read (`text()`/`arrayBuffer()` reject with a + * raw AbortError), or with a custom abort reason. A raw DOMException would skip + * the client's shared-query retry and reach the UI as "signal is aborted + * without reason". + */ async send(request: HttpRequest): Promise { + try { + return await this.exchange(request); + } catch (err) { + const aborted = abortFailure(err, request.signal); + if (aborted) throw aborted; + if (err instanceof ApiError) throw err; + // The connection dropped while the body was being read. + throw new ApiError('offline', err instanceof Error ? err.message : 'network error'); + } + } + + private async exchange(request: HttpRequest): Promise { const url = /^https?:/i.test(request.url) ? request.url : `${this.baseUrl()}${request.url}`; let response: Response; try { @@ -28,9 +48,8 @@ export class FetchTransport implements Transport { credentials: 'omit', }); } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - throw new ApiError('aborted', 'request aborted'); - } + const aborted = abortFailure(err, request.signal); + if (aborted) throw aborted; // DNS failure, refused connection, TLS error, CORS preflight rejection. // From the reader's point of view all of these mean "server not reachable // right now", and the UI must degrade to cached content instead of diff --git a/web/src/features/home/pages/HomePage.tsx b/web/src/features/home/pages/HomePage.tsx index 9bfec16..a3b9ddd 100644 --- a/web/src/features/home/pages/HomePage.tsx +++ b/web/src/features/home/pages/HomePage.tsx @@ -11,6 +11,7 @@ import { useShelf } from '../../shelf/queries/shelf.queries.ts'; import { historyQuery, mediaKeys, type Activity } from '../../media/queries/media.queries.ts'; import { MediaCover } from '../../media/components/cover.tsx'; import { resolveWork } from '../../media/services/work.ts'; +import { useMediaPreferences } from '../../media/hooks/use-media-preferences.ts'; import type { SearchItem } from '../../media/api/media-api.ts'; import type { ContinueReadingItem } from '../../../api/types.ts'; import '../styles/home.css'; @@ -136,11 +137,13 @@ export function HomePage() { queryFn: ({ signal }) => runtime.api.continueReading(2, { signal }), }); const shelf = useShelf(1, 'recent'); - const video = useQuery(historyQuery(runtime, 'video', new URLSearchParams())); - const audio = useQuery(historyQuery(runtime, 'audiobook', new URLSearchParams())); + // 首页显示最近续播 (偏好设置 › 影音浏览) off: no video/audiobook continue cards; books are not media. + const { showContinue } = useMediaPreferences(); + const video = useQuery({ ...historyQuery(runtime, 'video', new URLSearchParams()), enabled: showContinue }); + const audio = useQuery({ ...historyQuery(runtime, 'audiobook', new URLSearchParams()), enabled: showContinue }); const unfinished = (items?: Activity[]) => items?.find(item => !item.completed); const readingBooks = reading.data ?? [], book = readingBooks[0]; - const videoActivity = unfinished(video.data?.items), audioActivity = unfinished(audio.data?.items); + const videoActivity = showContinue ? unfinished(video.data?.items) : undefined, audioActivity = showContinue ? unfinished(audio.data?.items) : undefined; const books = (shelf.data?.items ?? []).slice(0, 8); const name = user?.displayName || user?.username || ''; diff --git a/web/src/features/media/components/MediaItemGrid.tsx b/web/src/features/media/components/MediaItemGrid.tsx index aa2213c..debfd91 100644 --- a/web/src/features/media/components/MediaItemGrid.tsx +++ b/web/src/features/media/components/MediaItemGrid.tsx @@ -4,11 +4,13 @@ import type { Item, MediaChannel } from '../api/media-api.ts'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; import { favoriteChannel } from './favorites.tsx'; import { itemLabel } from './item-label.ts'; +import type { MediaPreferences } from '../services/preferences.ts'; -export function MediaItemGrid({ items, channel }: { items: Item[]; channel?: MediaChannel }) { +/** `density`: 封面密度 from 偏好设置 › 影音浏览 — compact is three columns on a phone, comfortable two. */ +export function MediaItemGrid({ items, channel, density }: { items: Item[]; channel?: MediaChannel; density?: MediaPreferences['density'] }) { const { mediaApi } = useRuntime(); if (!items.length) return

暂无内容

; - return
+ return
{items.map(item => { const subtitle = itemLabel(item); return diff --git a/web/src/features/media/components/channel-menu.tsx b/web/src/features/media/components/channel-menu.tsx index 3e91428..bceae00 100644 --- a/web/src/features/media/components/channel-menu.tsx +++ b/web/src/features/media/components/channel-menu.tsx @@ -2,14 +2,20 @@ import { Icon } from '../../../ui/icon.tsx'; import { useNavigate } from 'react-router-dom'; import { ActionMenu } from '../../../ui/action-menu.tsx'; import type { MediaChannel } from '../api/media-api.ts'; +import { useAuthStore } from '../../../shared/stores/auth.store.ts'; -/** The ⋯ menu on a channel's landing page: the same four destinations on every channel. */ +/** + * The ⋯ menu on a channel's landing page: 收藏, 历史 and 队列 on every channel, and + * 媒体库管理 for an administrator only. Until the role is known it counts as a member's + * (docs/ui.md §3.4.4), so the entry never leads a member to 需要管理员权限. + */ export function ChannelMenu({ channel }: { channel: MediaChannel }) { const navigate = useNavigate(); + const admin = useAuthStore(state => state.verifiedUser?.role === 'admin'); return , onSelect: () => navigate('/media/favorites') }, { label: '历史', icon: , onSelect: () => navigate(`/media/${channel}/history`) }, { label: '队列', icon: , onSelect: () => navigate(`/media/${channel}/queue`) }, - { label: '媒体库管理', icon: , onSelect: () => navigate(`/media/${channel}/settings/libraries`) }, + ...(admin ? [{ label: '媒体库管理', icon: , onSelect: () => navigate(`/media/${channel}/settings/libraries`) }] : []), ]} />; } diff --git a/web/src/features/media/components/channel-navigation.tsx b/web/src/features/media/components/channel-navigation.tsx index 1be76b1..8613984 100644 --- a/web/src/features/media/components/channel-navigation.tsx +++ b/web/src/features/media/components/channel-navigation.tsx @@ -1,4 +1,6 @@ -import { channelCategories } from '../hooks/use-media-channel.ts'; +import { Link, useLocation } from 'react-router-dom'; +import { channelCategories, channelLabels } from '../hooks/use-media-channel.ts'; +import type { MediaChannel } from '../api/media-api.ts'; /** The three media channels. Reading, home and the account page live in the app navigation. */ export const mediaChannels = [ @@ -20,6 +22,23 @@ export function ChannelSegments({ current }: { current: string }) { ; } +/** + * A channel's category tabs (影视: 首页 电影 剧集; 有声书: 有声书 演播者 …), shared by the + * generic catalog and the narrator page so both draw the same row. + */ +export function CatalogTabs({ channel }: { channel: MediaChannel }) { + const { pathname } = useLocation(); + const categories = channelCategories[channel]; + return ; +} + /** * Every catalog tab on one line, channel by channel: 影视(首页 电影 剧集), 音乐(专辑 * 歌手 曲目), 有声书(有声书 演播者). A swipe past a channel's last category is diff --git a/web/src/features/media/components/favorites.tsx b/web/src/features/media/components/favorites.tsx index a91b0dd..891eb39 100644 --- a/web/src/features/media/components/favorites.tsx +++ b/web/src/features/media/components/favorites.tsx @@ -22,7 +22,8 @@ export function MediaFavorites({api,items,total,offset,scope,busy,onScope,onPage {item.title}{[kindNames[item.kind],itemLabel(item)].filter(Boolean).join(' · ')} )}
- {!items.length&&

{scope==='all'?'还没有收藏,打开作品详情即可收藏。':'暂无这类收藏。'}

{scope!=='all'&&}
} + {/* No rows but a total means a page past the end (the page clamps it), not an empty list. */} + {!items.length&&!total&&

{scope==='all'?'还没有收藏,打开作品详情即可收藏。':'暂无这类收藏。'}

{scope!=='all'&&}
} {total>60&&} ; } diff --git a/web/src/features/media/components/playback-controls.tsx b/web/src/features/media/components/playback-controls.tsx index 43d4672..46ed42c 100644 --- a/web/src/features/media/components/playback-controls.tsx +++ b/web/src/features/media/components/playback-controls.tsx @@ -4,7 +4,7 @@ import {SleepTimerOptions} from './sleep-timer.tsx'; import {PlayModeCycle,PlayModeSegments} from './play-mode.tsx'; import { ActionMenu } from '../../../ui/action-menu.tsx'; import type { MediaPlayer } from '../services/player.ts'; -import { useEffect, useState, useRef, useLayoutEffect } from 'react'; +import { useEffect, useState, useRef, useLayoutEffect, useReducer } from 'react'; import type {Detail,MediaApi,Part} from '../api/media-api.ts'; import {MediaCover} from './cover.tsx'; import {AudiobookChapters} from './audiobook-chapters.tsx'; @@ -40,7 +40,20 @@ function PlayingChapters({api,player,item,error,cause,onRetry}:{api:MediaApi;pla return <>{setEditionId(id);setPosition({query:'',page:0});setNotice('');}} onPlay={(parts:Part[],index:number)=>void run(()=>player.play(parts.map(part=>({part,title:item.title+' · '+part.title,video:false})),index))} onQueue={ids=>void run(async()=>{await api.request('queue','POST',{partIds:ids});},true)} onRefresh={onRetry} position={position} onPositionChange={setPosition}/>{notice&&

{notice}

}; } +/** The player is an imperative EventTarget: queue edits (上移 / 移除, native queue + * updates, track changes) only announce themselves through its `change` event. */ +function usePlayerChanges(player:MediaPlayer){ + const [,changed]=useReducer((version:number)=>version+1,0); + useEffect(()=>{ + if(typeof player.addEventListener!=='function')return; + const listener=()=>changed(); + player.addEventListener('change',listener); + return ()=>player.removeEventListener('change',listener); + },[player]); +} + function CurrentPlaylist({player,modes}:{player:MediaPlayer;modes:boolean}){ + usePlayerChanges(player); const [query,setQuery]=useState(''),[page,setPage]=useState(0); const [editing,setEditing]=useState(false),[selected,setSelected]=useState(null); const [searchOpen,setSearchOpen]=useState(player.currentPlaylist.length>10); diff --git a/web/src/features/media/components/search.tsx b/web/src/features/media/components/search.tsx index e7c6cd2..80c7570 100644 --- a/web/src/features/media/components/search.tsx +++ b/web/src/features/media/components/search.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { MediaCover } from './cover.tsx'; import { MediaLoading } from './loading.tsx'; import { MediaScreenError } from './screen-error.tsx'; +import { useScrollMemory } from '../hooks/use-scroll-memory.ts'; import type { MediaApi, MediaChannel, SearchItem } from '../api/media-api.ts'; const labels={video:'影视',music:'音乐',audiobook:'有声书'}; @@ -24,10 +25,15 @@ function resultDescription(item:SearchItem):string { return parts.join(' · '); } -export function MediaSearch({api,channel,navigate,initialLocation,onLocationChange}:{api:MediaApi;channel:MediaChannel;navigate:(channel:MediaChannel,id:string)=>void;initialLocation?:Record|undefined;onLocationChange?:(params:Record)=>void}) { +/** + * `initialLocation` (the search route) reads the search from the URL; then `scrollKey` (account + URL) + * restores the scroll on Back through the shared scroll memory. Without it the search lives in + * `returns` (one snapshot per account, taken when a result is opened). + */ +export function MediaSearch({api,channel,navigate,initialLocation,onLocationChange,scrollKey}:{api:MediaApi;channel:MediaChannel;navigate:(channel:MediaChannel,id:string)=>void;initialLocation?:Record|undefined;onLocationChange?:(params:Record)=>void;scrollKey?:string}) { const saved=searchReturnFor(api),initial=initialLocation?{input:initialLocation.q??'',query:initialLocation.q??'',scope:(['all','music','video','audiobook'].includes(initialLocation.scope??'')?initialLocation.scope:'all') as MediaChannel|'all',offset:Number(initialLocation.offset)||0,scroll:0}:saved?.channel===channel?saved:undefined; const host=useRef(null); - const scrollToRestore=useRef(initial?.scroll??null); + const scrollToRestore=useRef(initialLocation?null:initial?.scroll??null); const [input,setInput]=useState(initial?.input??''); const [scope,setScope]=useState(initial?.scope??channel); const [query,setQuery]=useState(initial?.query??''); @@ -46,6 +52,7 @@ export function MediaSearch({api,channel,navigate,initialLocation,onLocationChan const [error,setError]=useState(''); const [errorCause,setErrorCause]=useState(); const [loaded,setLoaded]=useState(false); + const skipRestore=useScrollMemory(host,initialLocation?scrollKey:undefined,loaded&&!busy&&!error); useEffect(()=>{ if(!loaded||busy||error||scrollToRestore.current===null)return; const screen=host.current?.closest('.media-screen'); @@ -60,23 +67,23 @@ export function MediaSearch({api,channel,navigate,initialLocation,onLocationChan let recovering=false; void api.search(query,scope,offset,controller.signal).then(result=>{ if(controller.signal.aborted)return; - if(offset>0&&offset>=result.total){recovering=true;scrollToRestore.current=0;setOffset(0);change({offset:0});return;} + if(offset>0&&offset>=result.total){recovering=true;skipRestore();scrollToRestore.current=0;setOffset(0);change({offset:0});return;} setItems(result.items);setTotal(result.total);setLoaded(true); }).catch(error=>{if(!controller.signal.aborted){setError(error instanceof Error?error.message:'搜索失败');setErrorCause(error);}}) .finally(()=>{if(!controller.signal.aborted&&!recovering)setBusy(false);}); return ()=>controller.abort(); },[api,query,scope,offset,attempt]); return
-
{event.preventDefault();scrollToRestore.current=null;setQuery(input.trim());setOffset(0);change({q:input.trim(),offset:0});setAttempt(value=>value+1);}}> + {event.preventDefault();skipRestore();scrollToRestore.current=null;setQuery(input.trim());setOffset(0);change({q:input.trim(),offset:0});setAttempt(value=>value+1);}}> setInput(event.currentTarget.value)}/>
- + {loaded&&!busy&&!error&&

找到 {total} 项内容

} - {busy?:error?setAttempt(value=>value+1)}/>:!query||!items.length?

{query?'没有找到相关内容':'搜索你的影音'}

{query?'没有匹配内容,请调整搜索词或搜索范围。':'输入名称,搜索有权访问的媒体库。'}

{query&&}
:
{(['video','music','audiobook'] as const).map(group=>{ + {busy?:error?setAttempt(value=>value+1)}/>:!query||!items.length?

{query?'没有找到相关内容':'搜索你的影音'}

{query?'没有匹配内容,请调整搜索词或搜索范围。':'输入名称,搜索有权访问的媒体库。'}

{query&&}
:
{(['video','music','audiobook'] as const).map(group=>{ const entries=items.filter(item=>item.channel===group);if(!entries.length)return null; return

{labels[group]} {entries.length}{total>60?' · 本页':''}

{entries.map(item=>)}
; })}
} - {total>60&&
{Math.floor(offset/60)+1} / {Math.ceil(total/60)}
} + {total>60&&
{Math.floor(offset/60)+1} / {Math.ceil(total/60)}
}
; } diff --git a/web/src/features/media/hooks/use-media-preferences.ts b/web/src/features/media/hooks/use-media-preferences.ts new file mode 100644 index 0000000..9d34f12 --- /dev/null +++ b/web/src/features/media/hooks/use-media-preferences.ts @@ -0,0 +1,14 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import { useRuntime } from '../../../app/providers/runtime-context.tsx'; +import { mediaPreferencesSnapshot, subscribeMediaPreferences, type MediaPreferences } from '../services/preferences.ts'; + +/** + * The 影音浏览 preferences (我的 › 偏好设置) for the signed-in account on this server. + * Read from storage on every render and re-rendered when they are saved, so a change on + * the settings page is what the catalog shows next. + */ +export function useMediaPreferences(): MediaPreferences { + const scope = useRuntime().mediaApi.preferenceScope(); + const snapshot = useCallback(() => mediaPreferencesSnapshot(scope), [scope]); + return useSyncExternalStore(subscribeMediaPreferences, snapshot, snapshot); +} diff --git a/web/src/features/media/hooks/use-scroll-memory.ts b/web/src/features/media/hooks/use-scroll-memory.ts new file mode 100644 index 0000000..db2bebd --- /dev/null +++ b/web/src/features/media/hooks/use-scroll-memory.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react'; + +/** + * Remembers how far a media list was scrolled, per account and URL, so coming back to it + * (browser Back, Back after Forward, or the page's own back arrow, which links to the same + * URL) lands where the reader left instead of at the top. + * + * The scroll container is the page's `.media-screen` (MediaPageFrame). The position is kept + * up to date while the list is shown, so it is always the last one seen at that URL — there + * is nothing to consume, and leaving by any route keeps it. Only lists whose page/query is in + * the URL (docs/ui.md §3.5) can use it: the URL is the key. + */ +const positions = new Map(); +const LIMIT = 100; + +function remember(key: string, top: number) { + positions.delete(key); + positions.set(key, top); + if (positions.size > LIMIT) positions.delete(positions.keys().next().value!); +} + +export function savedScroll(key: string): number | undefined { + return positions.get(key); +} + +/** + * `anchor`: any element inside the page; `key`: account + URL; `ready`: the list for this key is + * rendered (restoring before that would be clamped by a short page). Returns `skipRestore`, for + * explicit in-page actions (new search, another page) that should not jump to an old position + * of the URL they lead to. + */ +export function useScrollMemory(anchor: RefObject, key: string | undefined, ready: boolean): () => void { + const restored = useRef(undefined); + const skip = useRef(false); + useEffect(() => { + if (!key || !ready) return; + const screen = anchor.current?.closest('.media-screen'); + if (!screen) return; + if (restored.current !== key) { + restored.current = key; + const top = skip.current ? undefined : positions.get(key); + skip.current = false; + if (top !== undefined) screen.scrollTop = top; + } + const record = () => remember(key, screen.scrollTop); + screen.addEventListener('scroll', record, { passive: true }); + return () => screen.removeEventListener('scroll', record); + }, [anchor, key, ready]); + return useCallback(() => { skip.current = true; }, []); +} diff --git a/web/src/features/media/pages/MediaCatalogPage.tsx b/web/src/features/media/pages/MediaCatalogPage.tsx index 34627b0..f66672f 100644 --- a/web/src/features/media/pages/MediaCatalogPage.tsx +++ b/web/src/features/media/pages/MediaCatalogPage.tsx @@ -1,6 +1,6 @@ import { Icon } from '../../../ui/icon.tsx'; import { ChannelMenu } from '../components/channel-menu.tsx'; -import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; @@ -13,28 +13,44 @@ import { usePlaybackStore } from '../stores/playback.store.ts'; import { EmptyMediaLibrary } from '../components/empty-library.tsx'; import { useAuthStore } from '../../../shared/stores/auth.store.ts'; import { MediaSelect } from '../components/select.tsx'; +import { CatalogTabs } from '../components/channel-navigation.tsx'; +import { useMediaPreferences } from '../hooks/use-media-preferences.ts'; + +/** Catalog page size: the server's browse/items limit. */ +const PAGE = 60; const countLabels: Record = { video: '项', movie: '部电影', series: '部剧集', album: '张专辑', artist: '位歌手', track: '首曲目', audiobook: '部有声书', narrator: '位演播者', }; export function MediaCatalogPage() { - const location = useLocation(); const runtime = useRuntime(), navigate = useNavigate(), channel = useMediaChannel(), { category } = useParams(), [params, setParams] = useSearchParams(); const categories = channelCategories[channel], current = categories.find(item => item.path === category) ?? categories[0]!; - const query = useQuery(catalogQuery(runtime, channel, current.kind, params)), libraries = useMediaLibraries(); + const preferences = useMediaPreferences(); + // 默认排序 (偏好设置 › 影音浏览) applies when the URL names no sort; an explicit one, even "default", wins. + const sort = params.get('sort') ?? preferences.sort ?? 'default'; + const effective = new URLSearchParams(params); + if (!params.has('sort') && sort !== 'default') effective.set('sort', sort); + const query = useQuery(catalogQuery(runtime, channel, current.kind, effective)), libraries = useMediaLibraries(); const playerActive = usePlaybackStore(state => state.active); const admin = useAuthStore(state => state.verifiedUser?.role === 'admin'); - const offset = Number(params.get('offset')) || 0; + const offset = Math.max(0, Number(params.get('offset')) || 0); const patch = (values: Record) => { const next = new URLSearchParams(params); for (const [key, value] of Object.entries(values)) value ? next.set(key, value) : next.delete(key); setParams(next); }; const channelLibraries = libraries.data?.items.filter(item => item.kind === channel) ?? []; const selectedFromUrl = params.get('library'); const selectedLibrary = selectedFromUrl ?? (channelLibraries.length === 1 ? channelLibraries[0]!.id : ''); - const emptyCatalog = !query.isPending && !query.error && query.data?.total === 0; + const total = query.data?.total ?? 0; + // Past the last page (a stale link, or the last items of the last page went away) clamps + // to the last page instead of showing an empty grid. + const pastEnd = !!query.data && offset > 0 && offset >= total; + const emptyCatalog = !query.isPending && !query.error && total === 0 && !pastEnd; // An empty channel without a configured library uses the focused legacy empty state; // there is no source selector or category toolbar to act on in that state. const hasLibraries = channelLibraries.length > 0; const emptyWithoutLibraries = emptyCatalog && !hasLibraries; + // 列表工具栏显示来源库: off hides the source selector — except while a deep link has narrowed + // the catalog to one of several libraries, which the reader must be able to see and clear. + const showLibrary = preferences.showLibraryName || (channelLibraries.length > 1 && !!selectedFromUrl); const libraryOptions = [{ value: '', label: '全部媒体库' }, ...(channelLibraries.map(item => ({ value: item.id, label: item.name })) )]; useEffect(() => { if (selectedFromUrl === null && channelLibraries.length === 1) { @@ -43,23 +59,22 @@ export function MediaCatalogPage() { setParams(next, { replace: true }); } }, [channelLibraries, params, selectedFromUrl, setParams]); + useEffect(() => { + if (!pastEnd) return; + const next = new URLSearchParams(params); + const last = total > 0 ? Math.floor((total - 1) / PAGE) * PAGE : 0; + if (last) next.set('offset', String(last)); else next.delete('offset'); + setParams(next, { replace: true }); + }, [pastEnd, params, setParams, total]); return }> {!emptyWithoutLibraries && <> - + } { void query.refetch(); }} /> - {!emptyCatalog && !playerActive && offset === 0 && (current.kind === 'video' || current.kind === 'album' || current.kind === 'audiobook') && item.id)} @@ -67,17 +82,17 @@ export function MediaCatalogPage() { />} {!emptyWithoutLibraries &&
- patch({ library: event.currentTarget.value, offset: '' })}> + {showLibrary && patch({ library: event.currentTarget.value, offset: '' })}> {libraryOptions.map(option => )} - + } {query.data && {query.data.total} {countLabels[current.kind] ?? '项'}}
- {params.get('sort') && params.get('sort') !== 'default' && } + {sort !== (preferences.sort ?? 'default') && }
-
@@ -86,10 +101,10 @@ export function MediaCatalogPage() {
} {/* After the toolbar: the empty state answers "this library/category has nothing", so the selector it refers to sits above it. */} {emptyCatalog && navigate(`/media/${channel}/settings/libraries/new`)} onManage={() => navigate(`/media/${channel}/settings/libraries`)} />} - {!emptyCatalog && query.data && <>{query.data.total > 60 &&