Merge branch 'feat/home-recommendations'

This commit is contained in:
hectorqin committed 2026-10-31 14:30:22 +00:00
commit 6b58ffd93c
10 files changed
+315 -157

No files matched your search

+1 -1
View File
@@ -857,7 +857,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
- 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 `web/src/app/AppNav.tsx` 的 `navMode`。
- 主题在应用壳层统一应用(`ThemeController` 写 `<html data-theme data-mode>`)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 `--reader-*` 是 `--color-*` 的别名(`theme-tokens.css`),影音页的 `--text`、`--media-*` 别名在 `presentation.css` 顶部。
- 频道色(阅读、影视、音乐、有声书)是每个主题自己的 `--color-ch-*`(填充)、`-soft`(卡片底)和 `-text`(文字),由 `theme-tokens.test.ts` 守住对比度。
- 首页显示在读的书、未看完的影视和有声书、书架一行;我的页只做入口,不拥有任何设置。
- 首页:「继续」一行把在读的书、未看完的影视和有声书画成同一种卡片(封面+作品名+集/章+进度,单集显示所属剧集名),最多两本书,按最近时间排序;然后是书架一行,最后是「看点什么」「听本书」「听张专辑」三行影音,各自可在「最近添加」(`browse?sort=added`,按作品及其分集/曲目最新文件的修改时间)与「随便看看」(`sort=random`,可换一批)之间切换,选择记在本机;有内容才出现。我的页只做入口,不拥有任何设置。
- “跟随系统”= 浅色时用的主题加深色时用的主题(默认都是暖陶),在外观与主题页指定;关掉跟随系统时保留屏幕上当前的主题与明暗,再选浅色或深色。读取与保存只走 `web/src/shared/theme/theme.ts` 的 `readThemePreference` / `saveThemePreference`;判断当前主题一律用 `resolveAppTheme` / `resolveReadingTheme`,React 里用 `useThemeState`,不要自己读存储。
- 搜索:桌面侧栏的“搜索书籍和影音”打开搜索页 `#/search?q=`(页内输入框写“搜索书名、片名、歌曲、有声书”),同时列出书库里的书和有权访问的影音,各自链接到完整的书库搜索和影音搜索。侧栏账号下方显示同步引擎对服务器的判断:已连接、正在同步、离线或同步出错。
- 书架:搜索框下是“全部 / 在读 / 想读 / 已读”和“分组”(按书的系列或标签筛选),都写进 URL(`?status=`、`?series=`、`?tag=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。
+1 -1
View File
@@ -205,7 +205,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
app.put<{Params:{id:string};Body:{targetParentId:string;expectedParentId:string|null}}>('/api/v1/media/items/:id/parent',{
preHandler:[auth,async request=>requireAdmin(request)],schema:{body:{type:'object',additionalProperties:false,required:['targetParentId','expectedParentId'],properties:{targetParentId:{type:'string',minLength:1,maxLength:100},expectedParentId:{anyOf:[{type:'string',minLength:1,maxLength:100},{type:'null'}]}}}},
},async request=>scanner.catalog.reparentMusic(currentUser(request),request.params.id,request.body.targetParentId,request.body.expectedParentId));
app.get<{Querystring:{channel:MediaLibraryKind;kind:MediaBrowseKind;artist?:string;album?:string;offset:number;limit:number;sort?:MediaSort}}>('/api/v1/media/browse',{preHandler:auth,schema:{querystring:{...pagination,required:['channel','kind'],properties:{...pagination.properties,artist:{type:'string',maxLength:200},album:{type:'string',maxLength:200},sort:{type:'string',enum:['default','title-asc','title-desc']},channel:{type:'string',enum:['video','music','audiobook']},kind:{type:'string',enum:['video','movie','series','artist','album','track','audiobook']}}}}},async (request,reply)=>queryCatalog(request,reply,{method:'browse',args:[currentUser(request),request.query.channel,request.query.kind,request.query]}));
app.get<{Querystring:{channel:MediaLibraryKind;kind:MediaBrowseKind;artist?:string;album?:string;offset:number;limit:number;sort?:MediaSort}}>('/api/v1/media/browse',{preHandler:auth,schema:{querystring:{...pagination,required:['channel','kind'],properties:{...pagination.properties,artist:{type:'string',maxLength:200},album:{type:'string',maxLength:200},sort:{type:'string',enum:['default','title-asc','title-desc','random','added']},channel:{type:'string',enum:['video','music','audiobook']},kind:{type:'string',enum:['video','movie','series','artist','album','track','audiobook']}}}}},async (request,reply)=>queryCatalog(request,reply,{method:'browse',args:[currentUser(request),request.query.channel,request.query.kind,request.query]}));
app.get<{Querystring:{name?:string;search?:string;offset:number;limit:number}}>('/api/v1/media/narrators',{preHandler:auth,schema:{querystring:{...pagination,properties:{...pagination.properties,name:{type:'string',minLength:1,maxLength:2000},search:{type:'string',maxLength:200}}}}},async request=>scanner.catalog.narrators(currentUser(request),'',request.query));
app.get<{Params:{id:string}}>('/api/v1/media/items/:id/album-playback',{preHandler:auth},async request=>scanner.catalog.albumPlayback(currentUser(request),request.params.id));
app.get<{Params:{id:string};Querystring:{name?:string;search?:string;offset:number;limit:number}}>('/api/v1/media/libraries/:id/narrators',{
+14 -1
View File
@@ -10,7 +10,8 @@ import {sameArtist,type ArtistProfile} from './artist-metadata.ts';
export type MediaItemKind = 'movie' | 'series' | 'season' | 'episode' | 'artist' | 'album' | 'track' | 'audiobook';
export type MediaBrowseKind = MediaItemKind | 'video';
export type MediaSort = 'default'|'title-asc'|'title-desc';
/** `random` and `added` are for the 首页 rows; only browse accepts them (random pages are not stable). */
export type MediaSort = 'default'|'title-asc'|'title-desc'|'random'|'added';
// Track tags identify an artist, but album artwork and descriptions are not a biography.
function artistMetadata(title:string,metadata:LocalMediaMetadata,profile?:ArtistProfile):LocalMediaMetadata {
return {title,sources:{title:metadata.sources?.albumArtist??metadata.sources?.artist??metadata.sources?.title??'filename',...(profile?.plot?{plot:'nfo' as const}:{})},externalIds:{},warnings:[],
@@ -29,8 +30,20 @@ function trackConditions(options:TrackFilters&{kind?:MediaBrowseKind},conditions
}
return joins.join(' ');
}
/**
* When a work last gained a playable file: the newest mtime among its own parts and those of
* its children and grandchildren (series → season → episode, album → track), so a new episode
* brings its series back up. File time rather than a stored "first seen" column: it needs no
* migration and already means something for libraries scanned before this existed.
*/
const ADDED_AT=`COALESCE((SELECT max(a.modified_at) FROM media_editions e JOIN media_parts p ON p.edition_id=e.id AND p.active=1
JOIN media_assets a ON a.id=p.asset_id AND a.available=1
WHERE e.item_id IN (SELECT i.id UNION ALL SELECT c.id FROM media_items c WHERE c.parent_id=i.id
UNION ALL SELECT g.id FROM media_items c JOIN media_items g ON g.parent_id=c.id WHERE c.parent_id=i.id)),0)`;
function titleOrder(sort:MediaSort|undefined,fallback:string){
if(!sort||sort==='default')return fallback;
if(sort==='random')return 'random()';
if(sort==='added')return `${ADDED_AT} DESC,i.id`;
if(sort!=='title-asc'&&sort!=='title-desc')throw badRequest('无效排序');
return `COALESCE((SELECT json_extract(o.value_json,'$') FROM media_metadata_overrides o WHERE o.item_id=i.id AND o.field='title'),(SELECT json_extract(m.fields_json,'$.title') FROM media_online_metadata m WHERE m.item_id=i.id),i.title) COLLATE NOCASE ${sort==='title-desc'?'DESC':'ASC'},i.id`;
}
+4
View File
@@ -74,6 +74,10 @@ test('search filters permissions before counting and paging, and respects effect
const ascending=catalog.browse(member,'video','movie',{sort:'title-asc'});
assert.deepEqual(ascending.items.map(item=>item.title),['100%_literal','AAA','ZZZ']);
assert.deepEqual(catalog.browse(member,'video','movie',{sort:'title-desc',offset:1,limit:1}).items.map(item=>item.title),['AAA']);
const sampled=catalog.browse(member,'video','movie',{sort:'random',limit:2});
assert.equal(sampled.items.length,2);
assert.equal(sampled.total,3,'random sampling keeps the same access filter and total');
assert.ok(sampled.items.every(item=>ascending.items.some(visible=>visible.id===item.id)));
assert.deepEqual(catalog.list(member,'public',{kind:'movie',sort:'title-asc'}).items.map(item=>item.id),ascending.items.map(item=>item.id));
} finally {db.close();}
});
+22
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {Db} from '../src/db/index.ts';
import {MediaLibraries} from '../src/media/libraries.ts';
import {MediaCatalog} from '../src/media/catalog.ts';
import {MediaScanner} from '../src/media/scanner.ts';
test('video home pages movies and series together without episodes or inaccessible libraries',()=>{
const db=new Db(':memory:');
@@ -23,3 +24,24 @@ test('video home pages movies and series together without episodes or inaccessib
assert.equal(catalog.browse(member,'video','video',{sort:'title-desc',limit:1}).items[0]!.id,'item-0');
}finally{db.close();}
});
test('recently added orders works by their newest available file, counting episodes under a series',()=>{
const db=new Db(':memory:');
try{
const libraries=new MediaLibraries(db);new MediaScanner(db,libraries);
const catalog=new MediaCatalog(db,libraries),member={id:'member',role:'member'} as const;
db.run("INSERT INTO media_libraries(id,name,kind,root,access,created_at,updated_at) VALUES('lib','lib','video','/lib','all',0,0)");
const item=(id:string,kind:string,parent:string|null)=>db.run("INSERT INTO media_items(id,library_id,kind,parent_id,local_key,title,metadata_json) VALUES(?,'lib',?,?,?,?,'{}')",id,kind,parent,id,id);
const file=(itemId:string,mtime:number,available=1)=>{
db.run("INSERT INTO media_assets(id,library_id,ref,size,modified_at,available,probe_status) VALUES(?,'lib',?,1,?,?,'ready')",itemId+'-a',itemId+'.mp4',mtime,available);
db.run("INSERT INTO media_editions(id,item_id,local_key,label) VALUES(?,?,'e','')",itemId+'-e',itemId);
db.run("INSERT INTO media_parts(id,edition_id,asset_id,local_key,title,ordinal) VALUES(?,?,?,'p','',0)",itemId+'-p',itemId+'-e',itemId+'-a');
};
item('old-movie','movie',null);file('old-movie',100);
item('new-movie','movie',null);file('new-movie',300);
item('show','series',null);item('s1','season','show');item('ep1','episode','s1');file('ep1',50);item('ep2','episode','s1');file('ep2',400);
item('gone','movie',null);file('gone',999,0);
item('empty','movie',null);
assert.deepEqual(catalog.browse(member,'video','video',{sort:'added'}).items.map(row=>row.id),['show','new-movie','old-movie','empty','gone']);
}finally{db.close();}
});
+121 -53
View File
@@ -1,11 +1,17 @@
import { Icon, type IconName } from '../../../ui/icon.tsx';
import { useState, type ReactNode } from 'react';
import { Icon } from '../../../ui/icon.tsx';
import { Segmented } from '../../../ui/toolkit.tsx';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { coverTint } from '../../../ui/cover-tint.ts';
import { BookCard, useCover } from '../../shelf/pages/ShelfPage.tsx';
import { useShelf } from '../../shelf/queries/shelf.queries.ts';
import { historyQuery, type Activity } from '../../media/queries/media.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 type { SearchItem } from '../../media/api/media-api.ts';
import type { ContinueReadingItem } from '../../../api/types.ts';
import '../styles/home.css';
@@ -25,83 +31,145 @@ export function activityProgress(activity: Activity): number {
return length > 0 && activity.position !== undefined ? Math.max(0, Math.min(1, (activity.position - activity.start) / length)) : 0;
}
function Resume({ book }: { book: ContinueReadingItem }) {
const runtime = useRuntime();
const src = useCover(runtime.api, book.coverUrl);
const percent = Math.round(Math.max(0, Math.min(1, book.percentage)) * 100);
return <Link className="home-hero" to={`/book/${encodeURIComponent(book.id)}`}>
<span className="home-hero-cover" aria-hidden="true">{src ? <img src={src} alt="" /> : <b>{book.title}</b>}</span>
<span className="home-hero-text">
<strong>{book.title || '未命名'}</strong>
{book.chapterTitle && <small>{book.chapterTitle}</small>}
<span className="home-hero-foot">
<span className="home-hero-action"><Icon name="play" size={14} filled />继续阅读</span>
<span className="home-hero-meter">
<span className="home-progress" aria-hidden="true"><i style={{ width: `${percent}%` }} /></span>
<span className="home-hero-percent">已读 {percent}%</span>
</span>
type Channel = 'read' | 'video' | 'audiobook';
const CHANNEL_LABEL: Record<Channel, string> = { read: '阅读', video: '影视', audiobook: '有声书' };
/** One shape for every kind of "continue": cover, work title, where you are, how far. */
function ResumeCard({ channel, to, cover, title, detail, progress, meter }: {
channel: Channel; to: string; cover: ReactNode; title: string; detail: string; progress: number; meter: string;
}) {
return <Link className="home-resume" data-channel={channel} to={to}>
<span className="home-resume-cover" aria-hidden="true">{cover}</span>
<span className="home-resume-text">
<span className="home-resume-label">{CHANNEL_LABEL[channel]}</span>
<strong title={title}>{title}</strong>
{detail && <small title={detail}>{detail}</small>}
<span className="home-resume-meter">
<span className="home-progress" aria-hidden="true"><i style={{ width: `${Math.round(progress * 100)}%` }} /></span>
<span className="home-resume-figure">{meter}</span>
</span>
</span>
<span className="home-resume-play" aria-hidden="true"><Icon name="play" size={16} filled /></span>
</Link>;
}
function ResumeMedia({ channel, label, icon, activity }: { channel: 'video' | 'audiobook'; label: string; icon: IconName; activity: Activity }) {
const progress = activityProgress(activity);
const title = `${activity.title}${activity.partTitle && activity.partTitle !== activity.title ? ` · ${activity.partTitle}` : ''}`;
const verb = channel === 'video' ? '观看' : '收听';
// Without a known length there is no time to show; say what tapping does instead of
// repeating the part title that the heading already carries.
const meta = activity.end !== null && activity.position !== undefined
? `${channel === 'video' ? '已看' : '已听'} ${clock(activity.position - activity.start)} / ${clock(activity.end - activity.start)}`
: `继续${verb}`;
return <Link className="home-card" data-channel={channel} to={`/media/${channel}/items/${encodeURIComponent(activity.itemId)}`}>
<span className="home-card-icon" aria-hidden="true"><Icon name={icon} size={20} /></span>
<span className="home-card-text">
<span className="home-card-label">{label}</span>
<strong>{title}</strong>
<small>{meta}</small>
<span className="home-progress" aria-hidden="true"><i style={{ width: `${Math.round(progress * 100)}%` }} /></span>
</span>
<Icon name="chevron-right" size={16} className="home-card-chevron" />
</Link>;
function ResumeBook({ book }: { book: ContinueReadingItem }) {
const runtime = useRuntime();
const src = useCover(runtime.api, book.coverUrl);
const progress = Math.max(0, Math.min(1, book.percentage));
const title = book.title || '未命名';
return <ResumeCard channel="read" to={`/book/${encodeURIComponent(book.id)}`} title={title} detail={book.chapterTitle ?? ''}
progress={progress} meter={`已读 ${Math.round(progress * 100)}%`}
cover={src ? <img src={src} alt="" /> : <span className="home-resume-placeholder" style={coverTint(title)}>{title}</span>} />;
}
/** 首页: where you left off, across reading and media, then the shelf. */
function ResumeMedia({ channel, activity }: { channel: 'video' | 'audiobook'; activity: Activity }) {
const runtime = useRuntime(), { mediaApi } = runtime;
// An episode's own title is "第 1 集"; the card is about the series it belongs to.
const work = useQuery({
queryKey: [...mediaKeys.root(runtime), 'home', 'work', activity.itemId],
queryFn: ({ signal }) => resolveWork(mediaApi, activity.itemId, signal),
staleTime: 5 * 60_000,
});
const resolved = work.data?.work;
const title = resolved?.title ?? activity.title;
const detail = [resolved && resolved.id !== activity.itemId ? activity.title : '', activity.partTitle !== activity.title ? activity.partTitle : '']
.filter(Boolean).join(' · ');
const known = activity.end !== null && activity.position !== undefined;
const meter = known ? `${clock(activity.position! - activity.start)} / ${clock(activity.end! - activity.start)}` : `继续${channel === 'video' ? '观看' : '收听'}`;
const coverItem = work.data?.artwork ?? resolved ?? { id: activity.itemId, libraryId: activity.libraryId, kind: activity.kind, title: activity.title, parentId: null, metadata: {}, overrides: {} };
return <ResumeCard channel={channel} to={`/media/${channel}/items/${encodeURIComponent(activity.itemId)}`} title={title} detail={detail}
progress={activityProgress(activity)} meter={meter}
cover={<MediaCover api={mediaApi} item={coverItem} square={channel === 'audiobook'} />} />;
}
type DiscoverMode = 'added' | 'random';
const DISCOVER_MODES: Array<{ value: DiscoverMode; label: string }> = [{ value: 'added', label: '最近添加' }, { value: 'random', label: '随便看看' }];
// Which view a row shows is a per-viewer convenience: storage may be missing or throw.
function readMode(channel: string): DiscoverMode {
try { return localStorage.getItem(`home.discover.${channel}`) === 'random' ? 'random' : 'added'; } catch { return 'added'; }
}
function saveMode(channel: string, mode: DiscoverMode) {
try { localStorage.setItem(`home.discover.${channel}`, mode); } catch { /* optional */ }
}
/** One media channel: what just arrived, or a random handful so a rarely opened library still turns up. */
function Discover({ channel, kind, heading, to }: { channel: 'video' | 'audiobook' | 'music'; kind: string; heading: string; to: string }) {
const runtime = useRuntime();
const [mode, setMode] = useState<DiscoverMode>(() => readMode(channel));
const [round, setRound] = useState(0);
const sample = useQuery({
queryKey: [...mediaKeys.root(runtime), 'home', 'discover', channel, mode, mode === 'random' ? round : 0],
queryFn: ({ signal }) => runtime.mediaApi.request<{ items: SearchItem[]; total: number }>(
`browse?${new URLSearchParams({ channel, kind, offset: '0', limit: '12', sort: mode })}`, 'GET', undefined, signal),
// Same picks until asked for another batch; refocusing the tab must not reshuffle under the reader.
staleTime: mode === 'random' ? Infinity : 5 * 60_000,
placeholderData: keepPreviousData,
});
const items = sample.data?.items ?? [];
// Optional: an older server, a starting media worker or an empty channel simply shows nothing.
if (!items.length) return null;
const square = channel !== 'video';
return <section className="home-discover" data-channel={channel} aria-label={heading}>
<div className="home-section">
<h2>{heading}</h2>
{mode === 'random' && sample.data!.total > items.length && <button type="button" className="home-shuffle" disabled={sample.isFetching} onClick={() => setRound(value => value + 1)}>
<Icon name="refresh" size={14} />换一批
</button>}
<Segmented label={`${heading}显示`} options={DISCOVER_MODES} value={mode} onChange={next => { setMode(next); saveMode(channel, next); }} />
<Link to={to} aria-label={`全部${heading}`}>全部<Icon name="chevron-right" size={16} /></Link>
</div>
<div className="home-shelf-row" data-square={square ? 'true' : undefined}>{items.map(item => <Link key={item.id} className="home-tile" to={`/media/${channel}/items/${encodeURIComponent(item.id)}`}>
<MediaCover api={runtime.mediaApi} item={item} square={square} />
<strong title={item.title}>{item.title}</strong>
{channel === 'music' && typeof item.metadata.artist === 'string' && <small title={item.metadata.artist}>{item.metadata.artist}</small>}
</Link>)}</div>
</section>;
}
/** 首页: where you left off across reading and media, the shelf, then something to pick up. */
export function HomePage() {
const runtime = useRuntime();
const user = useAuthStore(state => state.verifiedUser ?? state.session?.user ?? null);
const reading = useQuery({
queryKey: ['home', 'reading', runtime.api.baseUrl, user?.id],
queryFn: ({ signal }) => runtime.api.continueReading(1, { signal }),
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()));
const unfinished = (items?: Activity[]) => items?.find(item => !item.completed);
const book = reading.data?.[0];
const readingBooks = reading.data ?? [], book = readingBooks[0];
const videoActivity = unfinished(video.data?.items), audioActivity = unfinished(audio.data?.items);
const books = (shelf.data?.items ?? []).slice(0, 8);
const name = user?.displayName || user?.username || '';
// Most recent first, whatever kind it is.
const resumes: Array<{ at: number; node: ReactNode }> = [];
for (const item of readingBooks.slice(0, 2)) resumes.push({ at: item.lastReadAt ?? 0, node: <ResumeBook key={`read:${item.id}`} book={item} /> });
if (videoActivity) resumes.push({ at: videoActivity.updatedAt ?? 0, node: <ResumeMedia key="video" channel="video" activity={videoActivity} /> });
if (audioActivity) resumes.push({ at: audioActivity.updatedAt ?? 0, node: <ResumeMedia key="audiobook" channel="audiobook" activity={audioActivity} /> });
resumes.sort((a, b) => b.at - a.at);
return <div className="media-screen media-page home-page">
<header className="home-header"><h1>{greeting(new Date().getHours())}{name ? `,${name}` : ''}</h1><p>从上次停下的地方继续</p></header>
<div className="home-continue" data-media={videoActivity || audioActivity ? 'true' : undefined}>
<div className="home-continue">
{resumes.map(entry => entry.node)}
{/* Only a successful answer can say "nothing in progress": while loading the slot keeps
its shape, and a failure says so instead of telling a reader with books that they have none. */}
{book
? <Resume book={book} />
: reading.isPending
? <section className="home-empty home-empty-pending" aria-label="继续阅读" aria-busy="true"><strong>正在读取在读的书…</strong></section>
: reading.error
? <section className="home-empty" aria-label="继续阅读" role="alert"><strong>在读的书读取失败</strong><button type="button" className="home-retry" onClick={() => void reading.refetch()}>重试<Icon name="chevron-right" size={16} /></button></section>
: <section className="home-empty" aria-label="继续阅读"><strong>还没有在读的书</strong><Link to="/shelf">去书架挑一本<Icon name="chevron-right" size={16} /></Link></section>}
{(videoActivity || audioActivity) && <div className="home-cards">
{videoActivity && <ResumeMedia channel="video" label="影视" icon="video" activity={videoActivity} />}
{audioActivity && <ResumeMedia channel="audiobook" label="听书" icon="audiobook" activity={audioActivity} />}
</div>}
its shape, and a failure says so instead of telling a reader with books that they have none.
Media in progress is enough to fill the row, so the "no book" invitation is only for an empty row. */}
{!book && (reading.isPending
? <section className="home-empty home-empty-pending" aria-label="继续阅读" aria-busy="true"><strong>正在读取在读的书…</strong></section>
: reading.error
? <section className="home-empty" aria-label="继续阅读" role="alert"><strong>在读的书读取失败</strong><button type="button" className="home-retry" onClick={() => void reading.refetch()}>重试<Icon name="chevron-right" size={16} /></button></section>
: !resumes.length && <section className="home-empty" aria-label="继续阅读"><strong>还没有在读的书</strong><Link to="/shelf">去书架挑一本<Icon name="chevron-right" size={16} /></Link></section>)}
</div>
{books.length > 0 && <section className="home-shelf" aria-label="我的书架">
<div className="home-section"><h2>我的书架</h2><Link to="/shelf">全部<Icon name="chevron-right" size={16} /></Link></div>
<div className="home-shelf-row">{books.map(item => <BookCard key={item.id} book={item} showAuthor={false} showProgress />)}</div>
</section>}
<Discover channel="video" kind="video" heading="看点什么" to="/media/video" />
<Discover channel="audiobook" kind="audiobook" heading="听本书" to="/media/audiobook" />
<Discover channel="music" kind="album" heading="听张专辑" to="/media/music/albums" />
</div>;
}
+75 -79
View File
@@ -1,99 +1,95 @@
/* 首页: continue where you left off. Uses the shared theme tokens only:
* continue cards sit on ch-*-soft with ink text, brand fills carry on-brand text. */
/* 首页: continue cards (one shape for every channel), the shelf row, then random media rows.
* Uses the shared theme tokens only; channel colour comes from --color-ch-*. */
.home-header h1 { margin: 6px 0 0; font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif SC', serif; font-size: 28px; font-weight: 600; line-height: 1.2; color: var(--color-ink); }
.home-header p { margin: 6px 0 0; font-size: var(--text-ui); color: var(--color-muted); }
.home-progress { display: block; height: 4px; border-radius: var(--radius-xs); background: color-mix(in srgb, var(--color-ink) 10%, transparent); overflow: hidden; }
.home-progress > i { display: block; height: 100%; min-width: 4px; border-radius: var(--radius-xs); background: var(--color-brand); }
/* Reading takes the lead; unfinished media sits beside it on a wide window, below it otherwise. */
.home-continue { display: grid; gap: 12px; margin-top: 20px; }
@media (min-width: 64rem) {
.home-continue[data-media] { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); align-items: stretch; }
}
/* Every "continue" — book, video, audiobook — is the same card, most recent first.
* Only the label, cover shape and progress colour say which channel it is. */
.home-continue { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 12px; margin-top: 20px; }
.home-hero, .home-empty {
display: flex;
gap: 20px;
padding: 20px;
border: 1px solid color-mix(in srgb, var(--color-ch-read) 35%, transparent);
border-radius: var(--radius-xl);
background: var(--color-ch-read-soft);
color: var(--color-ink);
text-decoration: none;
transition: border-color var(--motion-duration) var(--motion-ease), box-shadow var(--motion-duration) var(--motion-ease);
}
@media (hover: hover) and (pointer: fine) { .home-hero:hover { border-color: var(--color-ch-read); box-shadow: 0 8px 24px color-mix(in srgb, var(--color-ink) 8%, transparent); }}
.home-hero:focus-visible, .home-card:focus-visible, .home-empty a:focus-visible, .home-retry:focus-visible { outline: 2px solid var(--color-brand-text); outline-offset: 3px; }
.home-hero-cover {
display: grid;
place-items: center;
flex: none;
width: 96px;
aspect-ratio: 5 / 7;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-ink);
box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 12%, transparent), 0 10px 22px color-mix(in srgb, var(--color-ink) 16%, transparent);
}
.home-hero-cover img { width: 100%; height: 100%; object-fit: cover; }
.home-hero-cover b { padding: 10px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: var(--text-body); font-weight: 600; line-height: 1.35; letter-spacing: 0.05em; text-align: center; overflow-wrap: anywhere; }
.home-hero-text { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.home-hero-text strong { margin-top: 8px; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: var(--text-display); font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
.home-hero-text small { font-size: var(--text-label); color: var(--color-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.home-hero-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: auto; padding-top: 14px; }
.home-hero-action { display: inline-flex; align-items: center; gap: 6px; flex: none; height: 38px; padding: 0 18px; border-radius: var(--radius-pill); background: var(--color-brand); color: var(--color-on-brand); font-size: var(--text-ui); font-weight: 600; }
.home-hero-meter { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 140px; max-width: 320px; }
.home-hero-meter .home-progress { flex: 1; }
.home-hero-percent { flex: none; font-size: var(--text-meta); color: var(--color-muted); font-variant-numeric: tabular-nums; }
.home-empty { flex-direction: column; justify-content: center; align-items: flex-start; gap: 8px; min-height: 120px; }
.home-empty strong { font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: var(--text-title); }
.home-empty a, .home-retry { display: inline-flex; align-items: center; color: var(--color-ch-read-text); font-size: var(--text-ui); font-weight: 600; text-decoration: none; }
.home-retry { min-height: 44px; padding: 0; border: 0; background: none; cursor: pointer; }
.home-empty-pending strong { color: var(--color-muted); }
.home-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; align-content: start; }
@media (min-width: 64rem) {
.home-continue[data-media] .home-cards { grid-template-columns: minmax(0, 1fr); }
}
.home-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 14px 14px 16px;
.home-resume, .home-empty {
border: 1px solid var(--color-line);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-ink);
text-decoration: none;
transition: border-color var(--motion-duration) var(--motion-ease);
--ch: var(--color-ch-video); --ch-soft: var(--color-ch-video-soft); --ch-text: var(--color-ch-video-text);
}
.home-card[data-channel='audiobook'] { --ch: var(--color-ch-audio); --ch-soft: var(--color-ch-audio-soft); --ch-text: var(--color-ch-audio-text); }
@media (hover: hover) and (pointer: fine) { .home-card:hover { border-color: var(--ch); }}
.home-card-icon { display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--ch-soft); color: var(--ch-text); }
.home-card-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.home-card-label { font-size: var(--text-meta); font-weight: 600; color: var(--ch-text); }
.home-card strong { font-size: var(--text-body); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.home-card small { margin-bottom: 6px; font-size: var(--text-meta); color: var(--color-muted); }
.home-card .home-progress > i { background: var(--ch); }
.home-card-chevron { flex: none; color: var(--color-muted); }
.home-resume {
--ch: var(--color-ch-read); --ch-soft: var(--color-ch-read-soft); --ch-text: var(--color-ch-read-text);
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px 12px 12px;
transition: border-color var(--motion-duration) var(--motion-ease), box-shadow var(--motion-duration) var(--motion-ease);
}
.home-resume[data-channel='video'] { --ch: var(--color-ch-video); --ch-soft: var(--color-ch-video-soft); --ch-text: var(--color-ch-video-text); }
.home-resume[data-channel='audiobook'] { --ch: var(--color-ch-audio); --ch-soft: var(--color-ch-audio-soft); --ch-text: var(--color-ch-audio-text); }
@media (hover: hover) and (pointer: fine) {
.home-resume:hover { border-color: var(--ch); box-shadow: 0 6px 18px color-mix(in srgb, var(--color-ink) 7%, transparent); }
.home-resume:hover .home-resume-play { background: var(--ch); color: var(--color-surface); }
}
.home-resume:focus-visible, .home-tile:focus-visible, .home-empty a:focus-visible, .home-retry:focus-visible, .home-shuffle:focus-visible { outline: 2px solid var(--color-brand-text); outline-offset: 3px; }
.home-section { display: flex; align-items: baseline; margin: 32px 0 12px; }
/* A fixed cover box: posters and book covers fill it, square art sits centred in it,
* so the text column starts at the same x on every card. */
.home-resume-cover { display: grid; place-items: center; flex: none; width: 60px; height: 90px; }
.home-resume-cover > img, .home-resume-cover > .home-resume-placeholder, .home-resume-cover > .media-cover {
width: 100%;
border-radius: var(--radius-sm);
box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 14%, transparent), 0 4px 10px color-mix(in srgb, var(--color-ink) 8%, transparent);
}
.home-resume-cover > img, .home-resume-cover > .home-resume-placeholder { height: 100%; object-fit: cover; }
.media-screen .home-resume-cover > .media-cover { padding: 4px; }
.media-screen .home-resume-cover > .media-cover.has-image { padding: 0; }
.media-screen .home-resume-cover > .media-cover:not(.has-image) .media-cover-title { font-size: var(--text-micro); letter-spacing: 0; }
.home-resume-placeholder {
display: flex; align-items: center; justify-content: center; padding: 6px;
overflow: hidden; font-size: var(--text-micro); line-height: 1.3; text-align: center; overflow-wrap: anywhere;
color: color-mix(in srgb, var(--color-ink) 72%, transparent);
background: linear-gradient(150deg, color-mix(in oklab, hsl(var(--cover-hue, 20) 55% 55%) 20%, var(--color-surface)), color-mix(in oklab, hsl(var(--cover-hue, 20) 55% 55%) 12%, var(--color-line)));
}
.home-resume-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.home-resume-label { font-size: var(--text-meta); font-weight: 600; color: var(--ch-text); }
.home-resume strong { font-size: var(--text-body); font-weight: 600; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.home-resume small { font-size: var(--text-meta); color: var(--color-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.home-resume-meter { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.home-resume-meter .home-progress { flex: 1; }
.home-resume-meter .home-progress > i { background: var(--ch); }
.home-resume-figure { flex: none; font-size: var(--text-meta); color: var(--color-muted); font-variant-numeric: tabular-nums; }
.home-resume-play {
display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: var(--radius-pill);
background: var(--ch-soft); color: var(--ch-text);
transition: background var(--motion-duration) var(--motion-ease), color var(--motion-duration) var(--motion-ease);
}
.home-empty { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; gap: 4px; min-height: 116px; padding: 12px 20px; border-style: dashed; }
.home-empty strong { font-size: var(--text-body); font-weight: 600; }
.home-empty a, .home-retry { display: inline-flex; align-items: center; min-height: 44px; color: var(--color-ch-read-text); font-size: var(--text-ui); font-weight: 600; text-decoration: none; }
.media-screen .home-retry { min-height: 44px; padding: 0; border: 0; background: none; cursor: pointer; }
.home-empty-pending strong { color: var(--color-muted); }
.home-section { display: flex; align-items: center; gap: 4px 16px; margin: 32px 0 12px; }
.home-section h2 { flex: 1; margin: 0; font-family: 'Songti SC', 'Noto Serif SC', serif; font-size: var(--text-title); font-weight: 600; }
.home-section a { display: inline-flex; align-items: center; color: var(--color-muted); font-size: var(--text-label); text-decoration: none; }
@media (hover: hover) and (pointer: fine) { .home-section a:hover { color: var(--color-brand-text); }}
.home-shelf-row { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.home-shelf-row .book-card { flex: none; width: 112px; }
@media (max-width: 480px) {
.home-hero { gap: 14px; padding: 16px; }
.home-hero-cover { width: 80px; }
.home-hero-text strong { font-size: var(--text-title); }
.home-hero-meter { max-width: none; }
}
/* The row's own view switch: the toolkit control, compacted to sit beside a section title. */
.home-section .segmented { flex: none; border-radius: var(--radius-pill); }
.media-screen .home-section .segmented button { min-height: 30px; padding: 0 12px; border: 0; border-radius: 0; font-size: var(--text-meta); }
.media-screen .home-shuffle { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; padding: 0; border: 0; background: none; color: var(--color-muted); font-size: var(--text-label); }
@media (hover: hover) and (pointer: fine) { .media-screen .home-shuffle:not(:disabled):hover { color: var(--color-brand-text); }}
/* Recommendation tiles keep the shelf row's width and title type, so the rows read as one page. */
.home-tile { display: flex; flex-direction: column; flex: none; width: 112px; color: var(--color-ink); text-decoration: none; }
.home-tile strong { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: 2.6em; margin-top: 8px; font-size: var(--text-label); font-weight: 520; line-height: 1.3; }
.media-screen .home-tile .media-cover:not(.has-image) .media-cover-title { font-size: var(--text-meta); }
.home-shelf-row[data-square] .home-tile { width: 128px; }
.home-tile small { margin-top: -2px; font-size: var(--text-meta); color: var(--color-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.home-tile strong:has(+ small) { min-height: 0; }
@media (hover: hover) and (pointer: fine) { .home-tile:hover strong { color: var(--color-brand-text); }}
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
import type { MediaApi, Part, Detail } from '../api/media-api.ts';
import {MediaCover} from './cover.tsx';
import {historyPosition} from './history-labels.ts';
import {resolveWork} from '../services/work.ts';
interface Recent {libraryId:string;itemId:string;partId:string;title:string;partTitle:string;start:number;end:number|null;position:number;completed:number;available:number;metadataJson?:string}
@@ -19,14 +20,8 @@ export function ContinuePlaying({api,libraryId,libraryIds=[],onPlay}:{api:MediaA
const row=result.items.find(row=>allowed.has(row.libraryId)&&!row.completed&&row.available&&row.position>row.start)??null;
if(abort.signal.aborted)return;setRecent(row);
if(!row)return;
let detail=await api.detail(row.itemId,abort.signal);
const hasCover=(item:Detail)=>!!(item.metadata.coverRef||item.metadata.tmdbPosterPath||item.metadata.embeddedCoverAssetId||item.metadata.musicBrainzCoverGroupId);
let artwork:Detail|null=hasCover(detail)?detail:null;
for(let depth=0;detail.parentId&&depth<3&&['episode','season','track'].includes(detail.kind);depth++){
detail=await api.detail(detail.parentId,abort.signal);
if(!artwork&&hasCover(detail))artwork=detail;
}
if(!abort.signal.aborted){setWork(detail);setCover(artwork);}
const {work,artwork}=await resolveWork(api,row.itemId,abort.signal);
if(!abort.signal.aborted){setWork(work);setCover(artwork);}
}).catch(()=>{/* Optional history must not prevent browsing. */});
return ()=>abort.abort();
},[api,scope]);
+17
View File
@@ -0,0 +1,17 @@
import type { Detail, Item, MediaApi } from '../api/media-api.ts';
export const hasCover = (item: Item) => !!(item.metadata.coverRef || item.metadata.tmdbPosterPath || item.metadata.embeddedCoverAssetId || item.metadata.musicBrainzCoverGroupId);
/**
* History rows point at the played item, which for a series is an episode titled "第 1 集".
* Walk up to the work (series / album) and the nearest item that carries artwork.
*/
export async function resolveWork(api: MediaApi, itemId: string, signal?: AbortSignal): Promise<{ work: Detail; artwork: Detail | null }> {
let detail = await api.detail(itemId, signal);
let artwork: Detail | null = hasCover(detail) ? detail : null;
for (let depth = 0; detail.parentId && depth < 3 && ['episode', 'season', 'track'].includes(detail.kind); depth++) {
detail = await api.detail(detail.parentId, signal);
if (!artwork && hasCover(detail)) artwork = detail;
}
return { work: detail, artwork };
}
+57 -14
View File
@@ -14,7 +14,11 @@ const user = { id: 'u1', username: 'lin', displayName: '林间', role: 'member',
const book = { id: 'b1', title: '夜航船', author: '示例', format: 'epub', coverUrl: null, percentage: 0.42, chapterTitle: '第十二章 夜雨', lastReadAt: 1 };
const activity = (over: object) => ({ id: 'a', itemId: 'i1', partId: 'p', assetId: 's', libraryId: 'l', kind: 'episode', title: '示例剧集', partTitle: '第 3 集', start: 0, end: 2480, available: 1, position: 1748, completed: false, ...over });
function mount({ reading = [book], video = [activity({})], audio = [] as object[] }: { reading?: object[]; video?: object[]; audio?: object[] } = {}) {
const series = { id: 's1', libraryId: 'l', kind: 'series', title: '示例剧集', parentId: null, metadata: {}, overrides: {}, children: [], editions: [] };
const episode = { ...series, id: 'i1', kind: 'episode', title: '第 3 集', parentId: 's1' };
const poster = (id: string, title: string) => ({ id, libraryId: 'l', kind: 'movie', title, parentId: null, metadata: {}, overrides: {}, channel: 'video', libraryName: '片库' });
function mount({ reading = [book], video = [activity({ title: '第 3 集', updatedAt: 5 })], audio = [] as object[], discover = { items: [] as object[], total: 0 } }: { reading?: object[]; video?: object[]; audio?: object[]; discover?: { items: object[]; total: number } } = {}) {
const runtime = {
api: {
baseUrl: 'https://reader.example.com',
@@ -25,7 +29,11 @@ function mount({ reading = [book], video = [activity({})], audio = [] as object[
},
mediaApi: {
preferenceScope: () => 'home-test',
request: vi.fn(async (path: string) => ({ items: path.includes('channel=video') ? video : audio, total: 0 })),
request: vi.fn(async (path: string) => path.startsWith('browse?')
? (path.includes('channel=video&') ? discover : { items: [], total: 0 })
: { items: path.includes('channel=video') ? video : audio, total: 0 }),
detail: vi.fn(async (id: string) => id === 's1' ? series : episode),
cover: vi.fn().mockRejectedValue(new Error('none')),
},
} as any;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -35,6 +43,7 @@ function mount({ reading = [book], video = [activity({})], audio = [] as object[
</QueryClientProvider></RuntimeContext.Provider>,
root,
));
return runtime;
}
const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
@@ -51,29 +60,63 @@ describe('首页', () => {
expect(activityProgress(activity({ position: 99999 }) as any)).toBe(1);
});
it('resumes the book first, then the unfinished media, then the shelf', async () => {
it('shows every continue entry as the same card, most recent first, with the series title', async () => {
useAuthStore.setState({ verifiedUser: user as any });
mount();
await settle();
expect(root.querySelector('h1')?.textContent).toContain('林间');
const hero = root.querySelector<HTMLAnchorElement>('a.home-hero')!;
expect(hero.getAttribute('href')).toBe('/book/b1');
expect(hero.textContent).toContain('第十二章 夜雨');
expect(hero.textContent).toContain('42%');
const card = root.querySelector<HTMLAnchorElement>('a.home-card[data-channel="video"]')!;
expect(card.getAttribute('href')).toBe('/media/video/items/i1');
expect(card.textContent).toContain('示例剧集 · 第 3 集');
expect(card.textContent).toContain('已看 29:08 / 41:20');
expect(root.querySelector('a.home-card[data-channel="audiobook"]')).toBeNull();
const cards = [...root.querySelectorAll<HTMLAnchorElement>('.home-continue a.home-resume')];
expect(cards.map(card => card.dataset.channel)).toEqual(['video', 'read']);
const [video, read] = cards as [HTMLAnchorElement, HTMLAnchorElement];
expect(video.getAttribute('href')).toBe('/media/video/items/i1');
expect(video.querySelector('strong')?.textContent).toBe('示例剧集');
expect(video.querySelector('small')?.textContent).toBe('第 3 集');
expect(video.textContent).toContain('29:08 / 41:20');
expect(read.getAttribute('href')).toBe('/book/b1');
expect(read.textContent).toContain('第十二章 夜雨');
expect(read.textContent).toContain('已读 42%');
expect(root.querySelector('.home-empty')).toBeNull();
expect(root.querySelectorAll('.home-shelf .book-card').length).toBe(1);
expect(root.querySelector('.home-discover')).toBeNull();
});
it('shows recently added media by default, and a reshuffleable random batch on request', async () => {
useAuthStore.setState({ verifiedUser: user as any });
localStorage.clear();
const runtime = mount({ discover: { items: [poster('m1', '远山'), poster('m2', '近水')], total: 9 } });
const browses = () => runtime.mediaApi.request.mock.calls.map(([path]: [string]) => path).filter((path: string) => path.startsWith('browse?channel=video'));
await settle();
const section = () => root.querySelector<HTMLElement>('.home-discover[data-channel="video"]')!;
expect(section().querySelector('h2')?.textContent).toBe('看点什么');
expect([...section().querySelectorAll('a.home-tile')].map(tile => tile.getAttribute('href'))).toEqual(['/media/video/items/m1', '/media/video/items/m2']);
expect(root.querySelector('.home-discover[data-channel="audiobook"]')).toBeNull();
expect(root.querySelector('.home-discover[data-channel="music"]')).toBeNull();
expect(browses()).toEqual([expect.stringContaining('sort=added')]);
expect(section().querySelector('.home-shuffle')).toBeNull();
const random = [...section().querySelectorAll<HTMLButtonElement>('.segmented button')].find(button => button.textContent === '随便看看')!;
act(() => random.click());
await settle();
expect(random.getAttribute('aria-pressed')).toBe('true');
expect(localStorage.getItem('home.discover.video')).toBe('random');
expect(browses().at(-1)).toContain('sort=random');
act(() => section().querySelector<HTMLButtonElement>('.home-shuffle')!.click());
await settle();
expect(browses().filter((path: string) => path.includes('sort=random')).length).toBe(2);
});
it('lists up to two books in progress among the media, by recency', async () => {
useAuthStore.setState({ verifiedUser: user as any });
mount({ reading: [{ ...book, id: 'b2', title: '新书', lastReadAt: 9 }, book] });
await settle();
expect([...root.querySelectorAll<HTMLAnchorElement>('a.home-resume')].map(card => card.getAttribute('href'))).toEqual(['/book/b2', '/media/video/items/i1', '/book/b1']);
});
it('skips finished media and invites the reader to the shelf when nothing is in progress', async () => {
useAuthStore.setState({ verifiedUser: user as any });
mount({ reading: [], video: [activity({ completed: true })] });
await settle();
expect(root.querySelector('a.home-hero')).toBeNull();
expect(root.querySelector('a.home-resume')).toBeNull();
expect(root.querySelector('.home-empty a')?.getAttribute('href')).toBe('/shelf');
expect(root.querySelector('a.home-card')).toBeNull();
});
});