From df939002369164679b299df8a8fc4a07cb226cab Mon Sep 17 00:00:00 2001 From: hectorqin Date: Sat, 31 Oct 2026 14:24:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=20continue=20cards?= =?UTF-8?q?=20share=20one=20shape;=20recently=20added=20/=20random=20media?= =?UTF-8?q?=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Books, video and audiobooks in 「继续」 are the same card (cover, work title, episode/chapter, progress), most recent first, up to two books. An episode resolves to its series for title and poster instead of reading "第 1 集". - New rows 看点什么 / 听本书 / 听张专辑, each switching between 最近添加 and 随便看看 (with 换一批); hidden when the channel is empty or unavailable. - Server: browse accepts sort=random and sort=added (newest available file mtime across the work and its episodes/tracks; no schema change). Co-Authored-By: Claude Opus 5.5 --- docs/ui.md | 2 +- server/src/http/routes/media.ts | 2 +- server/src/media/catalog.ts | 15 +- server/test/media-search.test.ts | 4 + server/test/media-video-home.test.ts | 22 +++ web/src/features/home/pages/HomePage.tsx | 174 ++++++++++++------ web/src/features/home/styles/home.css | 154 ++++++++-------- .../media/components/continue-playing.tsx | 11 +- web/src/features/media/services/work.ts | 17 ++ web/test/home-page.test.tsx | 71 +++++-- 10 files changed, 315 insertions(+), 157 deletions(-) create mode 100644 web/src/features/media/services/work.ts diff --git a/docs/ui.md b/docs/ui.md index f27cd68..a0d6cd8 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -857,7 +857,7 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 - 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 `web/src/app/AppNav.tsx` 的 `navMode`。 - 主题在应用壳层统一应用(`ThemeController` 写 ``)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 `--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=`)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。 diff --git a/server/src/http/routes/media.ts b/server/src/http/routes/media.ts index 24d00af..fb5a80e 100644 --- a/server/src/http/routes/media.ts +++ b/server/src/http/routes/media.ts @@ -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',{ diff --git a/server/src/media/catalog.ts b/server/src/media/catalog.ts index 3acf34d..41736ac 100644 --- a/server/src/media/catalog.ts +++ b/server/src/media/catalog.ts @@ -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`; } diff --git a/server/test/media-search.test.ts b/server/test/media-search.test.ts index c87e61b..2ed8696 100644 --- a/server/test/media-search.test.ts +++ b/server/test/media-search.test.ts @@ -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();} }); diff --git a/server/test/media-video-home.test.ts b/server/test/media-video-home.test.ts index f26f143..e781c87 100644 --- a/server/test/media-video-home.test.ts +++ b/server/test/media-video-home.test.ts @@ -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();} +}); diff --git a/web/src/features/home/pages/HomePage.tsx b/web/src/features/home/pages/HomePage.tsx index eac9db2..9bfec16 100644 --- a/web/src/features/home/pages/HomePage.tsx +++ b/web/src/features/home/pages/HomePage.tsx @@ -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 - - - {book.title || '未命名'} - {book.chapterTitle && {book.chapterTitle}} - - 继续阅读 - - - 已读 {percent}% - +type Channel = 'read' | 'video' | 'audiobook'; +const CHANNEL_LABEL: Record = { 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 + + + {CHANNEL_LABEL[channel]} + {title} + {detail && {detail}} + + + {meter} + ; } -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 - - - {label} - {title} - {meta} - - - - ; +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 : {title}} />; } -/** 首页: 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 } />; +} + +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(() => 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
+
+

{heading}

+ {mode === 'random' && sample.data!.total > items.length && } + { setMode(next); saveMode(channel, next); }} /> + 全部 +
+
{items.map(item => + + {item.title} + {channel === 'music' && typeof item.metadata.artist === 'string' && {item.metadata.artist}} + )}
+
; +} + +/** 首页: 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: }); + if (videoActivity) resumes.push({ at: videoActivity.updatedAt ?? 0, node: }); + if (audioActivity) resumes.push({ at: audioActivity.updatedAt ?? 0, node: }); + resumes.sort((a, b) => b.at - a.at); + return

{greeting(new Date().getHours())}{name ? `,${name}` : ''}

从上次停下的地方继续

-
+
+ {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 - ? - : reading.isPending - ?
正在读取在读的书…
- : reading.error - ?
在读的书读取失败
- :
还没有在读的书去书架挑一本
} - {(videoActivity || audioActivity) &&
- {videoActivity && } - {audioActivity && } -
} + 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 + ?
正在读取在读的书…
+ : reading.error + ?
在读的书读取失败
+ : !resumes.length &&
还没有在读的书去书架挑一本
)}
{books.length > 0 &&

我的书架

全部
{books.map(item => )}
} + + +
; } diff --git a/web/src/features/home/styles/home.css b/web/src/features/home/styles/home.css index c1b7352..286dd2c 100644 --- a/web/src/features/home/styles/home.css +++ b/web/src/features/home/styles/home.css @@ -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); }} diff --git a/web/src/features/media/components/continue-playing.tsx b/web/src/features/media/components/continue-playing.tsx index 199a084..05ec68b 100644 --- a/web/src/features/media/components/continue-playing.tsx +++ b/web/src/features/media/components/continue-playing.tsx @@ -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]); diff --git a/web/src/features/media/services/work.ts b/web/src/features/media/services/work.ts new file mode 100644 index 0000000..5c29be7 --- /dev/null +++ b/web/src/features/media/services/work.ts @@ -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 }; +} diff --git a/web/test/home-page.test.tsx b/web/test/home-page.test.tsx index 5103624..58382b1 100644 --- a/web/test/home-page.test.tsx +++ b/web/test/home-page.test.tsx @@ -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[ , 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('a.home-hero')!; - expect(hero.getAttribute('href')).toBe('/book/b1'); - expect(hero.textContent).toContain('第十二章 夜雨'); - expect(hero.textContent).toContain('42%'); - const card = root.querySelector('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('.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('.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('.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('.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('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(); }); });