mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 20:26:36 +08:00
优化阅读功能
This commit is contained in:
+85
-2
@@ -65,6 +65,13 @@ export interface ReaderBook {
|
||||
dur_chapter_title: string
|
||||
dur_chapter_time: number
|
||||
order: number
|
||||
/** 本地导入书籍(TXT / EPUB / 有声书目录),正文在服务端,不走书源。 */
|
||||
is_local: boolean
|
||||
/** 原地引用服务器上已有的文件/目录:移出书架只解除引用,不删除源文件。 */
|
||||
local_external: boolean
|
||||
/** 听书跳过片头/片尾秒数(0 表示不跳过,对应 legado Book.openCredits/closeCredits)。 */
|
||||
open_credits: number
|
||||
close_credits: number
|
||||
}
|
||||
|
||||
export interface ReaderBookInfo {
|
||||
@@ -100,6 +107,8 @@ export interface ReaderChapterContent {
|
||||
tracks?: string[]
|
||||
images?: string[]
|
||||
image_style?: string
|
||||
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
}
|
||||
|
||||
export interface ReaderReplaceRule {
|
||||
@@ -148,11 +157,26 @@ export interface ReaderBrowserRequest {
|
||||
title: string
|
||||
}
|
||||
|
||||
/** 书源 JS 交给宿主浏览器承载的一个页面(java.startBrowser / startBrowserAwait)。 */
|
||||
export interface ReaderBrowserPage {
|
||||
id: string
|
||||
title: string
|
||||
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
|
||||
mode: 'wait' | 'open'
|
||||
/** iframe 承载地址(同源,带签名)。 */
|
||||
page_url: string
|
||||
refetch: boolean
|
||||
source_id: string
|
||||
target_url?: string
|
||||
}
|
||||
|
||||
export interface ReaderLoginResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
toasts?: string[]
|
||||
browsers?: ReaderBrowserRequest[]
|
||||
/** 书源要求重新渲染登录表单(java.reLoginView / refreshExplore / upLoginData)。 */
|
||||
ui_refresh?: boolean
|
||||
values: Record<string, string>
|
||||
cookies: Record<string, string>
|
||||
logged_in: boolean
|
||||
@@ -160,6 +184,10 @@ export interface ReaderLoginResult {
|
||||
|
||||
const longOpts = { timeout: LONG_REQUEST_TIMEOUT } as const
|
||||
|
||||
// 登录动作可能阻塞等待用户在页面里操作(java.startBrowserAwait),
|
||||
// 服务端上限 10 分钟,这里留出余量,避免 axios 先超时把请求掐掉。
|
||||
const browserWaitOpts = { timeout: 11 * 60_000 } as const
|
||||
|
||||
export const readerAPI = {
|
||||
// ── 书源 ──
|
||||
listSources: () => api.get<{ sources: ReaderSource[] }>('/reader/sources').then((r) => r.data.sources),
|
||||
@@ -175,7 +203,30 @@ export const readerAPI = {
|
||||
sourceLogin: (id: string) => api.get<ReaderSourceLogin>(`/reader/sources/${id}/login`).then((r) => r.data),
|
||||
// 执行登录动作:action 为空表示执行 loginUrl 的 login()(确认登录)
|
||||
runSourceLogin: (id: string, body: { action?: string; fields?: Record<string, string> }) =>
|
||||
api.post<ReaderLoginResult>(`/reader/sources/${id}/login`, body, longOpts).then((r) => r.data),
|
||||
api.post<ReaderLoginResult>(`/reader/sources/${id}/login`, body, browserWaitOpts).then((r) => r.data),
|
||||
// 书源 JS 的宿主浏览器:轮询待用户完成的页面(startBrowserAwait 会阻塞在服务端)
|
||||
browserPending: (sourceId: string) =>
|
||||
api
|
||||
.get<{ pages: ReaderBrowserPage[] }>('/reader/browser/pending', { params: { source_id: sourceId } })
|
||||
.then((r) => r.data.pages ?? []),
|
||||
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
|
||||
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
|
||||
api.post('/reader/browser/result', body).then((r) => r.data),
|
||||
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
|
||||
browserXHR: (body: {
|
||||
id: string
|
||||
url: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: string
|
||||
}) =>
|
||||
api
|
||||
.post<{ status: number; content_type: string; body: string; base64: boolean }>(
|
||||
'/reader/browser/xhr',
|
||||
body,
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
// 仅保存表单值,不触发登录
|
||||
saveSourceLoginInfo: (id: string, fields: Record<string, string>) =>
|
||||
api.put(`/reader/sources/${id}/login-info`, { fields }),
|
||||
@@ -185,7 +236,11 @@ export const readerAPI = {
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
search: (key: string) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[]; skipped: ReaderSearchSkipped[] }>('/reader/search', { key }, longOpts)
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
bookInfo: (params: { source_id?: string; source_url?: string; book_url: string }) =>
|
||||
api.get<ReaderBookInfo>('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
@@ -202,9 +257,37 @@ export const readerAPI = {
|
||||
listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books),
|
||||
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
|
||||
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
|
||||
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
|
||||
uploadLocalBook: (file: File, onProgress?: (percent: number) => void) =>
|
||||
api
|
||||
.post<ReaderBook>('/reader/local/books', (() => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return form
|
||||
})(), {
|
||||
timeout: 0,
|
||||
onUploadProgress: (e) => {
|
||||
if (!onProgress || !e.total) return
|
||||
onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
/** 从服务器已有文件导入书籍(TXT / EPUB),原地引用不复制。仅管理员。 */
|
||||
importLocalBookFromPath: (path: string) =>
|
||||
api
|
||||
.post<ReaderBook>('/reader/local/books/from-path', { path }, { timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
/** 把服务器上的一本目录导入为有声书(音频文件 + .strm 播放指针)。仅管理员。 */
|
||||
importLocalAudioDir: (path: string) =>
|
||||
api
|
||||
.post<ReaderBook>('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
removeBook: (id: string) => api.delete(`/reader/books/${id}`),
|
||||
saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) =>
|
||||
api.put(`/reader/books/${id}/progress`, body),
|
||||
// 听书跳过片头/片尾(秒,0 不跳过)
|
||||
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
|
||||
api.put(`/reader/books/${id}/audio-config`, body),
|
||||
listChapters: (id: string) =>
|
||||
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
|
||||
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { prefetchCommonRouteChunks } from '../appRoutes'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { useReaderSettingsStore } from '../stores/readerSettings'
|
||||
import {
|
||||
LayoutHeader,
|
||||
LayoutSidebars,
|
||||
@@ -48,6 +49,7 @@ export function Layout() {
|
||||
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||
const theme = useThemeMode()
|
||||
@@ -61,7 +63,9 @@ export function Layout() {
|
||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||
const hideSearch = location.pathname.startsWith('/settings')
|
||||
const isPlayPage = isPlayerRoute(location.pathname)
|
||||
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
|
||||
// 阅读模式:首页切到阅读后,影视那一套壳(媒体搜索、账号菜单、底部导航)都不该出现
|
||||
const readingMode = homeMode === 'reading' && location.pathname === '/'
|
||||
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname) && !readingMode
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
@@ -84,6 +88,8 @@ export function Layout() {
|
||||
onLogout={closeProfileAndLogout}
|
||||
showSidebar={showSidebar}
|
||||
hideSearch={hideSearch}
|
||||
readingMode={readingMode}
|
||||
showReaderToggle={location.pathname === '/'}
|
||||
pathname={location.pathname}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
import { ArrowLeft, BookOpen, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
|
||||
import { ARTWORK, imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { readerAPI, type ReaderBook } from '../api/reader'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { favouriteMediaLink } from '../utils/mediaNavigation'
|
||||
import { resolveHeaderBack } from './layoutNavigation'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import { LayoutReaderModeToggle } from './LayoutReaderModeToggle'
|
||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import type { ThemeMode, useThemeMode } from './useThemeMode'
|
||||
|
||||
@@ -31,6 +33,10 @@ type LayoutHeaderProps = {
|
||||
onLogout: () => void
|
||||
showSidebar?: boolean
|
||||
hideSearch?: boolean
|
||||
/** 阅读模式:顶部搜索换成书搜索,并保留账号菜单。 */
|
||||
readingMode?: boolean
|
||||
/** 是否显示顶栏的「影视 / 阅读」图标切换(只在首页)。 */
|
||||
showReaderToggle?: boolean
|
||||
pathname?: string
|
||||
}
|
||||
|
||||
@@ -44,6 +50,8 @@ export function LayoutHeader({
|
||||
onLogout,
|
||||
showSidebar,
|
||||
hideSearch,
|
||||
readingMode,
|
||||
showReaderToggle,
|
||||
pathname = '',
|
||||
}: LayoutHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
@@ -85,9 +93,11 @@ export function LayoutHeader({
|
||||
|
||||
{/* Middle: Search Box */}
|
||||
<div className="flex min-w-0 flex-1 max-w-xl mx-auto">
|
||||
{!hideSearch && <LayoutHeaderSearch />}
|
||||
{readingMode ? <LayoutHeaderBookSearch /> : !hideSearch && <LayoutHeaderSearch />}
|
||||
</div>
|
||||
|
||||
{showReaderToggle && <LayoutReaderModeToggle />}
|
||||
|
||||
{/* Right: Actions (Theme Toggle & User Menu) */}
|
||||
<LayoutHeaderActions
|
||||
permissions={permissions}
|
||||
@@ -108,6 +118,191 @@ export function LayoutHeader({
|
||||
)
|
||||
}
|
||||
|
||||
// 阅读模式的顶部搜索:先搜书架(本地即时过滤),
|
||||
// 再给一个「在书源中搜索」的入口跳到多源聚合搜索页。
|
||||
function LayoutHeaderBookSearch() {
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [books, setBooks] = useState<ReaderBook[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true)
|
||||
if (books !== null || loading) return
|
||||
setLoading(true)
|
||||
readerAPI
|
||||
.listBooks()
|
||||
.then(setBooks)
|
||||
.catch(() => setBooks([]))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const close = () => setIsOpen(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) close()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown)
|
||||
}, [isOpen])
|
||||
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const matched = (books ?? [])
|
||||
.filter(
|
||||
(b) =>
|
||||
!keyword ||
|
||||
b.name.toLowerCase().includes(keyword) ||
|
||||
(b.author ?? '').toLowerCase().includes(keyword),
|
||||
)
|
||||
.slice(0, 8)
|
||||
|
||||
const openBook = (book: ReaderBook) => {
|
||||
close()
|
||||
setQuery('')
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
}
|
||||
|
||||
const searchSources = () => {
|
||||
const key = query.trim()
|
||||
if (!key) return
|
||||
close()
|
||||
navigate(`/reader/search?key=${encodeURIComponent(key)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
onFocus={openDropdown}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (matched.length > 0) openBook(matched[0])
|
||||
else searchSources()
|
||||
} else if (e.key === 'Escape') {
|
||||
close()
|
||||
}
|
||||
}}
|
||||
placeholder="搜索书籍…"
|
||||
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
|
||||
) : query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
inputRef.current?.focus()
|
||||
setIsOpen(true)
|
||||
}}
|
||||
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||
aria-label="清空"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl">
|
||||
{books === null || loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--app-muted)]">
|
||||
<LoaderCircle size={14} className="text-brand-500 animate-spin" />
|
||||
正在加载书架…
|
||||
</div>
|
||||
) : matched.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
||||
{query.trim() ? `书架里没有与 “${query}” 相关的书` : '书架还是空的,先导入书源或本地书籍'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="px-2 pb-1 pt-1 text-[10px] font-bold text-[var(--app-muted)]">
|
||||
{query.trim() ? '书架匹配' : '书架'}
|
||||
</p>
|
||||
{matched.map((book) => (
|
||||
<button
|
||||
key={book.id}
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
openBook(book)
|
||||
}}
|
||||
onClick={() => openBook(book)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<BookOpen size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-xs font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||
{book.name}
|
||||
</p>
|
||||
{book.is_local && (
|
||||
<span className="shrink-0 rounded border border-[var(--app-border)] bg-[var(--app-panel-elevated)] px-1.5 py-0.5 text-[9px] text-[var(--app-muted)]">
|
||||
本地
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--app-muted)]">
|
||||
<span className="truncate">{book.author || '佚名'}</span>
|
||||
{book.dur_chapter_title && <span className="truncate">读到 {book.dur_chapter_title}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
searchSources()
|
||||
}}
|
||||
onClick={searchSources}
|
||||
disabled={!query.trim()}
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-xl border border-dashed border-[var(--app-border)] px-3 py-2 text-left text-xs font-bold text-brand-600 transition-colors hover:bg-[var(--app-hover)] disabled:opacity-50"
|
||||
>
|
||||
<Search size={13} />
|
||||
{query.trim() ? `在书源中搜索「${query.trim()}」` : '输入关键词后可在书源中搜索'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SEARCH_PAGE_SIZE = 8
|
||||
|
||||
function LayoutHeaderSearch() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BookOpen, Clapperboard } from 'lucide-react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useReaderSettingsStore } from '../stores/readerSettings'
|
||||
|
||||
// 顶栏的「影视 / 阅读」切换:只显示当前模式的图标(影视 / 阅读),点一下切到另一个模块。
|
||||
// 模式只作用于首页(`/`),所以不在首页时顺带跳回首页,避免点了没反应。
|
||||
export function LayoutReaderModeToggle() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||
const setHomeMode = useReaderSettingsStore((s) => s.setHomeMode)
|
||||
|
||||
const reading = homeMode === 'reading'
|
||||
const label = reading ? '当前是阅读模式,点击切换到影视' : '当前是影视模式,点击切换到阅读'
|
||||
|
||||
const toggle = () => {
|
||||
setHomeMode(reading ? 'media' : 'reading')
|
||||
if (location.pathname !== '/') navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`shrink-0 rounded-xl border p-2.5 transition-colors ${
|
||||
reading
|
||||
? 'border-brand-500/60 bg-brand-500/10 text-brand-600 hover:bg-brand-500/20'
|
||||
: 'border-[var(--app-border)] text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||
}`}
|
||||
>
|
||||
{reading ? <BookOpen size={18} /> : <Clapperboard size={18} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
HomeLoadingState,
|
||||
} from './HomePageSections'
|
||||
import { ReaderHomeContent } from './reader/ReaderHomeContent'
|
||||
import { ReaderModeSwitch } from './reader/ReaderModeSwitch'
|
||||
import { useReaderSettingsStore } from '../stores/readerSettings'
|
||||
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
@@ -323,7 +322,7 @@ export function HomePage() {
|
||||
if (homeMode === 'reading') {
|
||||
return (
|
||||
<div className="space-y-6 pb-16">
|
||||
<ReaderHomeContent />
|
||||
<ReaderHomeContent embedded />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -340,7 +339,6 @@ export function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-12 pb-16">
|
||||
<ReaderModeSwitch />
|
||||
{/* 1. 顶部海报轮播区 */}
|
||||
{carouselItems.length > 0 && (
|
||||
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ExternalLink, Loader2, RefreshCw, X } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderBrowserPage } from '../../api/reader'
|
||||
|
||||
// 书源页面的承载面板(对应 legado 的 BottomWebViewDialog)。
|
||||
//
|
||||
// 书源通过 java.startBrowser / java.startBrowserAwait 把页面交给宿主:
|
||||
// - startBrowser / showBrowser:只展示,用户看完关掉即可;
|
||||
// - startBrowserAwait:服务端会一直阻塞,直到这里的「√」把用户操作后的
|
||||
// DOM 回传过去,书源再从 DOM 里解析出结果(光遇聚合的「切换线路」
|
||||
// 就是从回传页面里抓 #serverValue 写进源变量)。
|
||||
//
|
||||
// iframe 刻意不带 allow-same-origin:页面是第三方 HTML,给它同源权限就能读写
|
||||
// 本应用的 localStorage(JWT)。因此改由服务端注入的脚本用 postMessage 回传
|
||||
// DOM,父窗口只负责发一个「请把当前 DOM 给我」的消息。
|
||||
|
||||
interface Props {
|
||||
page: ReaderBrowserPage
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 请求 iframe 回传 DOM 的消息标识(与 browser_panel.go 注入的脚本一致)。 */
|
||||
const DOM_REQUEST = '__mebox_dom__'
|
||||
|
||||
/** iframe 内页面的 fetch/XHR 代理消息(同浏览器 bridge 脚本)。 */
|
||||
interface ProxyRequest {
|
||||
__mebox_proxy__?: boolean
|
||||
reqId?: string
|
||||
url?: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: string
|
||||
}
|
||||
|
||||
export default function BrowserPanel({ page, onClose }: Props) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
||||
const awaitingRef = useRef(false)
|
||||
const timerRef = useRef<number | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [awaiting, setAwaiting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// 提交回传结果:body 为用户操作后的页面 DOM(服务端作为 StrResponse.body())
|
||||
const submit = useCallback(
|
||||
async (html: string | null) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await readerAPI.submitBrowserResult({
|
||||
id: page.id,
|
||||
body: html ?? '',
|
||||
url: page.target_url ?? '',
|
||||
cancelled: html === null,
|
||||
})
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '提交失败,请重试')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[page.id, page.target_url, onClose],
|
||||
)
|
||||
|
||||
// 接收 iframe 的消息:① 回传 DOM;② 代发页面自己的接口请求
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
const data = ev.data as ProxyRequest & { __mebox_dom__?: boolean; html?: string } | null
|
||||
if (!data) return
|
||||
|
||||
if (data.__mebox_dom__ === true) {
|
||||
if (!awaitingRef.current) return
|
||||
awaitingRef.current = false
|
||||
setAwaiting(false)
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
void submit(data.html ?? '')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.__mebox_proxy__ === true && data.reqId) {
|
||||
const win = iframeRef.current?.contentWindow
|
||||
const reply = (payload: Record<string, unknown>) => {
|
||||
win?.postMessage({ __mebox_proxy_res__: true, reqId: data.reqId, ...payload }, '*')
|
||||
}
|
||||
void readerAPI
|
||||
.browserXHR({
|
||||
id: page.id,
|
||||
url: data.url ?? '',
|
||||
method: data.method ?? 'GET',
|
||||
headers: data.headers ?? {},
|
||||
body: data.body ?? '',
|
||||
})
|
||||
.then((res) => reply({ ...res }))
|
||||
.catch((e) => {
|
||||
// 让页面自己走失败分支,而不是一直挂着
|
||||
reply({
|
||||
status: 502,
|
||||
contentType: 'text/plain',
|
||||
body: String((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '代理请求失败'),
|
||||
base64: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [submit, page.id])
|
||||
|
||||
const confirm = () => {
|
||||
const win = iframeRef.current?.contentWindow
|
||||
if (!win) {
|
||||
setError('页面尚未加载完成')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
awaitingRef.current = true
|
||||
setAwaiting(true)
|
||||
try {
|
||||
win.postMessage(DOM_REQUEST, '*')
|
||||
} catch {
|
||||
awaitingRef.current = false
|
||||
setAwaiting(false)
|
||||
setError('无法读取页面内容')
|
||||
return
|
||||
}
|
||||
// 兜底:3 秒没回传就提示用户页面可能还没准备好
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (!awaitingRef.current) return
|
||||
awaitingRef.current = false
|
||||
setAwaiting(false)
|
||||
setError('读取页面内容超时,请确认页面已加载完成后重试')
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const externalURL = page.target_url && !page.target_url.startsWith('data:') ? page.target_url : ''
|
||||
const needConfirm = page.mode === 'wait'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex flex-col bg-black/60 p-0 sm:p-6"
|
||||
// 面板叠在登录对话框之上,而登录对话框的遮罩是「点一下关闭」。
|
||||
// 不拦住冒泡的话,点「完成」会把整个登录对话框一起关掉。
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl flex-col overflow-hidden rounded-none border border-[var(--app-border)] bg-[var(--app-panel)] sm:rounded-2xl">
|
||||
{/* 顶栏:标题 + 外部打开 + 关闭 */}
|
||||
<div className="flex items-center gap-3 border-b border-[var(--app-border)] px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)]">
|
||||
{page.title || '书源页面'}
|
||||
</p>
|
||||
<p className="truncate text-2xs text-[var(--app-muted)]">
|
||||
{page.mode === 'wait' ? '操作完成后点右下角「完成」把结果回传给书源' : '确认完毕后关闭即可'}
|
||||
{page.target_url ? ` · ${page.target_url}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!loaded && <Loader2 size={15} className="animate-spin text-[var(--app-muted)]" />}
|
||||
<button
|
||||
type="button"
|
||||
title="重新加载"
|
||||
onClick={() => {
|
||||
setLoaded(false)
|
||||
if (iframeRef.current) iframeRef.current.src = page.page_url
|
||||
}}
|
||||
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
{externalURL && (
|
||||
<button
|
||||
type="button"
|
||||
title="在新标签打开原始地址(浏览器里没有书源登录态)"
|
||||
onClick={() => window.open(externalURL, '_blank', 'noopener,noreferrer')}
|
||||
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-xs text-red-500">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 页面本体:sandbox 不含 allow-same-origin,避免第三方页面拿到本应用权限 */}
|
||||
<div className="min-h-0 flex-1 bg-white">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={page.title || '书源页面'}
|
||||
src={page.page_url}
|
||||
onLoad={() => setLoaded(true)}
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals"
|
||||
className="h-full w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部动作 */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--app-border)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void submit(null)}
|
||||
className="btn-outline text-xs text-red-500 disabled:opacity-50"
|
||||
>
|
||||
<X size={13} className="mr-1 inline" /> 取消
|
||||
</button>
|
||||
{needConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || awaiting}
|
||||
onClick={confirm}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{awaiting ? (
|
||||
<Loader2 size={13} className="mr-1 inline animate-spin" />
|
||||
) : (
|
||||
<Check size={13} className="mr-1 inline" />
|
||||
)}
|
||||
完成(回传页面)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +1,147 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Loader2, Pause, Play } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Gauge,
|
||||
ListMusic,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
Timer,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
|
||||
// 音频播放面板(仿 legado ReadAloudDialog transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链;上一章/播放暂停/下一章 + 进度条 + 倍速。
|
||||
import type { ReaderChapter } from '../../api/reader'
|
||||
import {
|
||||
AUDIO_CREDITS_MAX,
|
||||
AUDIO_SPEEDS,
|
||||
AUDIO_TIMERS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
|
||||
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链。
|
||||
// transport:上一章 | -15s | 播放暂停 | +15s | 下一章;
|
||||
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
|
||||
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
|
||||
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
|
||||
|
||||
interface ReaderAudioPanelProps {
|
||||
src: string
|
||||
title: string
|
||||
/** 书籍封面:做背景(强模糊)与居中圆形封面,对应 legado ivBg / ivCover。 */
|
||||
cover: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
initialPos: number // 恢复进度(秒)
|
||||
/** 片头跳过秒数(Book.openCredits)。 */
|
||||
openCredits: number
|
||||
/** 片尾跳过秒数(Book.closeCredits)。 */
|
||||
closeCredits: number
|
||||
chapters: ReaderChapter[]
|
||||
chapterIndex: number | null
|
||||
/** 是否还有下一章(决定片尾跳过/播完是续播还是停住)。 */
|
||||
hasNext: boolean
|
||||
/** 该音轨由服务端转码,首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
onProgress: (seconds: number) => void
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onSelectChapter: (index: number) => void
|
||||
onEnded: () => void
|
||||
onCreditsChange: (open: number, close: number) => void
|
||||
onToggleMenu: () => void
|
||||
}
|
||||
|
||||
const RATES = [0.75, 1, 1.25, 1.5, 2]
|
||||
/** -15s / +15s 步长(legado AudioPlayActivity.SEEK_STEP)。 */
|
||||
const SEEK_STEP = 15
|
||||
/** 片头片尾滑杆步进(秒)。 */
|
||||
const CREDIT_STEP = 5
|
||||
/** 动作行高度 76px:抽屉与遮罩都从动作行上沿开始,保证动作行常驻可点。 */
|
||||
const ACTION_BAR_BOTTOM = 'bottom-[76px]'
|
||||
|
||||
type Sheet = 'none' | 'chapters' | 'timer' | 'speed' | 'credits'
|
||||
|
||||
function fmt(sec: number): string {
|
||||
if (!Number.isFinite(sec)) return '0:00'
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function fmtCountdown(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function ReaderAudioPanel({
|
||||
src,
|
||||
title,
|
||||
cover,
|
||||
theme,
|
||||
initialPos,
|
||||
openCredits,
|
||||
closeCredits,
|
||||
chapters,
|
||||
chapterIndex,
|
||||
hasNext,
|
||||
transcoding = false,
|
||||
onProgress,
|
||||
onPrevChapter,
|
||||
onNextChapter,
|
||||
onSelectChapter,
|
||||
onEnded,
|
||||
onCreditsChange,
|
||||
onToggleMenu,
|
||||
}: ReaderAudioPanelProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
||||
const restoredRef = useRef(false)
|
||||
const skippedEndRef = useRef(false)
|
||||
|
||||
const speed = useReaderSettingsStore((s) => s.audioSpeed)
|
||||
const setSpeed = useReaderSettingsStore((s) => s.setAudioSpeed)
|
||||
const defaultTimer = useReaderSettingsStore((s) => s.audioTimerMinutes)
|
||||
const setDefaultTimer = useReaderSettingsStore((s) => s.setAudioTimerMinutes)
|
||||
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [cur, setCur] = useState(0)
|
||||
const [dur, setDur] = useState(0)
|
||||
const [rateIdx, setRateIdx] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
|
||||
const [audioError, setAudioError] = useState('')
|
||||
const [sheet, setSheet] = useState<Sheet>('none')
|
||||
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
|
||||
const [coverOK, setCoverOK] = useState(true)
|
||||
useEffect(() => {
|
||||
setCoverOK(true)
|
||||
}, [cover])
|
||||
|
||||
// 换源/换章:重建播放器
|
||||
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
|
||||
const [timerLeft, setTimerLeft] = useState(0)
|
||||
const [timerActive, setTimerActive] = useState(false)
|
||||
const [timerPreset, setTimerPreset] = useState(defaultTimer)
|
||||
const timerDeadlineRef = useRef(0)
|
||||
|
||||
// 片头片尾草稿值(滑杆拖动中先本地预览,松手才落库)
|
||||
const [creditDraft, setCreditDraft] = useState({ open: openCredits, close: closeCredits })
|
||||
useEffect(() => {
|
||||
setCreditDraft({ open: openCredits, close: closeCredits })
|
||||
}, [openCredits, closeCredits])
|
||||
|
||||
// ── 换源/换章:重建播放器 ──
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
restoredRef.current = false
|
||||
skippedEndRef.current = false
|
||||
setLoading(true)
|
||||
setAudioError('')
|
||||
setCur(0)
|
||||
setDur(0)
|
||||
let cancelled = false
|
||||
let hls: { destroy: () => void } | null = null
|
||||
|
||||
@@ -87,9 +178,57 @@ export function ReaderAudioPanel({
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.playbackRate = RATES[rateIdx]
|
||||
}, [rateIdx])
|
||||
if (audio) audio.playbackRate = speed
|
||||
}, [speed])
|
||||
|
||||
// ── 定时关闭 ──
|
||||
const applyTimer = useCallback(
|
||||
(minutes: number) => {
|
||||
setDefaultTimer(minutes)
|
||||
setTimerPreset(minutes)
|
||||
if (minutes <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerLeft(0)
|
||||
setTimerActive(false)
|
||||
return
|
||||
}
|
||||
timerDeadlineRef.current = Date.now() + minutes * 60_000
|
||||
setTimerLeft(minutes * 60)
|
||||
setTimerActive(true)
|
||||
},
|
||||
[setDefaultTimer],
|
||||
)
|
||||
|
||||
// 进场套用上次的定时设置(legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
|
||||
const timerBootRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (timerBootRef.current) return
|
||||
timerBootRef.current = true
|
||||
if (defaultTimer > 0) applyTimer(defaultTimer)
|
||||
}, [applyTimer, defaultTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (!timerActive) return
|
||||
const id = window.setInterval(() => {
|
||||
const audio = audioRef.current
|
||||
// 暂停期间不倒计时(legado doDs 只在播放中扣分钟),把截止时间顺延
|
||||
if (audio && audio.paused) {
|
||||
timerDeadlineRef.current += 1000
|
||||
return
|
||||
}
|
||||
const left = Math.max(0, Math.round((timerDeadlineRef.current - Date.now()) / 1000))
|
||||
setTimerLeft(left)
|
||||
if (left <= 0) {
|
||||
timerDeadlineRef.current = 0
|
||||
setTimerActive(false)
|
||||
audio?.pause()
|
||||
toast('定时结束,已暂停播放')
|
||||
}
|
||||
}, 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [timerActive])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
@@ -97,85 +236,502 @@ export function ReaderAudioPanel({
|
||||
else audio.pause()
|
||||
}
|
||||
|
||||
const seekBy = useCallback((delta: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : audio.currentTime + Math.abs(delta)
|
||||
const next = Math.min(Math.max(0, audio.currentTime + delta), Math.max(0, total - 0.2))
|
||||
audio.currentTime = next
|
||||
setCur(next)
|
||||
}, [])
|
||||
|
||||
const seekTo = useCallback((seconds: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
audio.currentTime = seconds
|
||||
setCur(seconds)
|
||||
}, [])
|
||||
|
||||
const changeSpeed = (v: number) => {
|
||||
setSpeed(v)
|
||||
}
|
||||
|
||||
const saveCredits = (open: number, close: number) => {
|
||||
setCreditDraft({ open, close })
|
||||
onCreditsChange(open, close)
|
||||
}
|
||||
|
||||
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
|
||||
|
||||
const chipClass = (active: boolean) =>
|
||||
`rounded-lg border px-2.5 py-1 text-2xs font-bold transition ${active ? '' : 'opacity-70'}`
|
||||
|
||||
const chipStyle = (active: boolean) => ({
|
||||
borderColor: active ? theme.accent : theme.text + '33',
|
||||
color: active ? theme.accent : theme.text,
|
||||
backgroundColor: active ? theme.accent + '1a' : 'transparent',
|
||||
})
|
||||
|
||||
const closeSheet = () => setSheet('none')
|
||||
|
||||
const openSheet = (next: Sheet) => setSheet((cur) => (cur === next ? 'none' : next))
|
||||
|
||||
const chapterList = useMemo(
|
||||
() => chapters.map((c, i) => ({ ...c, i })),
|
||||
[chapters],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 pb-16" onClick={onToggleMenu}>
|
||||
{/* 封面占位(居中装饰) */}
|
||||
<div
|
||||
className="flex h-28 w-28 items-center justify-center rounded-full border-2"
|
||||
style={{ borderColor: theme.accent + '66', color: theme.accent }}
|
||||
>
|
||||
<div className={playing ? 'h-16 w-16 animate-pulse rounded-full' : 'h-16 w-16 rounded-full'} style={{ backgroundColor: theme.accent + '33' }} />
|
||||
</div>
|
||||
<p className="max-w-full truncate px-4 text-sm font-bold" style={{ color: theme.text }}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-2xs opacity-60" style={{ color: theme.text }}>
|
||||
{playing ? '播放中' : loading ? '加载中…' : '已暂停'} · 倍速 {RATES[rateIdx]}x
|
||||
</p>
|
||||
<div
|
||||
className="relative flex h-full flex-col overflow-hidden"
|
||||
onClick={() => {
|
||||
if (sheet !== 'none') closeSheet()
|
||||
else onToggleMenu()
|
||||
}}
|
||||
>
|
||||
{/* 背景:书籍封面强模糊铺满 + 主题底色蒙层(legado AudioPlayActivity.upCover 的 ivBg)。
|
||||
蒙层用主题底色但很淡:既保留日/夜主题色调,又能看清封面。 */}
|
||||
{cover && coverOK && (
|
||||
<>
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
aria-hidden
|
||||
referrerPolicy="no-referrer"
|
||||
className="pointer-events-none absolute inset-0 z-0 h-full w-full scale-110 object-cover"
|
||||
style={{ filter: 'blur(32px)' }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
style={{ backgroundColor: theme.bg, opacity: 0.3 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="flex w-full max-w-md items-center gap-2" style={{ color: theme.text }}>
|
||||
<span className="w-10 text-right text-2xs tabular-nums opacity-70">{fmt(cur)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(1, Math.floor(dur))}
|
||||
value={Math.floor(cur)}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
setCur(v)
|
||||
if (audioRef.current) audioRef.current.currentTime = v
|
||||
}}
|
||||
className="flex-1"
|
||||
style={{ accentColor: theme.accent }}
|
||||
/>
|
||||
<span className="w-10 text-2xs tabular-nums opacity-70">{fmt(dur)}</span>
|
||||
</div>
|
||||
|
||||
{/* transport 行(上一章 / 播放暂停 / 下一章 / 倍速) */}
|
||||
<div className="flex items-center gap-6" style={{ color: theme.text }} onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" onClick={onPrevChapter} className="flex items-center gap-1 text-xs font-bold opacity-80 hover:opacity-100">
|
||||
<ChevronLeft size={16} /> 上一章
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg"
|
||||
style={{ backgroundColor: theme.accent }}
|
||||
aria-label={playing ? '暂停' : '播放'}
|
||||
{/* 播放信息 + transport(抽屉打开时被抽屉压住) */}
|
||||
<div className="relative z-10 flex flex-1 flex-col items-center justify-center gap-5 px-6">
|
||||
{/* 封面(legado ivCover:圆形封面图;无封面/加载失败退回主题色圆点) */}
|
||||
<div
|
||||
className="relative flex h-32 w-32 items-center justify-center rounded-full border-2"
|
||||
style={{ borderColor: theme.accent + '66', color: theme.accent }}
|
||||
>
|
||||
{loading ? <Loader2 size={22} className="animate-spin" /> : playing ? <Pause size={22} /> : <Play size={22} className="ml-0.5" />}
|
||||
</button>
|
||||
<button type="button" onClick={onNextChapter} className="flex items-center gap-1 text-xs font-bold opacity-80 hover:opacity-100">
|
||||
下一章 <ChevronRight size={16} />
|
||||
</button>
|
||||
<div className="h-full w-full overflow-hidden rounded-full">
|
||||
{cover && coverOK ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt={title}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setCoverOK(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full" style={{ backgroundColor: theme.accent + '33' }} />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`pointer-events-none absolute inset-0 rounded-full ${playing ? 'animate-pulse' : ''}`}
|
||||
style={{ boxShadow: `0 0 0 6px ${theme.accent}22` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-full px-4 text-center">
|
||||
<p className="truncate text-sm font-bold" style={{ color: theme.text }}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-1 text-2xs opacity-60" style={{ color: theme.text }}>
|
||||
{audioError ? (
|
||||
<span className="font-bold" style={{ color: theme.accent }}>
|
||||
{audioError}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} ·
|
||||
倍速 {speed}x
|
||||
</>
|
||||
)}
|
||||
{!audioError && timerActive ? ` · 定时 ${fmtCountdown(timerLeft)}` : ''}
|
||||
{!audioError && (openCredits > 0 || closeCredits > 0)
|
||||
? ` · 跳过片头${openCredits}s/片尾${closeCredits}s`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="flex w-full max-w-md items-center gap-2" style={{ color: theme.text }}>
|
||||
<span className="w-10 text-right text-2xs tabular-nums opacity-70">{fmt(cur)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(1, Math.floor(dur))}
|
||||
value={Math.floor(cur)}
|
||||
onChange={(e) => seekTo(Number(e.target.value))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1"
|
||||
style={{ accentColor: theme.accent }}
|
||||
/>
|
||||
<span className="w-10 text-2xs tabular-nums opacity-70">{fmt(dur)}</span>
|
||||
</div>
|
||||
|
||||
{/* transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章 */}
|
||||
<div
|
||||
className="flex items-center gap-5"
|
||||
style={{ color: theme.text }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevChapter}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="上一章"
|
||||
title="上一章"
|
||||
>
|
||||
<ChevronLeft size={22} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => seekBy(-SEEK_STEP)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100"
|
||||
style={{ borderColor: theme.text + '44' }}
|
||||
aria-label={`后退 ${SEEK_STEP} 秒`}
|
||||
title={`后退 ${SEEK_STEP} 秒`}
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
<span className="text-[10px]">{SEEK_STEP}s</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg"
|
||||
style={{ backgroundColor: theme.accent }}
|
||||
aria-label={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={22} className="animate-spin" />
|
||||
) : playing ? (
|
||||
<Pause size={22} />
|
||||
) : (
|
||||
<Play size={22} className="ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => seekBy(SEEK_STEP)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border text-2xs font-bold opacity-80 hover:opacity-100"
|
||||
style={{ borderColor: theme.text + '44' }}
|
||||
aria-label={`前进 ${SEEK_STEP} 秒`}
|
||||
title={`前进 ${SEEK_STEP} 秒`}
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
<span className="text-[10px]">{SEEK_STEP}s</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextChapter}
|
||||
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
|
||||
aria-label="下一章"
|
||||
title="下一章"
|
||||
>
|
||||
<ChevronRight size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRateIdx((i) => (i + 1) % RATES.length)}
|
||||
className="rounded-xl border px-3 py-1 text-2xs font-bold opacity-80 hover:opacity-100"
|
||||
style={{ borderColor: theme.text + '44', color: theme.text }}
|
||||
|
||||
{/* 动作行:常驻底部,抽屉打开时仍可点(可在几个抽屉间直接切换) */}
|
||||
<div
|
||||
className="relative z-50 grid h-[76px] w-full shrink-0 grid-cols-4 px-6"
|
||||
style={{ color: theme.text }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{RATES[rateIdx]}x
|
||||
</button>
|
||||
{(
|
||||
[
|
||||
{
|
||||
icon: <ListMusic size={18} />,
|
||||
label: '章节',
|
||||
active: sheet === 'chapters',
|
||||
action: () => openSheet('chapters'),
|
||||
},
|
||||
{
|
||||
icon: <Timer size={18} />,
|
||||
label: timerActive ? fmtCountdown(timerLeft) : '定时',
|
||||
active: timerActive || sheet === 'timer',
|
||||
action: () => openSheet('timer'),
|
||||
},
|
||||
{
|
||||
icon: <Gauge size={18} />,
|
||||
label: `${speed}x`,
|
||||
active: speed !== 1 || sheet === 'speed',
|
||||
action: () => openSheet('speed'),
|
||||
},
|
||||
{
|
||||
icon: <Scissors size={18} />,
|
||||
label: '片头片尾',
|
||||
active: openCredits > 0 || closeCredits > 0 || sheet === 'credits',
|
||||
action: () => openSheet('credits'),
|
||||
},
|
||||
] as const
|
||||
).map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex flex-col items-center justify-center gap-1 opacity-80 hover:opacity-100"
|
||||
style={item.active ? { color: theme.accent, opacity: 1 } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-2xs tabular-nums">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 底部抽屉(到动作行上沿为止,动作行始终可点) ── */}
|
||||
{sheet !== 'none' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭面板"
|
||||
className={`absolute inset-x-0 top-0 z-30 cursor-default bg-black/30 ${ACTION_BAR_BOTTOM}`}
|
||||
onClick={closeSheet}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-x-0 z-40 max-h-[65%] overflow-hidden rounded-t-2xl border-t ${ACTION_BAR_BOTTOM}`}
|
||||
style={sheetStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sheet === 'chapters' && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3 text-xs font-bold"
|
||||
style={{ borderColor: theme.text + '22' }}
|
||||
>
|
||||
<span>章节({chapters.length})</span>
|
||||
<button type="button" onClick={closeSheet} className="opacity-70 hover:opacity-100">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[45vh] min-h-0">
|
||||
<Virtuoso
|
||||
data={chapterList}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
itemContent={(_i, row) => {
|
||||
const isCurrent = row.i === chapterIndex
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (row.is_volume) return
|
||||
closeSheet()
|
||||
onSelectChapter(row.i)
|
||||
}}
|
||||
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||
row.is_volume ? 'font-bold opacity-70' : ''
|
||||
}`}
|
||||
style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sheet === 'timer' && (
|
||||
<div className="px-4 pb-6 pt-4">
|
||||
<p className="mb-3 text-xs font-bold">定时关闭</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AUDIO_TIMERS.map((m) => {
|
||||
const active = timerActive ? timerPreset === m : m === 0
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => applyTimer(m)}
|
||||
className={chipClass(active)}
|
||||
style={chipStyle(active)}
|
||||
>
|
||||
{m === 0 ? '关闭定时' : `${m} 分钟`}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-3 text-2xs opacity-60">
|
||||
{timerActive ? `剩余 ${fmtCountdown(timerLeft)},播完自动暂停` : '未开启:不会自动停止播放'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sheet === 'speed' && (
|
||||
<div className="px-4 pb-6 pt-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs font-bold">
|
||||
<span>播放倍速</span>
|
||||
<span className="tabular-nums" style={{ color: theme.accent }}>
|
||||
{speed}x
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => changeSpeed(Number(e.target.value))}
|
||||
className="w-full"
|
||||
style={{ accentColor: theme.accent }}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{AUDIO_SPEEDS.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => changeSpeed(v)}
|
||||
className={chipClass(v === speed)}
|
||||
style={chipStyle(v === speed)}
|
||||
>
|
||||
{v}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sheet === 'credits' && (
|
||||
<div className="px-4 pb-6 pt-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs font-bold">
|
||||
<span>跳过片头片尾</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveCredits(0, 0)}
|
||||
className="text-2xs opacity-70 hover:opacity-100"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-2xs">
|
||||
<span className="opacity-70">片头</span>
|
||||
<span className="tabular-nums" style={{ color: theme.accent }}>
|
||||
{creditDraft.open} 秒
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={AUDIO_CREDITS_MAX}
|
||||
step={CREDIT_STEP}
|
||||
value={creditDraft.open}
|
||||
onChange={(e) => setCreditDraft((d) => ({ ...d, open: Number(e.target.value) }))}
|
||||
onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||
onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||
className="w-full"
|
||||
style={{ accentColor: theme.accent }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-2xs">
|
||||
<span className="opacity-70">片尾</span>
|
||||
<span className="tabular-nums" style={{ color: theme.accent }}>
|
||||
{creditDraft.close} 秒
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={AUDIO_CREDITS_MAX}
|
||||
step={CREDIT_STEP}
|
||||
value={creditDraft.close}
|
||||
onChange={(e) => setCreditDraft((d) => ({ ...d, close: Number(e.target.value) }))}
|
||||
onPointerUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||
onKeyUp={() => saveCredits(creditDraft.open, creditDraft.close)}
|
||||
className="w-full"
|
||||
style={{ accentColor: theme.accent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{[0, 15, 30, 45, 60, 90].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => saveCredits(v, creditDraft.close)}
|
||||
className={chipClass(v === creditDraft.open)}
|
||||
style={chipStyle(v === creditDraft.open)}
|
||||
>
|
||||
片头 {v}s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-2xs opacity-60">
|
||||
每章从头播放时跳过前 {creditDraft.open} 秒;剩最后 {creditDraft.close} 秒时视为本章播完。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="hidden"
|
||||
preload="metadata"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onLoadedMetadata={(e) => {
|
||||
setDur(e.currentTarget.duration)
|
||||
onError={(e) => {
|
||||
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
|
||||
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
|
||||
const code = e.currentTarget.error?.code
|
||||
setLoading(false)
|
||||
if (!restoredRef.current && initialPos > 0 && initialPos < e.currentTarget.duration) {
|
||||
e.currentTarget.currentTime = initialPos
|
||||
setAudioError(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
: '音频无法播放',
|
||||
)
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const audio = e.currentTarget
|
||||
setDur(audio.duration)
|
||||
setLoading(false)
|
||||
audio.playbackRate = speed
|
||||
if (!restoredRef.current) {
|
||||
const total = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
if (initialPos > 0 && (total === 0 || initialPos < total)) {
|
||||
// 有进度:按存档续播(legado: position != 0 时不套用片头)
|
||||
audio.currentTime = initialPos
|
||||
setCur(initialPos)
|
||||
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
|
||||
// 全新开播:跳到片头结束位置(legado: skipStartMs)
|
||||
audio.currentTime = openCredits
|
||||
setCur(openCredits)
|
||||
}
|
||||
}
|
||||
restoredRef.current = true
|
||||
void e.currentTarget.play().catch(() => undefined)
|
||||
void audio.play().catch(() => undefined)
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
setCur(e.currentTarget.currentTime)
|
||||
onProgress(e.currentTarget.currentTime)
|
||||
const audio = e.currentTarget
|
||||
const t = audio.currentTime
|
||||
setCur(t)
|
||||
onProgress(t)
|
||||
// 片尾跳过(legado upPlayProgress:durP >= duration - skipEnds 即当播完)
|
||||
if (
|
||||
closeCredits > 0 &&
|
||||
!skippedEndRef.current &&
|
||||
Number.isFinite(audio.duration) &&
|
||||
audio.duration > closeCredits + 1 &&
|
||||
t >= audio.duration - closeCredits
|
||||
) {
|
||||
skippedEndRef.current = true
|
||||
if (!hasNext) {
|
||||
audio.pause()
|
||||
audio.currentTime = Math.max(0, audio.duration - 0.5)
|
||||
return
|
||||
}
|
||||
onEnded()
|
||||
}
|
||||
}}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { BookOpen, Loader2, RefreshCw, Search, Settings2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loader2, MoreHorizontal, RefreshCw, Settings2, Trash2 } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderBook } from '../../api/reader'
|
||||
import { confirmAction } from '../../components/confirmAction'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { ReaderModeSwitch } from './ReaderModeSwitch'
|
||||
import { ServerFilePickerDialog } from './ServerFilePickerDialog'
|
||||
|
||||
// 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。
|
||||
// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上搜索/书源管理入口。
|
||||
// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上上传本地书籍/搜索/书源管理入口。
|
||||
|
||||
// 未读章数:dur_chapter_time 为 0 表示还没开始读,否则读完到当前章为止。
|
||||
// total_chapter_num 为 0 表示目录尚未缓存,无法计算。
|
||||
function unreadChapters(book: ReaderBook): number | null {
|
||||
if (book.total_chapter_num <= 0) return null
|
||||
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
|
||||
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
|
||||
}
|
||||
|
||||
export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||
const [books, setBooks] = useState<ReaderBook[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [uploading, setUploading] = useState<number | null>(null) // 上传进度百分比
|
||||
const [picker, setPicker] = useState<'book' | 'audio' | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 下拉菜单:点外部或按 Esc 关闭
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
const load = () => {
|
||||
setError('')
|
||||
@@ -21,28 +56,153 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
.catch((e) => setError(e?.response?.data?.error ?? '加载书架失败'))
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(load, [])
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setUploading(0)
|
||||
try {
|
||||
const book = await readerAPI.uploadLocalBook(file, setUploading)
|
||||
toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`)
|
||||
load()
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
|
||||
} finally {
|
||||
setUploading(null)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 移出书架:所有书籍都可移除。本地导入的书会连同服务器文件一起删除,
|
||||
// 原地引用的只解除引用,书源书籍只清掉书架记录与阅读进度。
|
||||
const removeFromShelf = async (book: ReaderBook) => {
|
||||
const message = book.local_external
|
||||
? `《${book.name}》是原地引用服务器上的文件,移出书架只解除引用,不会删除源文件。确定吗?`
|
||||
: book.is_local
|
||||
? `《${book.name}》及其在服务器上的文件都会被删除,确定吗?`
|
||||
: `《${book.name}》会连同阅读进度一起从书架移除,不影响书源。确定吗?`
|
||||
const ok = await confirmAction({
|
||||
title: '移出书架',
|
||||
message,
|
||||
confirmText: '移出',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await readerAPI.removeBook(book.id)
|
||||
setBooks((prev) => (prev ? prev.filter((b) => b.id !== book.id) : prev))
|
||||
toast.success('已移出书架')
|
||||
} catch {
|
||||
toast.error('移出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const runPicker = async (path: string) => {
|
||||
const kind = picker
|
||||
setPicker(null)
|
||||
try {
|
||||
const book =
|
||||
kind === 'audio'
|
||||
? await readerAPI.importLocalAudioDir(path)
|
||||
: await readerAPI.importLocalBookFromPath(path)
|
||||
toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`)
|
||||
load()
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!embedded && <ReaderModeSwitch />}
|
||||
|
||||
{/* 标题右侧一个下拉入口,收纳全部书架操作 */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="font-display text-2xl text-ink-600">书架</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/reader/sources"
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] hover:text-[var(--app-text)]"
|
||||
<h1 className="shrink-0 font-display text-2xl text-ink-600">书架</h1>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".txt,.epub,text/plain,application/epub+zip"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) void upload(f)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] hover:text-[var(--app-text)]"
|
||||
title="书架操作"
|
||||
>
|
||||
<Settings2 size={13} /> 书源管理
|
||||
</Link>
|
||||
<Link
|
||||
to="/reader/search"
|
||||
className="flex items-center gap-1.5 rounded-xl border border-brand-500/60 bg-brand-500/10 px-3 py-1.5 text-xs font-bold text-brand-600 hover:bg-brand-500/20"
|
||||
>
|
||||
<Search size={13} /> 搜索
|
||||
</Link>
|
||||
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
|
||||
{uploading !== null ? `上传中 ${uploading}%` : '管理'}
|
||||
<ChevronDown size={13} className={`transition-transform ${menuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-1.5 w-40 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="上传 TXT / EPUB 到服务器阅读"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
fileRef.current?.click()
|
||||
}}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
<FileUp size={13} /> 本地导入
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="选择服务器上已有的 TXT / EPUB 文件导入(原地引用,不复制)"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setPicker('book')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
<FolderOpen size={13} /> 服务器导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="选择服务器上的一个目录导入为有声书(含 .strm 播放指针)"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setPicker('audio')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
|
||||
>
|
||||
<Headphones size={13} /> 有声书导入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="my-1 border-t border-[var(--app-border)]" />
|
||||
<Link
|
||||
to="/reader/sources"
|
||||
role="menuitem"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
<Settings2 size={13} /> 书源管理
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,48 +227,113 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
<BookOpen size={20} className="text-brand-500" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-bold text-[var(--app-text)]">书架空空如也</p>
|
||||
<p className="mt-1 text-xs text-[var(--app-muted)]">先到「书源管理」导入书源,再搜索加入书架</p>
|
||||
<p className="mt-1 text-xs text-[var(--app-muted)]">上传本地 TXT / EPUB,或导入书源后搜索加入书架</p>
|
||||
<div className="mt-5 flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading !== null}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="btn-outline text-xs disabled:opacity-60"
|
||||
>
|
||||
{uploading !== null ? `上传中 ${uploading}%` : '上传本地书籍'}
|
||||
</button>
|
||||
<Link to="/reader/sources" className="btn-outline text-xs">导入书源</Link>
|
||||
<Link to="/reader/search" className="btn-primary text-xs">去搜索</Link>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center gap-3">
|
||||
<button type="button" onClick={() => setPicker('book')} className="btn-outline whitespace-nowrap text-xs">
|
||||
从服务器导入
|
||||
</button>
|
||||
<button type="button" onClick={() => setPicker('audio')} className="btn-outline whitespace-nowrap text-xs">
|
||||
从服务器导入有声书
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{books !== null && books.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-6 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
{books.map((book) => (
|
||||
<button
|
||||
key={book.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/reader/view/${book.id}`)}
|
||||
className="group text-left"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md">
|
||||
<div className="aspect-[3/4] w-full">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt={book.name}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={22} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
{books.map((book) => {
|
||||
const unread = unreadChapters(book)
|
||||
return (
|
||||
<div key={book.id} className="group">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/reader/view/${book.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
}
|
||||
}}
|
||||
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
{book.cover_url ? (
|
||||
<img
|
||||
src={book.cover_url}
|
||||
alt={book.name}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={22} className="text-[var(--app-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */}
|
||||
{book.is_local && (
|
||||
<span className="absolute bottom-1 left-1 flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
|
||||
<HardDrive size={10} /> 本地
|
||||
</span>
|
||||
)}
|
||||
{unread !== null && (
|
||||
<span
|
||||
title={unread > 0 ? `还有 ${unread} 章未读` : '已读完'}
|
||||
className={`absolute right-1 top-1 whitespace-nowrap rounded-lg px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur ${
|
||||
unread > 0 ? 'bg-rose-500/90' : 'bg-black/55'
|
||||
}`}
|
||||
>
|
||||
{unread > 0 ? `${unread} 章未读` : '已读完'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移出书架:${book.name}`}
|
||||
title="移出书架"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void removeFromShelf(book)
|
||||
}}
|
||||
className="absolute bottom-1 right-1 rounded-lg bg-black/55 p-1 text-white opacity-80 backdrop-blur transition hover:bg-red-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-xs font-bold text-[var(--app-text)]">{book.name}</p>
|
||||
<p className="truncate text-2xs text-[var(--app-muted)]">
|
||||
{book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-xs font-bold text-[var(--app-text)]">{book.name}</p>
|
||||
<p className="truncate text-2xs text-[var(--app-muted)]">
|
||||
{book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<ServerFilePickerDialog
|
||||
mode={picker === 'audio' ? 'dir' : 'file'}
|
||||
extensions={picker === 'audio' ? undefined : ['.txt', '.epub']}
|
||||
title={picker === 'audio' ? '选择有声书目录(音频文件与 .strm)' : '选择服务器上的书籍文件(TXT / EPUB)'}
|
||||
onSelect={(path) => void runPicker(path)}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { AlertTriangle, ArrowLeft, BookOpen, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
|
||||
|
||||
@@ -9,7 +9,8 @@ import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../.
|
||||
|
||||
export default function ReaderSearchPage() {
|
||||
const navigate = useNavigate()
|
||||
const [key, setKey] = useState('')
|
||||
const [params] = useSearchParams()
|
||||
const [key, setKey] = useState(() => params.get('key') ?? '')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [books, setBooks] = useState<ReaderSearchBook[] | null>(null)
|
||||
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
|
||||
@@ -24,9 +25,11 @@ export default function ReaderSearchPage() {
|
||||
setShowSkipped(false)
|
||||
try {
|
||||
const res = await readerAPI.search(kw)
|
||||
setBooks(res.books)
|
||||
setSkipped(res.skipped)
|
||||
if (res.books.length === 0) toast.error('所有书源都没有找到结果')
|
||||
setBooks(res.books ?? [])
|
||||
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
|
||||
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
|
||||
setSkipped(res.skipped ?? [])
|
||||
if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果')
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败'
|
||||
toast.error(msg)
|
||||
@@ -35,8 +38,16 @@ export default function ReaderSearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const addToShelf = async (book: ReaderSearchBook) => {
|
||||
const origin = book.origins[0]
|
||||
// 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜)
|
||||
const autoSearchedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (autoSearchedRef.current) return
|
||||
autoSearchedRef.current = true
|
||||
if (key.trim()) void doSearch()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
|
||||
if (!origin) return
|
||||
setAdding(book.book_url)
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
@@ -25,9 +25,28 @@ import { ReaderComic } from './ReaderComic'
|
||||
|
||||
const COLUMN_GAP = 48
|
||||
|
||||
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
|
||||
const IMG_MARK = '[img]'
|
||||
|
||||
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
|
||||
const MENU_SHIFT = 'transition-transform duration-200'
|
||||
|
||||
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
|
||||
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
|
||||
const WHEEL_NOTCH = 40
|
||||
/** 触控板小步长累计到这个量翻一页。 */
|
||||
const WHEEL_SWIPE_THRESHOLD = 60
|
||||
/** 两次翻页的最小间隔,与 220ms 翻页动画对齐。 */
|
||||
const WHEEL_TURN_COOLDOWN = 220
|
||||
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
|
||||
const WHEEL_GESTURE_GAP = 180
|
||||
|
||||
function firstReadableIndex(chapters: ReaderChapter[]): number {
|
||||
const i = chapters.findIndex((c) => !c.is_volume && c.url)
|
||||
return i === -1 ? 0 : i
|
||||
if (i !== -1) return i
|
||||
// 本地导入的章节没有 url,退回第一个非卷名章
|
||||
const j = chapters.findIndex((c) => !c.is_volume)
|
||||
return j === -1 ? 0 : j
|
||||
}
|
||||
|
||||
export default function ReaderViewPage() {
|
||||
@@ -45,6 +64,15 @@ export default function ReaderViewPage() {
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
|
||||
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
|
||||
const topBarRef = useRef<HTMLDivElement>(null)
|
||||
const [menuInset, setMenuInset] = useState(0)
|
||||
// 滚轮翻页的累计量 / 冷却 / 一次手势只翻一页的锁
|
||||
const readerRef = useRef<HTMLDivElement>(null)
|
||||
const wheelAccumRef = useRef(0)
|
||||
const wheelLastEventRef = useRef(0)
|
||||
const wheelLastTurnRef = useRef(0)
|
||||
const wheelSwipeLockedRef = useRef(false)
|
||||
|
||||
// 分页状态
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
@@ -78,6 +106,7 @@ export default function ReaderViewPage() {
|
||||
setBook(b)
|
||||
let chs = await readerAPI.listChapters(b.id)
|
||||
if (chs.length === 0) {
|
||||
if (b.is_local) throw new Error('本地书籍目录为空,请重新导入该书')
|
||||
const toc = await readerAPI.toc({
|
||||
source_url: b.origin,
|
||||
book_url: b.book_url,
|
||||
@@ -90,7 +119,9 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (cancelled) return
|
||||
setChapters(chs)
|
||||
const qChapter = Number(new URLSearchParams(window.location.search).get('chapter') ?? '')
|
||||
// URL 上带 chapter 才用它;没有这个参数就不能当成 0,否则每次进来都回第一章
|
||||
const qChapterRaw = new URLSearchParams(window.location.search).get('chapter')
|
||||
const qChapter = qChapterRaw && qChapterRaw.trim() !== '' ? Number(qChapterRaw) : NaN
|
||||
let idx = firstReadableIndex(chs)
|
||||
if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter
|
||||
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
|
||||
@@ -276,6 +307,33 @@ export default function ReaderViewPage() {
|
||||
[chapterIndex, chapters],
|
||||
)
|
||||
|
||||
// 听书:进度条/章节列表跳章(不沿用 goChapter 的越界提示,直接落位)
|
||||
const jumpToChapter = useCallback((idx: number) => {
|
||||
if (idx < 0 || idx >= chapters.length) return
|
||||
pendingEndRef.current = false
|
||||
setError('')
|
||||
setChapterIndex(idx)
|
||||
}, [chapters.length])
|
||||
|
||||
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
|
||||
const hasNextAudioChapter = useMemo(() => {
|
||||
if (chapterIndex === null) return false
|
||||
for (let i = chapterIndex + 1; i < chapters.length; i++) {
|
||||
if (!chapters[i].is_volume) return true
|
||||
}
|
||||
return false
|
||||
}, [chapterIndex, chapters])
|
||||
|
||||
// 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits)
|
||||
const saveAudioCredits = useCallback(
|
||||
(open: number, close: number) => {
|
||||
if (!book) return
|
||||
setBook((prev) => (prev ? { ...prev, open_credits: open, close_credits: close } : prev))
|
||||
readerAPI.saveAudioConfig(book.id, { open_credits: open, close_credits: close }).catch(() => undefined)
|
||||
},
|
||||
[book],
|
||||
)
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(-1)
|
||||
@@ -334,8 +392,70 @@ export default function ReaderViewPage() {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [goPrev, goNext, menuOpen, panel])
|
||||
|
||||
// ── 鼠标滚轮翻页(仅翻页模式) ──
|
||||
// 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。
|
||||
// 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。
|
||||
useEffect(() => {
|
||||
const el = readerRef.current
|
||||
if (!el) return
|
||||
// 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页
|
||||
if (settings.pageMode !== 'page' || menuOpen) return
|
||||
if (contentType !== 'text' && contentType !== 'image') return
|
||||
|
||||
const turn = (forward: boolean) => (forward ? goNext() : goPrev())
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// ctrl+滚轮是浏览器缩放,别抢
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
e.preventDefault()
|
||||
const now = Date.now()
|
||||
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1
|
||||
const delta = e.deltaY * unit
|
||||
if (now - wheelLastEventRef.current > WHEEL_GESTURE_GAP) {
|
||||
wheelAccumRef.current = 0
|
||||
wheelSwipeLockedRef.current = false
|
||||
}
|
||||
wheelLastEventRef.current = now
|
||||
|
||||
if (Math.abs(delta) >= WHEEL_NOTCH) {
|
||||
// 鼠标滚轮:一格一页,但至少隔一次翻页动画的时间
|
||||
if (now - wheelLastTurnRef.current < WHEEL_TURN_COOLDOWN) return
|
||||
wheelLastTurnRef.current = now
|
||||
wheelAccumRef.current = 0
|
||||
turn(delta > 0)
|
||||
return
|
||||
}
|
||||
// 触控板:小步长累计到阈值再翻,一次手势只翻一页,避免惯性连翻
|
||||
if (wheelSwipeLockedRef.current) return
|
||||
wheelAccumRef.current += delta
|
||||
if (Math.abs(wheelAccumRef.current) < WHEEL_SWIPE_THRESHOLD) return
|
||||
const forward = wheelAccumRef.current > 0
|
||||
wheelAccumRef.current = 0
|
||||
wheelSwipeLockedRef.current = true
|
||||
wheelLastTurnRef.current = now
|
||||
turn(forward)
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
|
||||
|
||||
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
|
||||
|
||||
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
|
||||
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
|
||||
const menuShiftStyle = useMemo(
|
||||
() => (menuOpen && menuInset > 0 ? { transform: `translateY(${menuInset}px)` } : undefined),
|
||||
[menuOpen, menuInset],
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (!menuOpen) {
|
||||
setMenuInset(0)
|
||||
return
|
||||
}
|
||||
const h = topBarRef.current?.offsetHeight ?? 0
|
||||
setMenuInset((cur) => (cur === h ? cur : h))
|
||||
}, [menuOpen])
|
||||
|
||||
// 菜单进度条按内容类型适配:文本=页/滚动位置,音频=章节,漫画=图片序号
|
||||
const imageCount = media?.images?.length ?? 0
|
||||
const sliderCfg = (() => {
|
||||
@@ -344,7 +464,7 @@ export default function ReaderViewPage() {
|
||||
min: 0,
|
||||
max: Math.max(0, chapters.length - 1),
|
||||
value: Math.max(0, chapterIndex ?? 0),
|
||||
onChange: (v: number) => setChapterIndex(v),
|
||||
onChange: (v: number) => jumpToChapter(v),
|
||||
}
|
||||
}
|
||||
if (contentType === 'image') {
|
||||
@@ -376,6 +496,27 @@ export default function ReaderViewPage() {
|
||||
})()
|
||||
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
|
||||
|
||||
// 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片
|
||||
const renderParagraph = (line: string, key: number) => {
|
||||
if (line.startsWith(IMG_MARK)) {
|
||||
return (
|
||||
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
|
||||
<img
|
||||
src={line.slice(IMG_MARK.length)}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p key={key} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
|
||||
{line}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 渲染 ──
|
||||
if (error && !book) {
|
||||
return (
|
||||
@@ -389,7 +530,7 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
|
||||
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
|
||||
{/* 正文视口 */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="mx-auto h-full w-full max-w-[900px]">
|
||||
@@ -398,12 +539,21 @@ export default function ReaderViewPage() {
|
||||
<ReaderAudioPanel
|
||||
src={media.tracks[0]}
|
||||
title={currentChapter?.title ?? book?.name ?? '播放'}
|
||||
cover={book?.cover_url ?? ''}
|
||||
theme={theme}
|
||||
initialPos={restorePos}
|
||||
openCredits={book?.open_credits ?? 0}
|
||||
closeCredits={book?.close_credits ?? 0}
|
||||
chapters={chapters}
|
||||
chapterIndex={chapterIndex}
|
||||
hasNext={hasNextAudioChapter}
|
||||
transcoding={media.transcoding ?? false}
|
||||
onProgress={throttledMediaSave}
|
||||
onPrevChapter={() => goChapter(-1)}
|
||||
onNextChapter={() => goChapter(1)}
|
||||
onSelectChapter={jumpToChapter}
|
||||
onEnded={() => goChapter(1)}
|
||||
onCreditsChange={saveAudioCredits}
|
||||
onToggleMenu={() => setMenuOpen((v) => !v)}
|
||||
/>
|
||||
) : (
|
||||
@@ -429,25 +579,26 @@ export default function ReaderViewPage() {
|
||||
onScrolled={() => setScrollToImage(null)}
|
||||
/>
|
||||
) : settings.pageMode === 'page' ? (
|
||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full"
|
||||
style={{
|
||||
columnWidth: `${Math.max(vw, 1)}px`,
|
||||
columnGap: `${COLUMN_GAP}px`,
|
||||
columnFill: 'auto',
|
||||
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
||||
transition: 'transform 220ms ease',
|
||||
fontSize: settings.fontSize,
|
||||
lineHeight: settings.lineHeight,
|
||||
}}
|
||||
>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
|
||||
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
|
||||
不改高度也就不触发重新分页) */
|
||||
<div className={`h-full px-4 py-2 ${MENU_SHIFT}`} style={menuShiftStyle}>
|
||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full"
|
||||
style={{
|
||||
columnWidth: `${Math.max(vw, 1)}px`,
|
||||
columnGap: `${COLUMN_GAP}px`,
|
||||
columnFill: 'auto',
|
||||
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
||||
transition: 'transform 220ms ease',
|
||||
fontSize: settings.fontSize,
|
||||
lineHeight: settings.lineHeight,
|
||||
}}
|
||||
>
|
||||
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -465,15 +616,11 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-full overflow-y-auto px-1"
|
||||
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight }}
|
||||
className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`}
|
||||
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
|
||||
>
|
||||
<div className="py-4">
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
{paragraphs.map((p, i) => renderParagraph(p, i))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -541,6 +688,7 @@ export default function ReaderViewPage() {
|
||||
/>
|
||||
{/* 顶栏 */}
|
||||
<div
|
||||
ref={topBarRef}
|
||||
className="fixed inset-x-0 top-0 z-50 flex items-center gap-3 border-b px-4 py-3 backdrop-blur"
|
||||
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22' }}
|
||||
>
|
||||
@@ -619,46 +767,6 @@ export default function ReaderViewPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 目录抽屉 */}
|
||||
{panel === 'toc' && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 top-0 w-72 overflow-hidden border-l sm:w-80"
|
||||
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22' }}
|
||||
>
|
||||
<div className="flex h-full flex-col" style={{ color: theme.text }}>
|
||||
<p className="border-b px-4 py-3 text-xs font-bold" style={{ borderColor: theme.text + '22' }}>
|
||||
目录({chapters.length} 章)
|
||||
</p>
|
||||
<div className="min-h-0 flex-1">
|
||||
<Virtuoso
|
||||
data={chapters}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
itemContent={(_, ch) => {
|
||||
const idx = chapters.indexOf(ch)
|
||||
const isCurrent = idx === chapterIndex
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChapterIndex(idx)
|
||||
setPanel('none')
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||
ch.is_volume ? 'font-bold opacity-70' : ''
|
||||
}`}
|
||||
style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined}
|
||||
>
|
||||
{ch.title}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
|
||||
{panel === 'style' && (
|
||||
<div
|
||||
@@ -681,8 +789,8 @@ export default function ReaderViewPage() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-6 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-6 gap-y-3 text-xs">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="opacity-70">字号</span>
|
||||
<button type="button" onClick={() => settings.setFontSize(settings.fontSize - 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||
<Minus size={12} />
|
||||
@@ -692,7 +800,7 @@ export default function ReaderViewPage() {
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="opacity-70">行距</span>
|
||||
<button type="button" onClick={() => settings.setLineHeight(settings.lineHeight - 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||
<Minus size={12} />
|
||||
@@ -702,7 +810,7 @@ export default function ReaderViewPage() {
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="opacity-70">段距</span>
|
||||
<button type="button" onClick={() => settings.setParagraphSpacing(settings.paragraphSpacing - 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
|
||||
<Minus size={12} />
|
||||
@@ -716,6 +824,58 @@ export default function ReaderViewPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 目录:整屏面板。必须放在底部菜单之外——菜单带 backdrop-blur,
|
||||
会成为 fixed 后代的包含块,放里面高度会被算成菜单的高度。 */}
|
||||
{panel === 'toc' && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex flex-col"
|
||||
style={{ backgroundColor: theme.bg, color: theme.text }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3 border-b px-4 py-3"
|
||||
style={{ borderColor: theme.text + '22' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanel('none')}
|
||||
className="rounded-xl p-1.5 opacity-70 hover:opacity-100"
|
||||
aria-label="收起目录"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<p className="flex-1 truncate text-sm font-bold">
|
||||
{book?.name ?? '目录'}
|
||||
<span className="ml-2 text-2xs font-normal opacity-60">目录({chapters.length} 章)</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<Virtuoso
|
||||
data={chapters}
|
||||
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
|
||||
itemContent={(index, ch) => {
|
||||
const isCurrent = index === chapterIndex
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPanel('none')
|
||||
setMenuOpen(false)
|
||||
jumpToChapter(index)
|
||||
}}
|
||||
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
|
||||
ch.is_volume ? 'font-bold opacity-70' : ''
|
||||
}`}
|
||||
style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined}
|
||||
>
|
||||
{ch.title}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronRight, FileAudio, FileText, Folder, FolderPlus, HardDrive, Loader2, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { filesAPI, type FileEntry } from '../../api/files'
|
||||
|
||||
function apiErrorMessage(err: unknown): string {
|
||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const res = (err as { response?: { data?: { error?: string; message?: string } } }).response
|
||||
if (res?.data?.error) return res.data.error
|
||||
if (res?.data?.message) return res.data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return '请求失败'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)}${units[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* ServerFilePickerDialog 选择服务器上已有的文件或目录。
|
||||
*
|
||||
* 走 /api/files(仅管理员,且只能浏览已配置的存储根目录),供阅读模块
|
||||
* 「从服务器导入书籍 / 有声书」使用。
|
||||
*/
|
||||
export function ServerFilePickerDialog({
|
||||
mode,
|
||||
extensions,
|
||||
title,
|
||||
initialDir,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'file' | 'dir'
|
||||
/** mode=file 时限制可选的后缀,小写带点(如 ['.txt', '.epub']);留空表示不限制 */
|
||||
extensions?: string[]
|
||||
title: string
|
||||
initialDir?: string
|
||||
onSelect: (path: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [listing, setListing] = useState<{
|
||||
path: string
|
||||
parent?: string
|
||||
roots?: { label: string; path: string }[]
|
||||
entries: FileEntry[] | null
|
||||
} | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await filesAPI.list(target)
|
||||
setListing({ path: data.path, parent: data.parent, roots: data.roots, entries: data.entries })
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load(initialDir ?? '')
|
||||
}, [initialDir, load])
|
||||
|
||||
const atRoot = !listing?.path
|
||||
// 后端返回的 ext 不带点("epub"),这里统一去掉点再比较
|
||||
const normalizeExt = (raw?: string) => (raw ?? '').toLowerCase().replace(/^\./, '')
|
||||
const wanted = (extensions ?? []).map(normalizeExt)
|
||||
const accept = (entry: FileEntry) => {
|
||||
if (entry.is_dir) return true
|
||||
if (mode === 'dir') return false
|
||||
if (wanted.length === 0) return true
|
||||
return wanted.includes(normalizeExt(entry.ext))
|
||||
}
|
||||
|
||||
const roots = listing?.roots ?? []
|
||||
const entries = (listing?.entries ?? []).filter(accept)
|
||||
// 根目录列表和目录内条目是两个来源,空态判断要分别看
|
||||
const itemCount = atRoot ? roots.length : entries.length
|
||||
const canPickCurrent = mode === 'dir' && !atRoot
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">{title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500">
|
||||
{atRoot ? (
|
||||
<span className="text-ink-50">选择存储位置</span>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="hover:text-brand-500" onClick={() => load('')}>
|
||||
存储位置
|
||||
</button>
|
||||
<ChevronRight size={12} />
|
||||
<span className="truncate text-ink-50" title={listing?.path}>
|
||||
{listing?.path}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[280px] flex-1 overflow-y-auto p-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : itemCount === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-sand-500">
|
||||
{atRoot
|
||||
? '没有可用的存储位置'
|
||||
: mode === 'dir'
|
||||
? '该目录下没有子目录,可直接选择当前目录'
|
||||
: '该目录下没有可选文件'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{atRoot ? (
|
||||
roots.map((root) => (
|
||||
<button
|
||||
key={root.path}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
|
||||
onClick={() => load(root.path)}
|
||||
>
|
||||
<HardDrive size={16} className="text-brand-400" />
|
||||
<span className="flex-1 truncate text-ink-600">{root.label}</span>
|
||||
<span className="truncate text-xs text-sand-400">{root.path}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{(listing?.parent ?? '') !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm text-ink-50 transition hover:bg-gray-50"
|
||||
onClick={() => load(listing?.parent ?? '')}
|
||||
>
|
||||
<FolderPlus size={16} className="text-sand-400" />
|
||||
<span>..(上级目录)</span>
|
||||
</button>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
|
||||
onClick={() => (entry.is_dir ? load(entry.path) : onSelect(entry.path))}
|
||||
>
|
||||
{entry.is_dir ? (
|
||||
<Folder size={16} className="text-brand-400" />
|
||||
) : normalizeExt(entry.ext) === 'strm' ? (
|
||||
<FileAudio size={16} className="text-sand-400" />
|
||||
) : (
|
||||
<FileText size={16} className="text-sand-400" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-ink-600">{entry.name}</span>
|
||||
{!entry.is_dir && <span className="text-xs text-sand-400">{formatSize(entry.size)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3">
|
||||
<span className="text-xs text-sand-500">
|
||||
{atRoot
|
||||
? '先选择一个存储位置'
|
||||
: mode === 'dir'
|
||||
? '单击目录进入下一级,点击「选择当前目录」完成选择'
|
||||
: '单击目录进入下一级,单击文件即完成选择'}
|
||||
</span>
|
||||
{canPickCurrent && (
|
||||
<button type="button" className="neon-button" disabled={loading} onClick={() => onSelect(listing?.path ?? '')}>
|
||||
选择当前目录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ExternalLink, KeyRound, Loader2, LogOut, RefreshCw, Save, X } from 'lucide-react'
|
||||
|
||||
import { readerAPI, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader'
|
||||
import { readerAPI, type ReaderBrowserPage, type ReaderLoginField, type ReaderSourceLogin } from '../../api/reader'
|
||||
import BrowserPanel from './BrowserPanel'
|
||||
|
||||
// 书源登录面板(仿 legado SourceLoginDialog)。
|
||||
//
|
||||
// 后端按 legado 的登录模型执行:loginUi 是表单描述,loginUrl 是登录逻辑,
|
||||
// 点按钮时不区分类型,统统把 action 拼在 loginUrl 之后执行。
|
||||
// 服务端没有弹窗,java.toast/longToast 的提示与 java.startBrowser 的地址
|
||||
// 都通过返回值回传,这里负责展示并代为打开浏览器。
|
||||
// 服务端没有弹窗,java.toast/longToast 的提示通过返回值回传展示;
|
||||
// java.startBrowser / startBrowserAwait 则把页面登记成「待办」,这里轮询到后
|
||||
// 用内嵌 iframe 承载(见 BrowserPanel),用户的「√」会把 DOM 回传给书源。
|
||||
|
||||
interface Props {
|
||||
sourceId: string
|
||||
@@ -38,6 +40,10 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
||||
const [variable, setVariable] = useState('')
|
||||
const [showVariable, setShowVariable] = useState(false)
|
||||
const [showLoginJS, setShowLoginJS] = useState(false)
|
||||
// 书源交给宿主浏览器承载的页面(java.startBrowser / startBrowserAwait)
|
||||
const [browserPage, setBrowserPage] = useState<ReaderBrowserPage | null>(null)
|
||||
// 已处理过的页面 ID:避免轮询把刚关掉的页面又弹出来
|
||||
const handledPages = useRef<Set<string>>(new Set())
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -58,33 +64,46 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// 轮询书源登记的待办页面。
|
||||
// java.startBrowserAwait 会阻塞在服务端,前端必须在动作执行期间去取页面,
|
||||
// 否则用户永远看不到「切换线路」「用户后台」这些按钮真正要展示的东西。
|
||||
const pollBrowserPages = useCallback(async () => {
|
||||
try {
|
||||
const pages = await readerAPI.browserPending(sourceId)
|
||||
const next = pages.find((p) => !handledPages.current.has(p.id))
|
||||
if (next) setBrowserPage(next)
|
||||
} catch {
|
||||
// 轮询失败不打断正在执行的动作
|
||||
}
|
||||
}, [sourceId])
|
||||
|
||||
// 按钮分为「登录动作」与「其他工具按钮」两类,便于排版。
|
||||
const buttons = useMemo(() => info?.fields.filter((f) => f.type === 'button') ?? [], [info])
|
||||
const inputs = useMemo(() => info?.fields.filter((f) => f.type !== 'button') ?? [], [info])
|
||||
const selects = useMemo(() => info?.fields.filter((f) => f.type === 'toggle' || f.type === 'select') ?? [], [info])
|
||||
|
||||
const runAction = async (action: string, opts: { saveFields?: boolean } = {}) => {
|
||||
const runAction = async (action: string, opts: { fieldsOverride?: Record<string, string> } = {}) => {
|
||||
const key = action || '__login__'
|
||||
// 注意用 fieldsOverride 而不是闭包里的 form:select/toggle 的 onPick 里
|
||||
// setForm 是异步的,同一轮事件里读 form 拿到的还是旧值,
|
||||
// 会让书源按旧线路执行(表现为「切了但没生效」)。
|
||||
const fields = opts.fieldsOverride ?? form
|
||||
setRunning(key)
|
||||
setToasts([])
|
||||
setError('')
|
||||
// 动作可能阻塞等待人工操作,期间持续轮询待办页面
|
||||
const poll = window.setInterval(() => void pollBrowserPages(), 600)
|
||||
try {
|
||||
const res = await readerAPI.runSourceLogin(sourceId, { action, fields: form })
|
||||
const res = await readerAPI.runSourceLogin(sourceId, { action, fields })
|
||||
const messages = [...(res.toasts ?? [])]
|
||||
if (res.error) messages.push(res.error)
|
||||
setToasts(messages)
|
||||
if (res.browsers?.length) {
|
||||
// 服务端无法弹窗:把需要人工操作的页面提示给用户
|
||||
for (const b of res.browsers) {
|
||||
messages.push(`需要浏览器操作:${b.title || b.url}`)
|
||||
}
|
||||
setToasts([...messages])
|
||||
// 服务端未注入宿主浏览器时(非登录链路),这里退化为提示 + 可打开的地址
|
||||
for (const b of res.browsers ?? []) {
|
||||
messages.push(`需要浏览器操作:${b.title || ''} ${b.url}`.trim())
|
||||
}
|
||||
setToasts(messages)
|
||||
// 同步回最新登录信息(书源可能在动作里回填字段)
|
||||
if (res.values) setForm((prev) => ({ ...prev, ...res.values }))
|
||||
if (opts.saveFields && !res.ok) {
|
||||
// 动作失败也保留用户输入,便于改完重试
|
||||
}
|
||||
onLoggedInChange?.(res.logged_in)
|
||||
const nextInfo = await readerAPI.sourceLogin(sourceId)
|
||||
setInfo(nextInfo)
|
||||
@@ -96,10 +115,20 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
window.clearInterval(poll)
|
||||
setRunning('')
|
||||
// 收尾再取一次,避免最后一个待办落在轮询间隙里
|
||||
void pollBrowserPages()
|
||||
}
|
||||
}
|
||||
|
||||
const closeBrowserPage = useCallback(() => {
|
||||
setBrowserPage((cur) => {
|
||||
if (cur) handledPages.current.add(cur.id)
|
||||
return null
|
||||
})
|
||||
}, [])
|
||||
|
||||
const saveLoginInfo = async () => {
|
||||
try {
|
||||
await readerAPI.saveSourceLoginInfo(sourceId, form)
|
||||
@@ -141,7 +170,14 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
||||
const cookieEntries = Object.entries(info?.cookies ?? {})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
// 页面承载面板打开时不允许点遮罩关闭:书源正阻塞等待用户在这块面板里
|
||||
// 操作,把对话框一起关掉会让回传链路断在半路。
|
||||
onClick={() => {
|
||||
if (!browserPage) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -216,8 +252,11 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
||||
value={form[f.name] ?? f.default ?? ''}
|
||||
disabled={!!running}
|
||||
onPick={(v) => {
|
||||
setForm((prev) => ({ ...prev, [f.name]: v }))
|
||||
if (f.action) void runAction(f.action)
|
||||
// 必须把新值显式带进 action:setForm 是异步的,
|
||||
// runAction 读闭包里的 form 会拿到切换前的旧值。
|
||||
const next = { ...form, [f.name]: v }
|
||||
setForm(next)
|
||||
if (f.action) void runAction(f.action, { fieldsOverride: next })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -332,6 +371,9 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 书源页面承载面板:startBrowserAwait 会等这里的「完成」把 DOM 回传 */}
|
||||
{browserPage && <BrowserPanel page={browserPage} onClose={closeBrowserPage} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,8 +55,25 @@ interface ReaderSettingsState {
|
||||
|
||||
paragraphSpacing: number
|
||||
setParagraphSpacing: (v: number) => void
|
||||
|
||||
// ── 听书(音频源播放器)偏好,对齐 legado AudioPlayService ──
|
||||
/** 播放倍速(AudioPlay.playSpeed),0.5–3.0,步进 0.1。 */
|
||||
audioSpeed: number
|
||||
setAudioSpeed: (v: number) => void
|
||||
/** 定时关闭默认分钟数(AppConfig.ttsTimer 语义),0 表示不定时。 */
|
||||
audioTimerMinutes: number
|
||||
setAudioTimerMinutes: (v: number) => void
|
||||
}
|
||||
|
||||
/** 听书倍速可选值(legado 是 0.1 步进的浮点,这里收在 0.5–3.0)。 */
|
||||
export const AUDIO_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]
|
||||
|
||||
/** 定时关闭预设分钟数(legado ReadAloudDialog times 数组)。 */
|
||||
export const AUDIO_TIMERS = [0, 5, 10, 15, 30, 60, 90, 180]
|
||||
|
||||
/** 片头/片尾秒数上限(0 表示不跳过)。 */
|
||||
export const AUDIO_CREDITS_MAX = 300
|
||||
|
||||
export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -81,6 +98,14 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
|
||||
paragraphSpacing: 8,
|
||||
setParagraphSpacing: (paragraphSpacing) =>
|
||||
set({ paragraphSpacing: Math.min(32, Math.max(0, paragraphSpacing)) }),
|
||||
|
||||
audioSpeed: 1,
|
||||
setAudioSpeed: (audioSpeed) =>
|
||||
set({ audioSpeed: Math.min(3, Math.max(0.5, Math.round(audioSpeed * 10) / 10)) }),
|
||||
|
||||
audioTimerMinutes: 0,
|
||||
setAudioTimerMinutes: (audioTimerMinutes) =>
|
||||
set({ audioTimerMinutes: Math.min(180, Math.max(0, Math.round(audioTimerMinutes))) }),
|
||||
}),
|
||||
{ name: 'mebox-reader-settings' },
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user