mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
feat(reader): P3+P4——音频源与漫画/图片源全链路支持
后端: - /api/reader/media 签名媒体代理:HMAC-SHA256(jwtSecret, bookID|url) 防开放 中转滥用;免 JWT(audio/img 元素带不了头)但需有效签名+书籍存在 - 携书源级请求头与 Referer(防盗链)、Range 透传(音频拖动进度 206)、 m3u8 播放列表分片/密钥地址改写为签名代理(hls.js 无感续播) - 音频/图片地址按最终页面 URL 绝对化,imageStyle 透传; GetContentForBook 将 tracks/images 统一改写为代理地址 前端: - ReaderAudioPanel(仿 ReadAloudDialog):hls.js 播 m3u8 / <audio> 直链, 上一章/播放暂停/下一章、进度条拖动、倍速 0.75–2x、按秒记忆进度、 播完自动下一章、点背景呼出菜单 - ReaderComic:上下滚动 / 左右单页双模式(复用翻页/滚动切换)、 图片懒加载+失败占位、滚动模式左右点击滚屏/中间呼菜单、 单页模式点击区翻页、菜单进度条按图片序号、按图片序号记忆进度 - 菜单进度条按内容类型适配(文本页/音频章节/漫画图片序号)
This commit is contained in:
@@ -97,6 +97,7 @@ export interface ReaderChapterContent {
|
||||
content?: string
|
||||
tracks?: string[]
|
||||
images?: string[]
|
||||
image_style?: string
|
||||
}
|
||||
|
||||
export interface ReaderReplaceRule {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Loader2, Pause, Play } from 'lucide-react'
|
||||
|
||||
// 音频播放面板(仿 legado ReadAloudDialog transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链;上一章/播放暂停/下一章 + 进度条 + 倍速。
|
||||
|
||||
interface ReaderAudioPanelProps {
|
||||
src: string
|
||||
title: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
initialPos: number // 恢复进度(秒)
|
||||
onProgress: (seconds: number) => void
|
||||
onPrevChapter: () => void
|
||||
onNextChapter: () => void
|
||||
onEnded: () => void
|
||||
onToggleMenu: () => void
|
||||
}
|
||||
|
||||
const RATES = [0.75, 1, 1.25, 1.5, 2]
|
||||
|
||||
function fmt(sec: number): string {
|
||||
if (!Number.isFinite(sec)) return '0:00'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function ReaderAudioPanel({
|
||||
src,
|
||||
title,
|
||||
theme,
|
||||
initialPos,
|
||||
onProgress,
|
||||
onPrevChapter,
|
||||
onNextChapter,
|
||||
onEnded,
|
||||
onToggleMenu,
|
||||
}: ReaderAudioPanelProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const hlsRef = useRef<{ destroy: () => void } | null>(null)
|
||||
const restoredRef = useRef(false)
|
||||
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)
|
||||
|
||||
// 换源/换章:重建播放器
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
restoredRef.current = false
|
||||
setLoading(true)
|
||||
let cancelled = false
|
||||
let hls: { destroy: () => void } | null = null
|
||||
|
||||
const setup = async () => {
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
const Hls = mod.default
|
||||
if (cancelled) return
|
||||
if (Hls.isSupported()) {
|
||||
const inst = new Hls({ enableWorker: true })
|
||||
inst.loadSource(src)
|
||||
inst.attachMedia(audio)
|
||||
inst.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) setLoading(false)
|
||||
})
|
||||
hls = inst
|
||||
hlsRef.current = inst
|
||||
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
audio.src = src // Safari 原生 HLS
|
||||
}
|
||||
} else {
|
||||
audio.src = src
|
||||
}
|
||||
}
|
||||
void setup()
|
||||
return () => {
|
||||
cancelled = true
|
||||
hls?.destroy()
|
||||
hlsRef.current = null
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) audio.playbackRate = RATES[rateIdx]
|
||||
}, [rateIdx])
|
||||
|
||||
const toggle = () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
if (audio.paused) void audio.play().catch(() => undefined)
|
||||
else audio.pause()
|
||||
}
|
||||
|
||||
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="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 ? '暂停' : '播放'}
|
||||
>
|
||||
{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>
|
||||
<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 }}
|
||||
>
|
||||
{RATES[rateIdx]}x
|
||||
</button>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="hidden"
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onLoadedMetadata={(e) => {
|
||||
setDur(e.currentTarget.duration)
|
||||
setLoading(false)
|
||||
if (!restoredRef.current && initialPos > 0 && initialPos < e.currentTarget.duration) {
|
||||
e.currentTarget.currentTime = initialPos
|
||||
}
|
||||
restoredRef.current = true
|
||||
void e.currentTarget.play().catch(() => undefined)
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
setCur(e.currentTarget.currentTime)
|
||||
onProgress(e.currentTarget.currentTime)
|
||||
}}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
|
||||
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
|
||||
|
||||
interface ReaderComicProps {
|
||||
images: string[]
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
mode: 'page' | 'scroll'
|
||||
page: number // 单页模式当前页(0 基)
|
||||
onZone: (zone: 'left' | 'center' | 'right') => void
|
||||
initialImage: number
|
||||
onProgress: (imageIndex: number) => void
|
||||
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
|
||||
onScrolled: () => void
|
||||
}
|
||||
|
||||
function ComicImage({ src, theme }: { src: string; theme: { bg: string; text: string; accent: string } }) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{state === 'loading' && (
|
||||
<div className="flex h-64 w-full items-center justify-center" style={{ color: theme.text }}>
|
||||
<Loader2 className="animate-spin opacity-50" size={22} />
|
||||
</div>
|
||||
)}
|
||||
{state === 'error' ? (
|
||||
<div className="flex h-40 w-full items-center justify-center text-xs opacity-50" style={{ color: theme.text }}>
|
||||
图片加载失败
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
alt=""
|
||||
onLoad={() => setState('ok')}
|
||||
onError={() => setState('error')}
|
||||
className={`block w-full ${state === 'loading' ? 'hidden' : ''}`}
|
||||
style={{ backgroundColor: theme.bg }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const restoredRef = useRef(false)
|
||||
|
||||
// 滚动模式:恢复进度(图片序号)并上报当前图
|
||||
useEffect(() => {
|
||||
if (mode !== 'scroll') return
|
||||
if (!restoredRef.current && initialImage > 0 && imgRefs.current[initialImage]) {
|
||||
imgRefs.current[initialImage]?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
restoredRef.current = true
|
||||
}, [mode, initialImage, images.length])
|
||||
|
||||
// 外部要求滚动到指定图片(菜单进度条)
|
||||
useEffect(() => {
|
||||
if (mode !== 'scroll' || scrollTo === null) return
|
||||
imgRefs.current[scrollTo]?.scrollIntoView({ block: 'start' })
|
||||
onScrolled()
|
||||
}, [scrollTo, mode, onScrolled])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'page') return
|
||||
restoredRef.current = false
|
||||
}, [mode])
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
本章没有图片
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'page') {
|
||||
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
|
||||
const idx = Math.min(Math.max(page, 0), images.length - 1)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<ComicImage key={images[idx]} src={images[idx]} theme={theme} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 上下滚动模式
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full overflow-y-auto"
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
// 以视口顶部所在图片为当前进度
|
||||
let current = 0
|
||||
for (let i = 0; i < imgRefs.current.length; i++) {
|
||||
const node = imgRefs.current[i]
|
||||
if (node && node.offsetTop <= el.scrollTop + el.clientHeight * 0.4) current = i
|
||||
}
|
||||
onProgress(current)
|
||||
if (!restoredRef.current && initialImage >= 0) {
|
||||
restoredRef.current = true
|
||||
}
|
||||
}}
|
||||
>
|
||||
{images.map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(node) => {
|
||||
imgRefs.current[i] = node
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = (e.clientX - rect.left) / rect.width
|
||||
if (x < 0.3) {
|
||||
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||
} else if (x > 0.7) {
|
||||
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
||||
} else {
|
||||
onZone('center')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ComicImage src={src} theme={theme} />
|
||||
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
|
||||
{i + 1} / {images.length}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="h-10" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
LayoutList,
|
||||
ListEnd,
|
||||
Loader2,
|
||||
@@ -18,6 +17,8 @@ import { Virtuoso } from 'react-virtuoso'
|
||||
|
||||
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent } from '../../api/reader'
|
||||
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||
import { ReaderAudioPanel } from './ReaderAudioPanel'
|
||||
import { ReaderComic } from './ReaderComic'
|
||||
|
||||
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
|
||||
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
|
||||
@@ -52,6 +53,13 @@ export default function ReaderViewPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageCount, setPageCount] = useState(1)
|
||||
const [vw, setVw] = useState(0)
|
||||
// 音频/漫画媒体状态
|
||||
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
|
||||
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
|
||||
const [comicPage, setComicPage] = useState(0)
|
||||
const [currentImage, setCurrentImage] = useState(0)
|
||||
const [scrollToImage, setScrollToImage] = useState<number | null>(null)
|
||||
const lastMediaSaveRef = useRef(0)
|
||||
const contentCache = useRef(new Map<string, ReaderChapterContent>())
|
||||
const pendingPosRef = useRef(0)
|
||||
const pendingEndRef = useRef(false)
|
||||
@@ -120,9 +128,16 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (cancelled) return
|
||||
setContentType(ct.type)
|
||||
setMedia(ct)
|
||||
setContent(ct.content ?? '')
|
||||
setPage(0)
|
||||
// pendingPosRef 保留:排版完成后由 relayout / 滚动恢复效果消费
|
||||
setComicPage(0)
|
||||
setCurrentImage(0)
|
||||
// 音频/漫画的进度恢复值在这里取走(文本由排版/滚动效果消费 pendingPosRef)
|
||||
if (ct.type !== 'text') {
|
||||
setRestorePos(pendingPosRef.current)
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
// 进度上报(pos 保留原值,排版完成后才被消费清零)
|
||||
readerAPI
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
|
||||
@@ -211,6 +226,39 @@ export default function ReaderViewPage() {
|
||||
return () => clearTimeout(t)
|
||||
}, [page, content, chapterIndex, settings.pageMode, savePos])
|
||||
|
||||
// 漫画单页进度保存
|
||||
useEffect(() => {
|
||||
if (contentType !== 'image' || settings.pageMode !== 'page' || !media || chapterIndex === null) return
|
||||
const t = setTimeout(() => savePos(comicPage), 1200)
|
||||
return () => clearTimeout(t)
|
||||
}, [comicPage, contentType, settings.pageMode, media, chapterIndex, savePos])
|
||||
|
||||
// 音频/漫画滚动:节流进度保存
|
||||
const throttledMediaSave = useCallback(
|
||||
(pos: number) => {
|
||||
if (!book || chapterIndex === null) return
|
||||
const now = Date.now()
|
||||
if (now - lastMediaSaveRef.current < (contentType === 'audio' ? 10_000 : 2_000)) return
|
||||
lastMediaSaveRef.current = now
|
||||
savePos(pos)
|
||||
},
|
||||
[book, chapterIndex, contentType, savePos],
|
||||
)
|
||||
|
||||
// 漫画:翻到章尾/恢复进度定位
|
||||
useEffect(() => {
|
||||
if (!media || media.type !== 'image') return
|
||||
const n = media.images?.length ?? 0
|
||||
if (pendingEndRef.current && n > 0) {
|
||||
setComicPage(n - 1)
|
||||
pendingEndRef.current = false
|
||||
return
|
||||
}
|
||||
if (restorePos > 0) {
|
||||
setComicPage(Math.min(restorePos, Math.max(0, n - 1)))
|
||||
}
|
||||
}, [media, restorePos])
|
||||
|
||||
// ── 章节导航 ──
|
||||
const goChapter = useCallback(
|
||||
(delta: number, atEnd = false) => {
|
||||
@@ -229,15 +277,34 @@ export default function ReaderViewPage() {
|
||||
)
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(-1)
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
if (comicPage > 0) setComicPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
return
|
||||
}
|
||||
if (settings.pageMode === 'scroll') {
|
||||
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||
return
|
||||
}
|
||||
if (page > 0) setPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
}, [settings.pageMode, page, goChapter])
|
||||
}, [contentType, settings.pageMode, comicPage, page, goChapter])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
goChapter(1)
|
||||
return
|
||||
}
|
||||
if (contentType === 'image' && settings.pageMode === 'page') {
|
||||
const n = media?.images?.length ?? 0
|
||||
if (comicPage < n - 1) setComicPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
return
|
||||
}
|
||||
if (settings.pageMode === 'scroll') {
|
||||
const el = scrollRef.current
|
||||
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) goChapter(1)
|
||||
@@ -246,7 +313,7 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
if (page < pageCount - 1) setPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
}, [settings.pageMode, page, pageCount, goChapter])
|
||||
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
|
||||
|
||||
// ── 键盘(桌面端) ──
|
||||
useEffect(() => {
|
||||
@@ -268,6 +335,45 @@ export default function ReaderViewPage() {
|
||||
}, [goPrev, goNext, menuOpen, panel])
|
||||
|
||||
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
|
||||
|
||||
// 菜单进度条按内容类型适配:文本=页/滚动位置,音频=章节,漫画=图片序号
|
||||
const imageCount = media?.images?.length ?? 0
|
||||
const sliderCfg = (() => {
|
||||
if (contentType === 'audio') {
|
||||
return {
|
||||
min: 0,
|
||||
max: Math.max(0, chapters.length - 1),
|
||||
value: Math.max(0, chapterIndex ?? 0),
|
||||
onChange: (v: number) => setChapterIndex(v),
|
||||
}
|
||||
}
|
||||
if (contentType === 'image') {
|
||||
const max = Math.max(0, imageCount - 1)
|
||||
if (settings.pageMode === 'page') {
|
||||
return { min: 0, max, value: Math.min(comicPage, max), onChange: (v: number) => setComicPage(v) }
|
||||
}
|
||||
return { min: 0, max, value: Math.min(currentImage, max), onChange: (v: number) => setScrollToImage(v) }
|
||||
}
|
||||
return {
|
||||
min: 1,
|
||||
max: Math.max(1, settings.pageMode === 'page' ? pageCount : 1000),
|
||||
value:
|
||||
settings.pageMode === 'page'
|
||||
? page + 1
|
||||
: Math.round(
|
||||
((scrollRef.current?.scrollTop ?? 0) /
|
||||
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
|
||||
1000,
|
||||
),
|
||||
onChange: (v: number) => {
|
||||
if (settings.pageMode === 'page') setPage(v - 1)
|
||||
else {
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTop = (v / 1000) * (el.scrollHeight - el.clientHeight)
|
||||
}
|
||||
},
|
||||
}
|
||||
})()
|
||||
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
|
||||
|
||||
// ── 渲染 ──
|
||||
@@ -287,7 +393,42 @@ export default function ReaderViewPage() {
|
||||
{/* 正文视口 */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="mx-auto h-full w-full max-w-[900px]">
|
||||
{settings.pageMode === 'page' ? (
|
||||
{contentType === 'audio' ? (
|
||||
media && media.tracks && media.tracks.length > 0 ? (
|
||||
<ReaderAudioPanel
|
||||
src={media.tracks[0]}
|
||||
title={currentChapter?.title ?? book?.name ?? '播放'}
|
||||
theme={theme}
|
||||
initialPos={restorePos}
|
||||
onProgress={throttledMediaSave}
|
||||
onPrevChapter={() => goChapter(-1)}
|
||||
onNextChapter={() => goChapter(1)}
|
||||
onEnded={() => goChapter(1)}
|
||||
onToggleMenu={() => setMenuOpen((v) => !v)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
|
||||
{loadingStage !== null ? <Loader2 className="animate-spin opacity-60" size={24} /> : '本章没有可播放的音频'}
|
||||
</div>
|
||||
)
|
||||
) : contentType === 'image' ? (
|
||||
<ReaderComic
|
||||
images={media?.images ?? []}
|
||||
theme={theme}
|
||||
mode={settings.pageMode}
|
||||
page={comicPage}
|
||||
onZone={(zone) => {
|
||||
if (zone === 'center') setMenuOpen((v) => !v)
|
||||
}}
|
||||
initialImage={restorePos}
|
||||
onProgress={(idx) => {
|
||||
setCurrentImage(idx)
|
||||
throttledMediaSave(idx)
|
||||
}}
|
||||
scrollTo={scrollToImage}
|
||||
onScrolled={() => setScrollToImage(null)}
|
||||
/>
|
||||
) : settings.pageMode === 'page' ? (
|
||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
ref={contentRef}
|
||||
@@ -368,17 +509,18 @@ export default function ReaderViewPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 点击区域(9 宫格简化为三列,语义同 legado 默认配置:左右翻页、中间呼出菜单) */}
|
||||
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
|
||||
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="菜单"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="cursor-default"
|
||||
/>
|
||||
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
|
||||
</div>
|
||||
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
|
||||
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
|
||||
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="菜单"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="cursor-default"
|
||||
/>
|
||||
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 页脚页码(翻页模式) */}
|
||||
@@ -433,20 +575,10 @@ export default function ReaderViewPage() {
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={Math.max(1, settings.pageMode === 'page' ? pageCount : 1000)}
|
||||
value={settings.pageMode === 'page' ? page + 1 : Math.round(
|
||||
((scrollRef.current?.scrollTop ?? 0) /
|
||||
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
|
||||
1000,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
if (settings.pageMode === 'page') setPage(Number(e.target.value) - 1)
|
||||
else {
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTop = (Number(e.target.value) / 1000) * (el.scrollHeight - el.clientHeight)
|
||||
}
|
||||
}}
|
||||
min={sliderCfg.min}
|
||||
max={sliderCfg.max}
|
||||
value={sliderCfg.value}
|
||||
onChange={(e) => sliderCfg.onChange(Number(e.target.value))}
|
||||
className="flex-1 accent-current"
|
||||
style={{ accentColor: theme.accent }}
|
||||
/>
|
||||
@@ -587,15 +719,6 @@ export default function ReaderViewPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 音频/漫画提示(P3/P4 开放) */}
|
||||
{contentType !== 'text' && content !== null && (
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
|
||||
<div className="text-center">
|
||||
<ChevronLeft className="mx-auto opacity-30" size={28} />
|
||||
<p className="mt-2 text-sm opacity-70">{contentType === 'audio' ? '音频播放将在后续版本开放' : '漫画阅读将在后续版本开放'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user