fix(web): media browsing, lists, player queue and query cache regressions

- 影音浏览 preferences finally take effect: cover density (phone 3/2 columns),
  default catalog sort, the continue card (channel and 首页) and the 来源库
  selector, read live from the per-device preferences.
- A catalog or favorites page past the end clamps to the last page instead of
  showing nothing / claiming there are no favorites.
- 演播者 shows the 有声书 category tabs (shared CatalogTabs).
- The channel ⋯ menu offers 媒体库管理 only to administrators.
- History and search restore their scroll on back (also back after forward);
  a detail opened from search returns to the search; browser Back between two
  searches shows the earlier search again.
- The player's queue re-renders after 上移 / 移除.
- Media writes invalidate the cached queries they change (editions, metadata,
  hierarchy, libraries, recognition…); item details are always rechecked, so
  刷新资源状态 sees a rescan.
- Aborts surface as ApiError('aborted') everywhere (including body reads), so a
  shared request aborted by one caller is retried for a live one and never shows
  "signal is aborted without reason".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-31 23:51:33 +00:00
1 parent a0a0fb5f2f
commit 4ea2ea6096
27 files changed
+916 -81

No files matched your search

+2 -2
View File
@@ -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=… |
+16 -4
View File
@@ -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<Session | null>;
@@ -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
+47 -17
View File
@@ -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');
+32
View File
@@ -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.
*/
+22 -3
View File
@@ -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<HttpResponse> {
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<HttpResponse> {
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
+6 -3
View File
@@ -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 || '';
@@ -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 <p className="media-empty muted">暂无内容</p>;
return <div className="media-grid">
return <div className="media-grid" data-density={density}>
{items.map(item => {
const subtitle = itemLabel(item);
return <Link className="media-tile" key={item.id} to={`/media/${channel ?? favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`}>
@@ -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 <ActionMenu className="media-icon-button" label="更多操作" items={[
{ label: '收藏', icon: <Icon name="star" size={16} />, onSelect: () => navigate('/media/favorites') },
{ label: '历史', icon: <Icon name="history" size={16} />, onSelect: () => navigate(`/media/${channel}/history`) },
{ label: '队列', icon: <Icon name="episodes" size={16} />, onSelect: () => navigate(`/media/${channel}/queue`) },
{ label: '媒体库管理', icon: <Icon name="library" size={16} />, onSelect: () => navigate(`/media/${channel}/settings/libraries`) },
...(admin ? [{ label: '媒体库管理', icon: <Icon name="library" size={16} />, onSelect: () => navigate(`/media/${channel}/settings/libraries`) }] : []),
]} />;
}
@@ -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 }) {
</nav>;
}
/**
* 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 <nav className="media-tabs" aria-label={`${channelLabels[channel]}分类`}>{categories.map((item, index) => {
const isDefault = index === 0;
// The first category answers at both the channel's bare path and its own one
// (/media/music redirects to /media/music/albums), so either marks it current.
const own = item.path ? `/media/${channel}/${item.path}` : `/media/${channel}`;
const selected = pathname === own || (isDefault && pathname === `/media/${channel}`);
return <Link key={item.path} className="media-tab-link" to={isDefault ? `/media/${channel}` : own} aria-current={selected ? 'page' : undefined}>{item.label}</Link>;
})}</nav>;
}
/**
* Every catalog tab on one line, channel by channel: 影视(首页 电影 剧集), 音乐(专辑
* 歌手 曲目), 有声书(有声书 演播者). A swipe past a channel's last category is
@@ -22,7 +22,8 @@ export function MediaFavorites({api,items,total,offset,scope,busy,onScope,onPage
<span className="media-favorite-copy"><strong title={item.title}>{item.title}</strong><small>{[kindNames[item.kind],itemLabel(item)].filter(Boolean).join(' · ')}</small></span>
<Icon name="chevron-right" size={18} />
</button>)}</div>
{!items.length&&<div className="media-personal-empty"><Icon name="favorite" size={32} /><p>{scope==='all'?'还没有收藏,打开作品详情即可收藏。':'暂无这类收藏。'}</p>{scope!=='all'&&<button onClick={()=>onScope('all')}>查看全部收藏</button>}</div>}
{/* No rows but a total means a page past the end (the page clamps it), not an empty list. */}
{!items.length&&!total&&<div className="media-personal-empty"><Icon name="favorite" size={32} /><p>{scope==='all'?'还没有收藏,打开作品详情即可收藏。':'暂无这类收藏。'}</p>{scope!=='all'&&<button onClick={()=>onScope('all')}>查看全部收藏</button>}</div>}
{total>60&&<nav className="media-toolbar" aria-label="收藏分页"><button disabled={busy||offset===0} onClick={()=>onPage(Math.max(0,offset-60))}>上一页</button><span>第 {Math.floor(offset/60)+1} 页</span><button disabled={busy||offset+60>=total} onClick={()=>onPage(offset+60)}>下一页</button></nav>}
</section>;
}
@@ -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 <><AudiobookChapters api={api} item={item} editionId={editionId} currentPartId={player.currentPartId} busy={busy} onEditionChange={id=>{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&&<p role="status">{notice}</p>}</>;
}
/** 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<number|null>(null);
const [searchOpen,setSearchOpen]=useState(player.currentPlaylist.length>10);
+14 -7
View File
@@ -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<string,string>|undefined;onLocationChange?:(params:Record<string,string>)=>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<string,string>|undefined;onLocationChange?:(params:Record<string,string>)=>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<HTMLElement>(null);
const scrollToRestore=useRef(initial?.scroll??null);
const scrollToRestore=useRef<number|null>(initialLocation?null:initial?.scroll??null);
const [input,setInput]=useState(initial?.input??'');
const [scope,setScope]=useState<MediaChannel|'all'>(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<unknown>();
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 <section ref={host} aria-label="影音搜索结果">
<form className="media-search" role="search" onSubmit={event=>{event.preventDefault();scrollToRestore.current=null;setQuery(input.trim());setOffset(0);change({q:input.trim(),offset:0});setAttempt(value=>value+1);}}>
<form className="media-search" role="search" onSubmit={event=>{event.preventDefault();skipRestore();scrollToRestore.current=null;setQuery(input.trim());setOffset(0);change({q:input.trim(),offset:0});setAttempt(value=>value+1);}}>
<input name="q" aria-label="搜索影音" placeholder="搜索作品、专辑、歌手或曲目" value={input} maxLength={200} onInput={event=>setInput(event.currentTarget.value)}/>
<button className="media-primary" type="submit" disabled={!input.trim()}>搜索</button>
</form>
<nav className="media-search-scopes" aria-label="搜索范围">{(['all','video','music','audiobook'] as const).map(value=><button key={value} type="button" aria-pressed={scope===value} data-scope={value} onClick={()=>{scrollToRestore.current=null;setScope(value);setOffset(0);change({scope:value,offset:0});}}>{value==='all'?'全部':labels[value]}</button>)}</nav>
<nav className="media-search-scopes" aria-label="搜索范围">{(['all','video','music','audiobook'] as const).map(value=><button key={value} type="button" aria-pressed={scope===value} data-scope={value} onClick={()=>{skipRestore();scrollToRestore.current=null;setScope(value);setOffset(0);change({scope:value,offset:0});}}>{value==='all'?'全部':labels[value]}</button>)}</nav>
{loaded&&!busy&&!error&&<p className="media-search-count media-visually-hidden" role="status">找到 {total} 项内容</p>}
{busy?<MediaLoading label="正在搜索…" layout="list"/>:error?<MediaScreenError fullPage error={errorCause} message={error} busy={busy} onRetry={()=>setAttempt(value=>value+1)}/>:!query||!items.length?<div className="media-search-empty"><Icon name="search" size={32} /><h2>{query?'没有找到相关内容':'搜索你的影音'}</h2><p>{query?'没有匹配内容,请调整搜索词或搜索范围。':'输入名称,搜索有权访问的媒体库。'}</p>{query&&<button onClick={()=>{setInput('');setQuery('');setOffset(0);change({q:'',offset:0});host.current?.querySelector<HTMLInputElement>('input')?.focus();}}>清空关键词</button>}</div>:<div className="media-search-results">{(['video','music','audiobook'] as const).map(group=>{
{busy?<MediaLoading label="正在搜索…" layout="list"/>:error?<MediaScreenError fullPage error={errorCause} message={error} busy={busy} onRetry={()=>setAttempt(value=>value+1)}/>:!query||!items.length?<div className="media-search-empty"><Icon name="search" size={32} /><h2>{query?'没有找到相关内容':'搜索你的影音'}</h2><p>{query?'没有匹配内容,请调整搜索词或搜索范围。':'输入名称,搜索有权访问的媒体库。'}</p>{query&&<button onClick={()=>{skipRestore();setInput('');setQuery('');setOffset(0);change({q:'',offset:0});host.current?.querySelector<HTMLInputElement>('input')?.focus();}}>清空关键词</button>}</div>:<div className="media-search-results">{(['video','music','audiobook'] as const).map(group=>{
const entries=items.filter(item=>item.channel===group);if(!entries.length)return null;
return <section key={group} className="media-search-group" aria-label={labels[group]+'搜索结果'}><h2>{labels[group]} <small>{entries.length}{total>60?' · 本页':''}</small></h2>{entries.map(item=><button className="media-search-result" key={item.id} onClick={()=>open(item)}><MediaCover api={api} item={item} square={item.channel==='music'}/><span className="media-search-copy"><strong title={item.title}>{item.title}</strong>{resultDescription(item)&&<small className="media-search-description" title={resultDescription(item)}>{resultDescription(item)}</small>}<small title={(kinds[item.kind]||item.kind)+' · '+item.libraryName}>{kinds[item.kind]||item.kind} · {item.libraryName}</small></span><Icon name="chevron-right" size={18} /></button>)}</section>;
})}</div>}
{total>60&&<div className="media-toolbar"><button disabled={busy||offset===0} onClick={()=>{setOffset(Math.max(0,offset-60));change({offset:Math.max(0,offset-60)});}}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(total/60)}</span><button disabled={busy||offset+60>=total} onClick={()=>{setOffset(offset+60);change({offset:offset+60});}}>下一页</button></div>}
{total>60&&<div className="media-toolbar"><button disabled={busy||offset===0} onClick={()=>{skipRestore();setOffset(Math.max(0,offset-60));change({offset:Math.max(0,offset-60)});}}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(total/60)}</span><button disabled={busy||offset+60>=total} onClick={()=>{skipRestore();setOffset(offset+60);change({offset:offset+60});}}>下一页</button></div>}
</section>;
}
@@ -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);
}
@@ -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<string, number>();
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<HTMLElement | null>, key: string | undefined, ready: boolean): () => void {
const restored = useRef<string | undefined>(undefined);
const skip = useRef(false);
useEffect(() => {
if (!key || !ready) return;
const screen = anchor.current?.closest<HTMLElement>('.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; }, []);
}
@@ -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<string, string> = {
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<string, string>) => { 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 <MediaPageFrame className="media-catalog-page" title={channelLabels[channel]} emptyChannel={emptyWithoutLibraries} actions={<>
<Link to="/media/search" className="media-icon-button" aria-label="搜索" title="搜索"><Icon name="search" size={18} /></Link>
<ChannelMenu channel={channel} />
</>}>
{!emptyWithoutLibraries && <>
<nav className="media-tabs" aria-label={`${channelLabels[channel]}分类`}>{categories.map(item => {
const defaultCategory = categories[0]!;
const isDefault = item.path === defaultCategory.path;
// The first category answers at both the channel's bare path and its own one
// (/media/music redirects to /media/music/albums), so either marks it current.
const own = `/media/${channel}/${item.path}`;
const selected = location.pathname === own || (isDefault && location.pathname === `/media/${channel}`);
return <Link key={item.path} className="media-tab-link" to={isDefault ? `/media/${channel}` : own} aria-current={selected ? 'page' : undefined}>{item.label}</Link>;
})}</nav>
<CatalogTabs channel={channel} />
</>}
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
{!emptyCatalog && !playerActive && offset === 0 && (current.kind === 'video' || current.kind === 'album' || current.kind === 'audiobook') && <ContinuePlaying
{preferences.showContinue && !emptyCatalog && !playerActive && offset === 0 && (current.kind === 'video' || current.kind === 'album' || current.kind === 'audiobook') && <ContinuePlaying
api={runtime.mediaApi}
libraryId={selectedLibrary}
libraryIds={channelLibraries.map(item => item.id)}
@@ -67,17 +82,17 @@ export function MediaCatalogPage() {
/>}
{!emptyWithoutLibraries && <div className="media-toolbar media-browse-tools">
<div className="media-library-filter">
<MediaSelect variant="plain" aria-label="来源库" value={selectedLibrary} onChange={event => patch({ library: event.currentTarget.value, offset: '' })}>
{showLibrary && <MediaSelect variant="plain" aria-label="来源库" value={selectedLibrary} onChange={event => patch({ library: event.currentTarget.value, offset: '' })}>
{libraryOptions.map(option => <option value={option.value} key={option.value}>{option.label}</option>)}
</MediaSelect>
</MediaSelect>}
{query.data && <span className="media-catalog-count">{query.data.total} {countLabels[current.kind] ?? '项'}</span>}
</div>
<div className="media-browse-actions">
<Link to={`/media/${channel}/folders${selectedLibrary ? `/${encodeURIComponent(selectedLibrary)}` : ''}`} className="media-icon-button" aria-label="打开文件夹" title="打开文件夹"><Icon name="folder-browse" size={18} /></Link>
<details className="media-browse-filters">
<summary aria-label="筛选与排序" title="筛选与排序"><Icon name="sliders" size={18} />{params.get('sort') && params.get('sort') !== 'default' && <i aria-label="已应用筛选" />}</summary>
<summary aria-label="筛选与排序" title="筛选与排序"><Icon name="sliders" size={18} />{sort !== (preferences.sort ?? 'default') && <i aria-label="已应用筛选" />}</summary>
<div className="media-browse-filter-panel">
<label>排序<MediaSelect aria-label="排序" value={params.get('sort') ?? 'default'} onChange={event => patch({ sort: event.currentTarget.value, offset: '' })}>
<label>排序<MediaSelect aria-label="排序" value={sort} onChange={event => patch({ sort: event.currentTarget.value, offset: '' })}>
<option value="default">默认顺序</option><option value="title-asc">名称升序</option><option value="title-desc">名称降序</option>
</MediaSelect></label>
</div>
@@ -86,10 +101,10 @@ export function MediaCatalogPage() {
</div>}
{/* After the toolbar: the empty state answers "this library/category has nothing", so the selector it refers to sits above it. */}
{emptyCatalog && <EmptyMediaLibrary channel={channel} hasLibraries={hasLibraries} admin={admin} category={current.label} onCreate={() => navigate(`/media/${channel}/settings/libraries/new`)} onManage={() => navigate(`/media/${channel}/settings/libraries`)} />}
{!emptyCatalog && query.data && <><MediaItemGrid items={query.data.items} channel={channel} />{query.data.total > 60 && <nav className="media-toolbar media-pagination" aria-label="分页">
<button type="button" disabled={offset === 0 || query.isFetching} onClick={() => patch({ offset: offset >= 60 ? String(offset - 60) : '' })}>上一页</button>
<span>{Math.floor(offset / 60) + 1} / {Math.ceil(query.data.total / 60)}</span>
<button type="button" disabled={offset + 60 >= query.data.total || query.isFetching} onClick={() => patch({ offset: String(offset + 60) })}>下一页</button>
{!emptyCatalog && !pastEnd && query.data && <><MediaItemGrid items={query.data.items} channel={channel} density={preferences.density} />{total > PAGE && <nav className="media-toolbar media-pagination" aria-label="分页">
<button type="button" disabled={offset === 0 || query.isFetching} onClick={() => patch({ offset: offset >= PAGE ? String(offset - PAGE) : '' })}>上一页</button>
<span>{Math.floor(offset / PAGE) + 1} / {Math.ceil(total / PAGE)}</span>
<button type="button" disabled={offset + PAGE >= total || query.isFetching} onClick={() => patch({ offset: String(offset + PAGE) })}>下一页</button>
</nav>}</>}
</MediaPageFrame>;
}
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
@@ -13,13 +14,19 @@ export function MediaFavoritesPage() {
const query = useQuery(favoritesQuery(runtime, queryParams));
const scope = favoriteScope(queryParams.get('scope') ?? undefined);
const offset = Math.max(0, Number(params.get('offset')) || 0);
const patch = (values: Record<string, string>) => {
const patch = (values: Record<string, string>, replace = false) => {
const next = new URLSearchParams(params);
for (const [key, value] of Object.entries(values)) value ? next.set(key, value) : next.delete(key);
setParams(next);
setParams(next, { replace });
};
// Past the last page (a stale link, or favorites removed elsewhere) clamps to the last page instead of
// claiming there are no favorites; replace, so Back does not return to the empty page (docs/ui.md §3.5).
const total = query.data?.total;
const pastEnd = total !== undefined && offset > 0 && offset >= total;
const lastPage = total ? Math.floor((total - 1) / 60) * 60 : 0;
useEffect(() => { if (pastEnd) patch({ offset: lastPage ? String(lastPage) : '' }, true); });
return <MediaPageFrame className="media-secondary-page" title="我的收藏" backLabel="返回影视">
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
{query.data && <MediaFavorites api={runtime.mediaApi} items={query.data.items} total={query.data.total} offset={offset} scope={scope} busy={query.isFetching} onScope={value => patch({ scope: value, offset: '' })} onPage={value => patch({ offset: String(value) })} onOpen={item => navigate(`/media/${favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`, { state: { returnTo: location.pathname + location.search } })} />}
{query.data && !pastEnd && <MediaFavorites api={runtime.mediaApi} items={query.data.items} total={query.data.total} offset={offset} scope={scope} busy={query.isFetching} onScope={value => patch({ scope: value, offset: '' })} onPage={value => patch({ offset: String(value) })} onOpen={item => navigate(`/media/${favoriteChannel(item)}/items/${encodeURIComponent(item.id)}`, { state: { returnTo: location.pathname + location.search } })} />}
</MediaPageFrame>;
}
@@ -1,4 +1,5 @@
import { Icon } from '../../../ui/icon.tsx';
import { useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
@@ -6,10 +7,15 @@ import { historyQuery, type Activity } from '../queries/media.queries.ts';
import { channelLabels, useMediaChannel } from '../hooks/use-media-channel.ts';
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
import { historyDay, historyPosition } from '../components/history-labels.ts';
import { useScrollMemory } from '../hooks/use-scroll-memory.ts';
export function MediaHistoryPage() {
const runtime = useRuntime(), channel = useMediaChannel(), location = useLocation(), [params, setParams] = useSearchParams();
const query = useQuery(historyQuery(runtime, channel, params));
const list = useRef<HTMLElement>(null);
// Back from a detail (browser Back or its back arrow, which links here) returns to the same row.
const skipRestore = useScrollMemory(list, runtime.mediaApi.preferenceScope() + '\0' + location.pathname + location.search, query.data !== undefined);
const page = (offset: number) => { skipRestore(); setParams({ offset: offset ? String(offset) : '' }); };
const play = useMutation({ mutationFn: async (row: Activity) => {
const detail = await runtime.queryClient.ensureQueryData({ queryKey: ['media', runtime.mediaApi.preferenceScope(), 'detail', row.itemId], queryFn: ({ signal }) => runtime.mediaApi.detail(row.itemId, signal) });
const parts = detail.editions.find(edition => edition.parts.some(part => part.id === row.partId))?.parts.filter(part => part.available) ?? [];
@@ -17,16 +23,16 @@ export function MediaHistoryPage() {
if (index < 0) throw new Error('资源已不可用,请在作品详情中选择其他版本。');
await runtime.player.play(parts.map(part => ({ part, title: detail.title, video: channel === 'video' })), index);
} });
return <MediaPageFrame className="media-secondary-page" title="播放历史" subtitle={channelLabels[channel]}><section className="media-history-page"><QueryFeedback pending={query.isPending} error={query.error ?? play.error} retry={() => { void query.refetch(); }} />
return <MediaPageFrame className="media-secondary-page" title="播放历史" subtitle={channelLabels[channel]}><section ref={list} className="media-history-page"><QueryFeedback pending={query.isPending} error={query.error ?? play.error} retry={() => { void query.refetch(); }} />
<div className="media-history-list">{query.data?.items.map((row, index, rows) => <div key={row.id || row.partId}>
{(index === 0 || historyDay(row.updatedAt) !== historyDay(rows[index - 1]?.updatedAt)) && <h2 className="media-history-date">{historyDay(row.updatedAt)}</h2>}
<article className="media-row media-history-row"><div><Link className="media-personal-title" to={`/media/${channel}/items/${row.itemId}`} state={{ returnTo: location.pathname + location.search }}>{row.title}</Link><small>{[row.partTitle, row.editionLabel].filter(Boolean).join(' · ')}{row.completed ? ' · 已完成' : row.position !== undefined ? ` · ${historyPosition(row.position, row.start)}` : ''}</small>{!row.available && <small className="media-history-unavailable">资源不可用</small>}</div><button className="media-history-play" aria-label="播放" title={row.available ? '播放' : '资源不可用'} disabled={!row.available || play.isPending} onClick={() => play.mutate(row)}><Icon name="play" size={18} /></button></article>
</div>)}</div>
{query.data && query.data.items.length === 0 && <div className="media-personal-empty"><Icon name="clock" size={32} /><p>暂无记录。</p></div>}
{query.data && query.data.total > 60 && <nav className="media-toolbar media-pagination" aria-label="分页">
<button type="button" disabled={Number(params.get('offset') ?? 0) <= 0 || query.isFetching} onClick={() => setParams({ offset: Math.max(0, Number(params.get('offset') ?? 0) - 60) ? String(Math.max(0, Number(params.get('offset') ?? 0) - 60)) : '' })}>上一页</button>
<button type="button" disabled={Number(params.get('offset') ?? 0) <= 0 || query.isFetching} onClick={() => page(Math.max(0, Number(params.get('offset') ?? 0) - 60))}>上一页</button>
<span>{Math.floor(Number(params.get('offset') ?? 0) / 60) + 1} / {Math.ceil(query.data.total / 60)}</span>
<button type="button" disabled={Number(params.get('offset') ?? 0) + 60 >= query.data.total || query.isFetching} onClick={() => setParams({ offset: String(Number(params.get('offset') ?? 0) + 60) })}>下一页</button>
<button type="button" disabled={Number(params.get('offset') ?? 0) + 60 >= query.data.total || query.isFetching} onClick={() => page(Number(params.get('offset') ?? 0) + 60)}>下一页</button>
</nav>}
</section>
</MediaPageFrame>;
@@ -11,6 +11,7 @@ import { Narrators, type NarratorLocation } from '../components/narrators.tsx';
import type { MediaChannel, Part } from '../api/media-api.ts';
import { MediaSelect } from '../components/select.tsx';
import { EmptyMediaLibrary } from '../components/empty-library.tsx';
import { CatalogTabs } from '../components/channel-navigation.tsx';
/**
* Dedicated narrator workspace. Narrators are backed by the specialised
@@ -69,6 +70,10 @@ export function MediaNarratorsPage() {
<ChannelMenu channel="audiobook" />
</>;
return <MediaPageFrame className="media-catalog-page" title="有声书" actions={actions} emptyChannel={!libraries.isPending && !libraries.error && !libraryId}>
{/* 演播者 is 有声书's second category: the directory carries the same tab row as the catalog.
A narrator's own page is a push page with its back button instead. Without any
library the channel is one empty page and shows no tabs, as in the catalog. */}
{!routeName && libraryId && <CatalogTabs channel="audiobook" />}
<QueryFeedback pending={libraries.isPending} error={libraries.error} retry={() => { void libraries.refetch(); }} />
{!libraries.isPending && !libraries.error && !libraryId && <EmptyMediaLibrary channel="audiobook" hasLibraries={false} admin={role === 'admin'} category="演播者" onCreate={() => navigate('/media/audiobook/settings/libraries/new')} onManage={() => navigate('/media/audiobook/settings/libraries')} />}
{!libraries.isPending && !libraries.error && libraryId && <Narrators
@@ -1,17 +1,26 @@
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useRef } from 'react';
import { useLocation, useNavigationType, useSearchParams, useNavigate } from 'react-router-dom';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { MediaSearch } from '../components/search.tsx';
import { MediaPageFrame } from '../components/MediaPageFrame.tsx';
export function MediaSearchPage() {
const runtime = useRuntime(), navigate = useNavigate(), [params, setParams] = useSearchParams();
const runtime = useRuntime(), navigate = useNavigate(), location = useLocation(), [params, setParams] = useSearchParams();
// The URL (query, scope, page) is the search; a result's back arrow returns to it, and so does the scroll.
const here = location.pathname + location.search;
// The search reads the URL when it mounts. Its own changes push new entries and keep it
// mounted; browser Back/Forward (POP) between two searches remounts it on that entry's URL.
const navigationType = useNavigationType(), seen = useRef({ key: location.key, mount: 0 });
if (seen.current.key !== location.key) seen.current = { key: location.key, mount: seen.current.mount + (navigationType === 'POP' ? 1 : 0) };
return <MediaPageFrame className="media-secondary-page" title="搜索" backLabel="返回搜索来源">
<MediaSearch
key={seen.current.mount}
api={runtime.mediaApi}
channel="video"
navigate={(channel, id) => navigate(`/media/${channel}/items/${encodeURIComponent(id)}`)}
navigate={(channel, id) => navigate(`/media/${channel}/items/${encodeURIComponent(id)}`, { state: { returnTo: here } })}
initialLocation={{ q: params.get('q') ?? '', scope: params.get('scope') ?? 'all', offset: params.get('offset') ?? '0' }}
onLocationChange={next => setParams(next)}
scrollKey={runtime.mediaApi.preferenceScope() + '\0' + here}
/>
</MediaPageFrame>;
}
+34 -3
View File
@@ -1,15 +1,46 @@
export interface MediaPreferences {density:'compact'|'comfortable';showContinue:boolean;showLibraryName:boolean;sort?:'default'|'title-asc'|'title-desc'}
export const defaultMediaPreferences:MediaPreferences={density:'compact',showContinue:true,showLibraryName:true};
const key=(scope:string)=>'reader.media.preferences.v1:'+scope;
const prefix='reader.media.preferences.v1:';
const key=(scope:string)=>prefix+scope;
function validated(value:unknown):MediaPreferences {
const raw=value&&typeof value==='object'?value as Partial<MediaPreferences>:{};
return {density:raw.density==='comfortable'?'comfortable':'compact',showContinue:typeof raw.showContinue==='boolean'?raw.showContinue:true,showLibraryName:typeof raw.showLibraryName==='boolean'?raw.showLibraryName:true,...(raw.sort==='title-asc'||raw.sort==='title-desc'?{sort:raw.sort}:{})};
}
export function readMediaPreferences(scope:string):MediaPreferences {
try{return validated(JSON.parse(localStorage.getItem(key(scope))??'null'));}catch{return {...defaultMediaPreferences};}
function stored(scope:string):string|null {
try{return localStorage.getItem(key(scope));}catch{return null;}
}
function parse(raw:string|null):MediaPreferences {
try{return validated(JSON.parse(raw??'null'));}catch{return {...defaultMediaPreferences};}
}
export function readMediaPreferences(scope:string):MediaPreferences {
return parse(stored(scope));
}
const listeners=new Set<()=>void>();
export function saveMediaPreferences(scope:string,value:MediaPreferences):MediaPreferences {
const settings=validated(value);
try{localStorage.setItem(key(scope),JSON.stringify(settings));}catch{throw new Error('设备无法保存影音偏好,请检查浏览器存储空间或隐私设置。');}
for(const listener of listeners)listener();
return settings;
}
/**
* Change notifications for the media screens that apply these preferences: a save in
* this window, or one in another tab (the `storage` event). Readers re-read on notify,
* so nothing holds a stale copy for the session.
*/
export function subscribeMediaPreferences(listener:()=>void):()=>void {
listeners.add(listener);
const onStorage=(event:StorageEvent)=>{if(event.key===null||event.key.startsWith(prefix))listener();};
if(typeof window!=='undefined')window.addEventListener('storage',onStorage);
return ()=>{listeners.delete(listener);if(typeof window!=='undefined')window.removeEventListener('storage',onStorage);};
}
// A stable snapshot per stored string, as useSyncExternalStore requires.
const snapshots=new Map<string,{raw:string|null;value:MediaPreferences}>();
export function mediaPreferencesSnapshot(scope:string):MediaPreferences {
const raw=stored(scope),cached=snapshots.get(scope);
if(cached&&cached.raw===raw)return cached.value;
const value=parse(raw);snapshots.set(scope,{raw,value});
return value;
}
+3
View File
@@ -12,6 +12,8 @@
.media-screen .media-browse-tools {min-height:44px}
.media-screen .media-browse-tools :is(button,select) {min-height:44px}
.media-screen .media-grid {grid-template-columns:repeat(6,minmax(0,1fr));gap:20px}
/* 封面密度 (偏好设置 › 影音浏览): comfortable trades a column for larger covers. */
.media-screen .media-grid[data-density=comfortable] {grid-template-columns:repeat(5,minmax(0,1fr))}
.media-screen .media-tile {padding:0;text-align:left;border-radius:0;min-width:0}
/* Same weight as the shelf's .book-card .title, so a cover title reads the same in every channel. */
.media-screen .media-tile strong {display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font-size:var(--text-label);font-weight:520;line-height:1.5;margin-top:8px}
@@ -485,6 +487,7 @@
.media-screen .media-heading:has(.media-mobile-title) {grid-template-rows:56px 44px;min-height:100px}
.media-screen .media-mobile-title {height:56px;font-size:var(--text-title)}
.media-screen .media-grid {grid-template-columns:repeat(3,minmax(0,1fr));gap:20px 12px}
.media-screen .media-grid[data-density=comfortable] {grid-template-columns:repeat(2,minmax(0,1fr))}
.media-detail-page .media-hero {gap:20px}
.media-detail-page .media-hero>.media-cover {width:106px;min-width:106px}
.media-detail-page .media-detail-heading h1 {font-size:var(--text-display)}
+53 -2
View File
@@ -1,5 +1,7 @@
import { expect, it, vi } from 'vitest';
import { combineAbortSignals } from '../src/core/abort.ts';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { abortFailure, combineAbortSignals, isAbortError } from '../src/core/abort.ts';
import { ApiError } from '../src/api/errors.ts';
import { FetchTransport } from '../src/core/fetch-transport.ts';
it.each([0, 1])('forwards cancellation from input %s and removes all listeners', index => {
const inputs = [new AbortController(), new AbortController()];
@@ -35,3 +37,52 @@ it('releases listeners after success without aborting or retaining duplicate inp
expect(combined.signal.aborted).toBe(false);
expect(add).toHaveBeenCalledOnce(); expect(remove).toHaveBeenCalledOnce();
});
describe('abort failures surface as ApiError(aborted)', () => {
afterEach(() => { vi.unstubAllGlobals(); });
const transport = () => new FetchTransport(() => 'http://server');
it('maps a raw AbortError and the reason of a signal aborted without one', () => {
const controller = new AbortController();
controller.abort();
expect(isAbortError(controller.signal.reason)).toBe(true);
expect(abortFailure(controller.signal.reason)?.kind).toBe('aborted');
expect(abortFailure(new Error('custom'), controller.signal)?.kind).toBe('aborted');
expect(abortFailure(new TypeError('Failed to fetch'))).toBeNull();
expect(abortFailure(new ApiError('server', 'boom'), controller.signal)).toBeNull();
});
it('treats a timed-out signal as a connectivity failure, not a cancellation', () => {
const controller = new AbortController();
controller.abort(new DOMException('timed out', 'TimeoutError'));
expect(abortFailure(controller.signal.reason, controller.signal)?.kind).toBe('offline');
});
it('maps an abort while the response body is read', async () => {
const controller = new AbortController();
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }),
text: async () => { controller.abort(); throw controller.signal.reason; },
})));
const error = await transport().send({ url: '/api/v1/media/browse', method: 'GET', headers: {}, signal: controller.signal }).catch(value => value);
expect(error).toBeInstanceOf(ApiError);
expect(error.kind).toBe('aborted');
});
it('maps a fetch rejected with a custom abort reason', async () => {
const controller = new AbortController();
vi.stubGlobal('fetch', vi.fn(async () => { controller.abort(new Error('screen replaced')); throw controller.signal.reason; }));
const error = await transport().send({ url: '/api/v1/media/browse', method: 'GET', headers: {}, signal: controller.signal }).catch(value => value);
expect(error.kind).toBe('aborted');
});
it('maps a binary body read aborted mid-way', async () => {
const controller = new AbortController();
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true, status: 200, headers: new Headers(),
arrayBuffer: async () => { controller.abort(); throw new DOMException('signal is aborted without reason', 'AbortError'); },
})));
const error = await transport().send({ url: '/cover', method: 'GET', headers: {}, binary: true, signal: controller.signal }).catch(value => value);
expect(error.kind).toBe('aborted');
});
});
+111
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { ReaderApi } from '../src/api/client.ts';
import { ApiError } from '../src/api/errors.ts';
import { affectedNavigationQuery } from '../src/api/query.ts';
import type { Session } from '../src/api/types.ts';
import { FakeTransport, makePlatform } from './helpers/env.ts';
@@ -84,3 +85,113 @@ describe('a shared in-flight navigation query', () => {
expect((await live).items).toEqual(['ok']);
});
});
describe('media writes invalidate the navigation they change', () => {
const detail = '/api/v1/media/items/album-1';
const lists = ['/api/v1/media/browse?channel=music&kind=album', '/api/v1/media/search?query=a', '/api/v1/media/libraries/lib-1/items?kind=track', '/api/v1/media/favorites?offset=0', detail, detail + '/children'];
it.each([
'/api/v1/media/editions/ed-1', '/api/v1/media/editions/ed-1/item', '/api/v1/media/editions/ed-1/new-item',
'/api/v1/media/editions/ed-1/split', '/api/v1/media/editions/ed-1/merge', '/api/v1/media/editions/ed-1/order',
'/api/v1/media/items/track-1/metadata', '/api/v1/media/items/track-1/match', '/api/v1/media/items/track-1/auto-match',
'/api/v1/media/items/track-1/hierarchy', '/api/v1/media/items/track-1/parent',
'/api/v1/media/libraries/lib-1/missing-resources/cleanup', '/api/v1/media/libraries/lib-1/recognition-apply',
'/api/v1/media/libraries/lib-1/recognition-rule', '/api/v1/media/libraries/lib-1/scan', '/api/v1/media/libraries/lib-1/access',
'/api/v1/media/scan-jobs', '/api/v1/media/scrape-jobs',
])('%s refreshes item details and every catalog list', mutation => {
for (const query of lists) expect(affectedNavigationQuery(mutation, query)).toBe(true);
expect(affectedNavigationQuery(mutation, '/api/v1/books')).toBe(false);
});
it.each([
'/api/v1/media/playback', '/api/v1/media/playback/s-1/progress', '/api/v1/media/background/playback/s-1/progress',
'/api/v1/media/background-grants', '/api/v1/media/items/track-1/matches', '/api/v1/media/libraries/lib-1/recognition-preview',
'/api/v1/media/queue/preview', '/api/v1/media/jobs/j-1/cancel', '/api/v1/media/scan-jobs/j-1', '/api/v1/media/scrape-jobs/j-1/cancel',
'/api/v1/media/ai-scan-jobs', '/api/v1/media/ai-scan-jobs/j-1',
])('%s keeps the catalog cache', mutation => {
for (const query of lists) expect(affectedNavigationQuery(mutation, query)).toBe(false);
});
it('keeps playback, queue and favorite invalidation narrow', () => {
expect(affectedNavigationQuery('/api/v1/media/playback/s-1/progress', '/api/v1/media/history?channel=music')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/queue/q-1/move', '/api/v1/media/queue')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/queue/q-1/move', '/api/v1/media/browse?channel=music')).toBe(false);
expect(affectedNavigationQuery('/api/v1/media/items/track-1/favorite', '/api/v1/media/favorites?offset=0')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/items/track-1/favorite', '/api/v1/media/items/track-1/favorite')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/items/track-1/favorite', '/api/v1/media/browse?channel=music')).toBe(false);
expect(affectedNavigationQuery('/api/v1/media/libraries', '/api/v1/media/libraries')).toBe(true);
expect(affectedNavigationQuery('/api/v1/media/libraries', '/api/v1/media/browse?channel=music')).toBe(false);
});
it('serves the edited catalog after an edition merge', async () => {
const transport = new FakeTransport();
let version = 1;
transport.respondWith(request => request.method === 'GET'
? { status: 200, headers: {}, json: { items: [{ id: 'album-1', version }], total: 1 } }
: (version += 1, { status: 200, headers: {}, json: { id: 'album-1' } }));
const api = await apiWith(transport);
const browse = '/api/v1/media/browse?channel=music&kind=album';
await api.mediaRequest(browse);
await api.mediaRequest('/api/v1/media/editions/ed-1/merge', 'POST', { expectedRevision: 'a', targetEditionId: 'ed-2', targetRevision: 'b' });
expect((await api.mediaRequest<{ items: Array<{ version: number }> }>(browse)).items[0]!.version).toBe(2);
});
it('revalidates item details on every read so 刷新资源状态 sees a rescan', async () => {
const transport = new FakeTransport();
let available = false;
transport.respondWith(() => ({ status: 200, headers: {}, json: { id: 'album-1', editions: [{ parts: [{ available }] }] } }));
const api = await apiWith(transport);
type D = { editions: Array<{ parts: Array<{ available: boolean }> }> };
expect((await api.mediaRequest<D>(detail)).editions[0]!.parts[0]!.available).toBe(false);
available = true; // restored on disk and rescanned; no write from this client
expect((await api.mediaRequest<D>(detail)).editions[0]!.parts[0]!.available).toBe(true);
// Concurrent readers still share one request.
const before = transport.requests.length;
await Promise.all([api.mediaRequest(detail), api.mediaRequest(detail)]);
expect(transport.requests.length - before).toBe(1);
// The catalog lists stay cached.
await api.mediaRequest('/api/v1/media/browse?channel=music');
await api.mediaRequest('/api/v1/media/browse?channel=music');
expect(transport.countMatching(request => request.url.startsWith('/api/v1/media/browse'))).toBe(1);
});
});
describe('aborts never escape as raw DOMExceptions', () => {
it('retries for a live caller when the shared request failed with a raw AbortError', async () => {
let calls = 0;
const transport = { async send(request: { signal?: AbortSignal }) {
calls += 1;
if (calls === 1) {
// A transport that rejects with the signal's own reason ("signal is aborted without reason").
await new Promise((_, reject) => request.signal?.addEventListener('abort', () => reject(request.signal!.reason)));
}
return { status: 200, headers: {}, json: { items: ['ok'] } };
} } as any;
const api = new ReaderApi(makePlatform(transport), { load: async () => session, save: async () => {}, clear: async () => {} });
await api.restore();
const first = new AbortController();
const gone = api.mediaRequest('/api/v1/media/browse?channel=music', 'GET', undefined, { signal: first.signal }).catch(error => error);
const live = api.mediaRequest<{ items: string[] }>('/api/v1/media/browse?channel=music');
await vi.waitFor(() => expect(calls).toBe(1));
first.abort();
const error = await gone as ApiError;
expect(error).toBeInstanceOf(ApiError);
expect(error.kind).toBe('aborted');
expect((await live).items).toEqual(['ok']);
});
it('reports a non-navigation request aborted by its caller as ApiError(aborted)', async () => {
const transport = { async send(request: { signal?: AbortSignal }) {
await new Promise((_, reject) => request.signal?.addEventListener('abort', () => reject(request.signal!.reason)));
return { status: 200, headers: {} };
} } as any;
const api = new ReaderApi(makePlatform(transport), { load: async () => session, save: async () => {}, clear: async () => {} });
await api.restore();
const controller = new AbortController();
const pending = api.mediaRequest('/api/v1/media/scan-jobs', 'GET', undefined, { signal: controller.signal }).catch(error => error);
await Promise.resolve();
controller.abort();
const error = await pending as ApiError;
expect(error).toBeInstanceOf(ApiError);
expect(error.kind).toBe('aborted');
});
});
+14 -1
View File
@@ -7,6 +7,7 @@ import { render } from '../src/shared/ui/render-root.ts';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
import { HomePage, activityProgress, greeting } from '../src/features/home/pages/HomePage.tsx';
import { saveMediaPreferences } from '../src/features/media/services/preferences.ts';
const root = document.createElement('div');
document.body.append(root);
@@ -47,7 +48,7 @@ function mount({ reading = [book], video = [activity({ title: '第 3 集', updat
}
const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
afterEach(() => { act(() => render(null, root)); useAuthStore.setState({ session: null, verifiedUser: null }); });
afterEach(() => { act(() => render(null, root)); useAuthStore.setState({ session: null, verifiedUser: null }); localStorage.clear(); });
describe('首页', () => {
it('greets by time of day', () => {
@@ -119,4 +120,16 @@ describe('首页', () => {
expect(root.querySelector('a.home-resume')).toBeNull();
expect(root.querySelector('.home-empty a')?.getAttribute('href')).toBe('/shelf');
});
it('leaves out media continue cards when 首页显示最近续播 is off, and brings them back when it is turned on', async () => {
useAuthStore.setState({ verifiedUser: user as any });
saveMediaPreferences('home-test', { density: 'compact', showContinue: false, showLibraryName: true });
const runtime = mount();
await settle();
expect([...root.querySelectorAll<HTMLAnchorElement>('a.home-resume')].map(card => card.dataset.channel)).toEqual(['read']);
expect(runtime.mediaApi.request.mock.calls.some(([path]: [string]) => path.startsWith('history'))).toBe(false);
act(() => { saveMediaPreferences('home-test', { density: 'compact', showContinue: true, showLibraryName: true }); });
await settle();
expect([...root.querySelectorAll<HTMLAnchorElement>('a.home-resume')].map(card => card.dataset.channel)).toEqual(['video', 'read']);
});
});
+194
View File
@@ -0,0 +1,194 @@
// @vitest-environment jsdom
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '../src/shared/ui/render-root.ts';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { useAuthStore } from '../src/shared/stores/auth.store.ts';
import { saveMediaPreferences, type MediaPreferences } from '../src/features/media/services/preferences.ts';
import { MediaCatalogPage } from '../src/features/media/pages/MediaCatalogPage.tsx';
import { MediaNarratorsPage } from '../src/features/media/pages/MediaNarratorsPage.tsx';
import { ChannelMenu } from '../src/features/media/components/channel-menu.tsx';
const state = vi.hoisted(() => ({
libraries: [] as Array<{ id: string; name: string; kind: string }>,
total: 3,
sorts: [] as string[],
}));
vi.mock('../src/features/media/queries/media.queries.ts', () => ({
catalogQuery: (_runtime: unknown, channel: string, kind: string, params: URLSearchParams) => ({
queryKey: ['catalog-test', channel, kind, params.toString()],
queryFn: async () => {
state.sorts.push(params.get('sort') ?? 'default');
const offset = Number(params.get('offset')) || 0;
const count = Math.max(0, Math.min(60, state.total - offset));
return { total: state.total, items: Array.from({ length: count }, (_, index) => ({ id: `i${offset + index}`, libraryId: 'a', kind, title: `作品 ${offset + index}`, parentId: null, metadata: {}, overrides: {} })) };
},
}),
useMediaLibraries: () => ({ data: { items: state.libraries } }),
librariesQuery: () => ({ queryKey: ['libraries-test'], queryFn: async () => ({ items: state.libraries }) }),
}));
const root = document.createElement('div');
document.body.append(root);
const scope = 'catalog-page-test';
const request = vi.fn(async (_path: string) => ({ items: [] }));
const runtime = {
mediaApi: { preferenceScope: () => scope, request, cover: async () => null, narrators: async () => ({ items: [], total: 0 }) },
player: { play: vi.fn() },
} as any;
function Probe() {
const location = useLocation();
return <output data-testid="location">{location.pathname + location.search}</output>;
}
const where = () => root.querySelector('[data-testid=location]')!.textContent;
function mount(entry: string) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
act(() => render(
<RuntimeContext.Provider value={runtime}><QueryClientProvider client={client}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/media/audiobook/narrators" element={<MediaNarratorsPage />} />
<Route path="/media/:channel/:category?" element={<MediaCatalogPage />} />
</Routes>
<Probe />
</MemoryRouter>
</QueryClientProvider></RuntimeContext.Provider>,
root,
));
}
async function settle(done: () => boolean) {
for (let i = 0; i < 50 && !done(); i++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
expect(done()).toBe(true);
}
const prefer = (patch: Partial<MediaPreferences>) => saveMediaPreferences(scope, { density: 'compact', showContinue: true, showLibraryName: true, ...patch });
const tiles = () => root.querySelectorAll('.media-grid .media-tile').length;
afterEach(() => {
act(() => render(null, root));
localStorage.clear();
useAuthStore.setState({ session: null, verifiedUser: null });
state.libraries = []; state.total = 3; state.sorts = [];
request.mockClear();
});
describe('影音浏览 preferences on the catalog', () => {
it('draws the grid in the chosen 封面密度 and follows a change made while it is open', async () => {
state.libraries = [{ id: 'a', name: '音乐库', kind: 'music' }];
prefer({ density: 'comfortable' });
mount('/media/music/albums?library=a');
await settle(() => tiles() === 3);
expect(root.querySelector('.media-grid')!.getAttribute('data-density')).toBe('comfortable');
act(() => { prefer({ density: 'compact' }); });
expect(root.querySelector('.media-grid')!.getAttribute('data-density')).toBe('compact');
});
it('uses 默认排序 when the URL names no sort, and an explicit sort when it does', async () => {
state.libraries = [{ id: 'a', name: '音乐库', kind: 'music' }];
prefer({ sort: 'title-desc' });
mount('/media/music/albums?library=a');
await settle(() => tiles() === 3);
expect(state.sorts).toEqual(['title-desc']);
// The preference is the baseline, so it is not flagged as an applied filter.
expect(root.querySelector('[aria-label="已应用筛选"]')).toBeNull();
act(() => render(null, root));
state.sorts = [];
mount('/media/music/albums?library=a&sort=default');
await settle(() => tiles() === 3);
expect(state.sorts).toEqual(['default']);
expect(root.querySelector('[aria-label="已应用筛选"]')).not.toBeNull();
});
it('shows the continue card only when 首页显示最近续播 is on', async () => {
state.libraries = [{ id: 'a', name: '片库', kind: 'video' }];
prefer({ showContinue: false });
mount('/media/video?library=a');
await settle(() => tiles() === 3);
expect(request.mock.calls.some(([path]) => String(path).startsWith('history'))).toBe(false);
act(() => { prefer({ showContinue: true }); });
await settle(() => request.mock.calls.some(([path]) => String(path).startsWith('history')));
});
it('hides the 来源库 selector when 列表工具栏显示来源库 is off, unless a link has narrowed to one of several', async () => {
state.libraries = [{ id: 'a', name: '音乐库', kind: 'music' }, { id: 'b', name: '另一个库', kind: 'music' }];
prefer({ showLibraryName: false });
mount('/media/music/albums');
await settle(() => tiles() === 3);
expect(root.querySelector('.media-library-filter [aria-label="来源库"]')).toBeNull();
expect(root.querySelector('.media-catalog-count')?.textContent).toBe('3 张专辑');
act(() => render(null, root));
mount('/media/music/albums?library=b');
await settle(() => tiles() === 3);
expect(root.querySelector('.media-library-filter [aria-label="来源库"]')).not.toBeNull();
act(() => render(null, root));
prefer({ showLibraryName: true });
mount('/media/music/albums');
await settle(() => tiles() === 3);
expect(root.querySelector('.media-library-filter [aria-label="来源库"]')).not.toBeNull();
});
});
describe('catalog pagination', () => {
it('clamps a page past the end to the last page, replacing the history entry', async () => {
state.libraries = [{ id: 'a', name: '音乐库', kind: 'music' }];
state.total = 130;
mount('/media/music/tracks?library=a&offset=180');
await settle(() => where() === '/media/music/tracks?library=a&offset=120' && tiles() === 10);
expect(root.querySelector('.media-pagination span')?.textContent).toBe('3 / 3');
expect(root.querySelector('.media-empty')).toBeNull();
});
it('clamps to the first page when everything went away, which then says the library is empty', async () => {
state.libraries = [{ id: 'a', name: '音乐库', kind: 'music' }];
state.total = 0;
mount('/media/music/tracks?library=a&offset=60');
await settle(() => where() === '/media/music/tracks?library=a');
});
});
describe('category tabs', () => {
it('draws 有声书 and 演播者 on the narrator page, with 演播者 current', async () => {
state.libraries = [{ id: 'b', name: '书库', kind: 'audiobook' }];
mount('/media/audiobook/narrators');
await settle(() => root.querySelectorAll('.media-tabs .media-tab-link').length === 2);
const links = [...root.querySelectorAll<HTMLAnchorElement>('.media-tabs .media-tab-link')];
expect(links.map(link => [link.textContent, link.getAttribute('href'), link.getAttribute('aria-current')])).toEqual([
['有声书', '/media/audiobook', null],
['演播者', '/media/audiobook/narrators', 'page'],
]);
});
it('marks the default category current at both the bare channel path and its own path', async () => {
state.libraries = [{ id: 'a', name: '音乐库', kind: 'music' }];
for (const path of ['/media/music', '/media/music/albums']) {
mount(path);
await settle(() => tiles() === 3);
expect(root.querySelector('.media-tabs [aria-current=page]')?.textContent).toBe('专辑');
act(() => render(null, root));
}
});
});
describe('channel ⋯ menu', () => {
const labels = () => [...document.querySelectorAll('[role^=menuitem]')].map(item => item.textContent?.trim());
function open() {
act(() => render(<MemoryRouter><ChannelMenu channel="music" /></MemoryRouter>, root));
act(() => root.querySelector<HTMLButtonElement>('[aria-label="更多操作"]')!.click());
}
it('offers 媒体库管理 to an administrator only, and not before the role is known', () => {
open();
expect(labels()).toEqual(['收藏', '历史', '队列']);
act(() => render(null, root));
useAuthStore.setState({ verifiedUser: { role: 'member' } as any });
open();
expect(labels()).toEqual(['收藏', '历史', '队列']);
act(() => render(null, root));
useAuthStore.setState({ verifiedUser: { role: 'admin' } as any });
open();
expect(labels()).toEqual(['收藏', '历史', '队列', '媒体库管理']);
});
});
+159
View File
@@ -0,0 +1,159 @@
// @vitest-environment jsdom
import { act } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '../src/shared/ui/render-root.ts';
import { RuntimeContext } from '../src/app/providers/runtime-context.tsx';
import { MediaHistoryPage } from '../src/features/media/pages/MediaHistoryPage.tsx';
import { MediaSearchPage } from '../src/features/media/pages/MediaSearchPage.tsx';
import { MediaFavoritesPage } from '../src/features/media/pages/MediaFavoritesPage.tsx';
import { MediaPageFrame } from '../src/features/media/components/MediaPageFrame.tsx';
import type { SearchItem } from '../src/features/media/api/media-api.ts';
const favorites = { total: 60 };
vi.mock('../src/features/media/queries/media.queries.ts', async importOriginal => ({
...await importOriginal<typeof import('../src/features/media/queries/media.queries.ts')>(),
historyQuery: (_runtime: unknown, channel: string, params: URLSearchParams) => ({
queryKey: ['list-return-history', channel, params.get('offset') ?? ''],
queryFn: async () => ({ items: Array.from({ length: 30 }, (_, index) => ({ id: 'h' + index, itemId: 'item' + index, partId: 'p' + index, title: '历史 ' + index, updatedAt: '2026-10-01T00:00:00Z', available: true })), total: 30 }),
}),
favoritesQuery: (_runtime: unknown, params: URLSearchParams) => {
const offset = Number(params.get('offset')) || 0;
return {
queryKey: ['list-return-favorites', offset],
queryFn: async () => ({ items: offset < favorites.total ? [{ id: 'fav', kind: 'movie', title: '收藏电影', libraryId: 'lib', parentId: null, metadata: {}, overrides: {} }] : [], total: favorites.total }),
};
},
}));
const root = document.createElement('div');
document.body.append(root);
const results: SearchItem[] = Array.from({ length: 20 }, (_, index) => ({ id: 'film' + index, libraryId: 'lib', title: '结果 ' + index, kind: 'movie', channel: 'video', libraryName: '电影库', parentId: null, metadata: {}, overrides: {} }));
const search = vi.fn(async () => ({ items: results, total: results.length }));
const runtime = { mediaApi: { preferenceScope: () => 'list-return-test', search, coverUrl: () => '', thumbnailUrl: () => '' } } as any;
/** A stand-in detail page: the real frame, so its back arrow honours `state.returnTo`. */
function Detail() {
return <MediaPageFrame className="media-secondary-page" title="详情"><p>详情内容</p></MediaPageFrame>;
}
function mount(initial: string) {
const router = createMemoryRouter([
{ path: '/media/search', element: <MediaSearchPage /> },
{ path: '/media/favorites', element: <MediaFavoritesPage /> },
{ path: '/media/:channel/history', element: <MediaHistoryPage /> },
{ path: '/media/:channel/items/:id', element: <Detail /> },
], { initialEntries: [initial] });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
act(() => render(<RuntimeContext.Provider value={{ ...runtime, queryClient }}><QueryClientProvider client={queryClient}><RouterProvider router={router} /></QueryClientProvider></RuntimeContext.Provider>, root));
return router;
}
async function settle(done: () => boolean) {
for (let i = 0; i < 50 && !done(); i++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
expect(done()).toBe(true);
}
const screen = () => root.querySelector<HTMLElement>('.media-screen')!;
function scrollTo(top: number) { screen().scrollTop = top; screen().dispatchEvent(new Event('scroll')); }
async function go(router: ReturnType<typeof mount>, delta: number) { await act(async () => { await router.navigate(delta); }); }
afterEach(() => { act(() => render(null, root)); favorites.total = 60; });
describe('media history', () => {
it('restores the scroll after Back, Back after Forward, and the detail back arrow', async () => {
const router = mount('/media/video/history');
await settle(() => root.querySelectorAll('.media-history-row').length === 30);
scrollTo(871);
await act(async () => root.querySelector<HTMLAnchorElement>('.media-personal-title')!.click());
await settle(() => root.textContent!.includes('详情内容'));
expect(screen().scrollTop).toBe(0);
await go(router, -1);
await settle(() => root.querySelectorAll('.media-history-row').length === 30);
expect(screen().scrollTop).toBe(871);
await go(router, 1);
await settle(() => root.textContent!.includes('详情内容'));
await go(router, -1);
await settle(() => screen().scrollTop === 871);
await act(async () => root.querySelector<HTMLAnchorElement>('.media-personal-title')!.click());
await settle(() => root.textContent!.includes('详情内容'));
await act(async () => root.querySelector<HTMLAnchorElement>('.media-back-button')!.click());
await settle(() => root.querySelectorAll('.media-history-row').length === 30);
expect(router.state.location.pathname).toBe('/media/video/history');
expect(screen().scrollTop).toBe(871);
});
});
describe('media search', () => {
it('opens a result whose back arrow returns to the search, query included', async () => {
mount('/media/search?q=结果&scope=video&offset=0');
await settle(() => root.querySelectorAll('.media-search-result').length === 20);
await act(async () => root.querySelector<HTMLButtonElement>('.media-search-result')!.click());
await settle(() => root.textContent!.includes('详情内容'));
const back = root.querySelector<HTMLAnchorElement>('.media-back-button')!;
expect(back.getAttribute('aria-label')).toBe('返回上一页');
expect(decodeURIComponent(back.getAttribute('href')!)).toBe('/media/search?q=结果&scope=video&offset=0');
});
it('restores the scroll on browser Back and on Back after Forward', async () => {
const router = mount('/media/search?q=结果');
await settle(() => root.querySelectorAll('.media-search-result').length === 20);
scrollTo(1960);
await act(async () => root.querySelector<HTMLButtonElement>('.media-search-result')!.click());
await settle(() => root.textContent!.includes('详情内容'));
await go(router, -1);
await settle(() => root.querySelectorAll('.media-search-result').length === 20);
await settle(() => screen().scrollTop === 1960);
expect((root.querySelector('input[name=q]') as HTMLInputElement).value).toBe('结果');
await go(router, 1);
await settle(() => root.textContent!.includes('详情内容'));
await go(router, -1);
await settle(() => root.querySelectorAll('.media-search-result').length === 20);
await settle(() => screen().scrollTop === 1960);
});
it('browser Back between two searches shows the earlier search again', async () => {
const router = mount('/media/search?q=结果');
await settle(() => root.querySelectorAll('.media-search-result').length === 20);
await act(async () => root.querySelector<HTMLButtonElement>('[data-scope=music]')!.click());
await settle(() => router.state.location.search.includes('scope=music'));
await go(router, -1);
await settle(() => !router.state.location.search.includes('scope=music'));
await settle(() => root.querySelector('[data-scope=all]')?.getAttribute('aria-pressed') === 'true');
expect(root.querySelector('[data-scope=music]')?.getAttribute('aria-pressed')).not.toBe('true');
});
it('a new search does not jump to an old position', async () => {
const router = mount('/media/search?q=结果');
await settle(() => root.querySelectorAll('.media-search-result').length === 20);
scrollTo(500);
await act(async () => root.querySelector<HTMLButtonElement>('[data-scope=music]')!.click());
await settle(() => router.state.location.search.includes('scope=music') && root.querySelectorAll('.media-search-result').length === 20);
scrollTo(0);
await act(async () => root.querySelector<HTMLButtonElement>('[data-scope=all]')!.click());
await settle(() => router.state.location.search.includes('scope=all') && root.querySelectorAll('.media-search-result').length === 20);
expect(screen().scrollTop).toBe(0);
});
});
describe('media favorites', () => {
it('clamps a page past the end to the last page instead of claiming there are no favorites', async () => {
const router = mount('/media/favorites?offset=60');
await settle(() => root.querySelectorAll('.media-favorite-row').length === 1);
expect(router.state.location.search).toBe('');
expect(router.state.historyAction).toBe('REPLACE');
expect(root.textContent).not.toContain('还没有收藏');
expect(root.textContent).toContain('60 项收藏');
});
it('clamps to the last non-empty page and keeps a truly empty list empty', async () => {
favorites.total = 61;
const router = mount('/media/favorites?offset=180');
await settle(() => root.querySelectorAll('.media-favorite-row').length === 1);
expect(router.state.location.search).toBe('?offset=60');
act(() => render(null, root));
favorites.total = 0;
mount('/media/favorites');
await settle(() => root.textContent!.includes('还没有收藏'));
});
});
+23
View File
@@ -34,3 +34,26 @@ it('paginates and searches the full playlist without changing jump indices',asyn
expect(host.querySelector('input[type=search]')).toBeNull();
expect(host.querySelectorAll('.media-current-playlist li')).toHaveLength(50);
});
it('re-renders the current queue when the player edits it',()=>{
class FakePlayer extends EventTarget {
active=true;title='专辑';currentItemId='';canSelectPlaylist=true;canEditPlaylist=true;
entries=['一','二','三'];selectPlaylist=vi.fn();
get currentPlaylist(){return this.entries.map((title,index)=>({index,title,current:index===0}));}
editPlaylist(index:number,direction:-1|0|1){
const next=this.entries.slice();
if(direction===0)next.splice(index,1);else[next[index],next[index+direction]]=[next[index+direction]!,next[index]!];
this.entries=next;this.dispatchEvent(new Event('change'));
}
}
const player=new FakePlayer();
act(()=>render(<PlaybackControls player={player as unknown as MediaPlayer} api={{} as MediaApi} panel="queue"/>,host));
const titles=()=>Array.from(host.querySelectorAll('.media-current-playlist li strong')).map(node=>node.textContent);
expect(titles()).toEqual(['一','二','三']);
act(()=>host.querySelector<HTMLButtonElement>('[aria-label="队列操作"]')!.click());
act(()=>Array.from(host.querySelectorAll<HTMLButtonElement>('[role=menuitem]')).find(button=>button.textContent==='编辑队列')!.click());
act(()=>host.querySelector<HTMLButtonElement>('[aria-label="上移 三"]')!.click());
expect(titles()).toEqual(['一','三','二']);
act(()=>host.querySelector<HTMLButtonElement>('[aria-label="移除 二"]')!.click());
expect(titles()).toEqual(['一','三']);
expect(host.querySelector('.media-playlist-toolbar small')!.textContent).toContain('2 首');
});