mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user