Compare commits

...

2 Commits

Author SHA1 Message Date
truewhile ce3baee8f1 优化 2026-10-02 23:54:37 +08:00
truewhile 2494f0c0cd 优化 2026-10-02 23:09:35 +08:00
11 changed files with 1085 additions and 368 deletions
+1
View File
@@ -97,6 +97,7 @@
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
+3
View File
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { appRoutes, type AppRoute } from './appRoutes'
import { Layout } from './components/Layout'
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
import { ReaderMiniPlayer } from './pages/reader/ReaderMiniPlayer'
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
@@ -93,6 +94,8 @@ export default function App() {
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{/* 听书迷你条:播放引擎常驻,离开阅读页后仍可从这里控制 */}
<ReaderMiniPlayer />
</Suspense>
</AppErrorBoundary>
)
+42 -317
View File
@@ -1,28 +1,12 @@
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 { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { ReaderChapter } from '../../api/reader'
import {
AUDIO_CREDITS_MAX,
AUDIO_SPEEDS,
AUDIO_TIMERS,
useReaderSettingsStore,
} from '../../stores/readerSettings'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { AUDIO_CREDITS_MAX, AUDIO_SPEEDS, AUDIO_TIMERS } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { readerCoverSrc } from '../../utils/readerCover'
import { getReaderAudioEngine } from './readerAudioEngine'
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
// hls.js 播 m3u8,<audio> 播直链。
@@ -30,35 +14,12 @@ import { readerCoverSrc } from '../../utils/readerCover'
// 动作行:章节选择、定时关闭、倍速、跳过片头片尾。
// 片头片尾按 legado 语义:全新开播(进度 0)时 seek 到 openCredits,播放到
// duration - closeCredits 即等同播完自动下一章;两者都以秒计、0 表示不跳过。
//
// 这个组件只是一层「视图」:真正的 <audio> 与全部播放状态都在常驻的
// readerAudioEngine 单例里,面板挂载/卸载都不影响播放(见该文件顶部说明)。
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
/**
* 立即落一次进度(不节流)。只在「用户要离开这一章」时用(切后台/关页面),
* 把节流窗口里最后几秒补上;正常播放走 onProgress 的节流上报。
*/
onCommitProgress: (seconds: number) => void
onPrevChapter: () => void
onNextChapter: () => void
onSelectChapter: (index: number) => void
onEnded: () => void
onCreditsChange: (open: number, close: number) => void
onToggleMenu: () => void
}
@@ -84,56 +45,34 @@ function fmtCountdown(sec: number): string {
return `${m}:${String(s).padStart(2, '0')}`
}
export function ReaderAudioPanel({
src,
title,
cover,
theme,
initialPos,
openCredits,
closeCredits,
chapters,
chapterIndex,
hasNext,
transcoding = false,
onProgress,
onCommitProgress,
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)
/** 章节抽屉的虚拟列表句柄:分组下拉靠它整段跳转。 */
export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps) {
const engine = useMemo(() => getReaderAudioEngine(), [])
const title = useReaderAudioStore((s) => s.chapterTitle)
const cover = useReaderAudioStore((s) => s.cover)
const chapters = useReaderAudioStore((s) => s.chapters)
const chapterIndex = useReaderAudioStore((s) => s.chapterIndex)
const status = useReaderAudioStore((s) => s.status)
const audioError = useReaderAudioStore((s) => s.error)
const cur = useReaderAudioStore((s) => s.cur)
const dur = useReaderAudioStore((s) => s.dur)
const speed = useReaderAudioStore((s) => s.speed)
const transcoding = useReaderAudioStore((s) => s.transcoding)
const openCredits = useReaderAudioStore((s) => s.openCredits)
const closeCredits = useReaderAudioStore((s) => s.closeCredits)
const timerActive = useReaderAudioStore((s) => s.timerActive)
const timerLeft = useReaderAudioStore((s) => s.timerLeft)
const timerPreset = useReaderAudioStore((s) => s.timerPreset)
const playing = status === 'playing'
const loading = status === 'loading'
const tocRef = useRef<VirtuosoHandle>(null)
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 [loading, setLoading] = useState(true)
// 音轨加载失败(格式不支持 / 转码失败):界面上要给出原因,不能一直停在「加载中」
const [audioError, setAudioError] = useState('')
const [sheet, setSheet] = useState<Sheet>('none')
/** 章节抽屉顶部「区间下拉」当前选中的组号,随列表滚动同步。 */
const [groupIndex, setGroupIndex] = useState(0)
// 当前音轨的最后播放位置(秒)。timeupdate 只在播放中触发,离开页面时要用它补报一次。
const lastTimeRef = useRef(0)
// onCommitProgress 每次渲染都可能换实例(父级闭包里的 chapterIndex 会变),
// 卸载时只能通过 ref 拿到最新的那个,否则会写错章节。
const commitRef = useRef(onCommitProgress)
useEffect(() => {
commitRef.current = onCommitProgress
}, [onCommitProgress])
// 封面可能被防盗链挡掉:加载失败就退回主题色占位,不留破图
const [coverOK, setCoverOK] = useState(true)
useEffect(() => {
@@ -142,168 +81,22 @@ export function ReaderAudioPanel({
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
const coverSrc = readerCoverSrc(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)
// 换章时清零:否则离开页面时会把上一章的位置补报成新章节的进度
lastTimeRef.current = 0
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 = speed
}, [speed])
// ── 离开这一章时补报进度 ──
// 进度平时靠 timeupdate 节流上报(听书 5s 一次),而 timeupdate 只在播放中触发:
// 用户按退出/切后台那一瞬间的位置没人上报,节流窗口内最后几秒就丢了。
// 这里只处理「离开」事件:换章不会卸载本面板(src 变化时 lastTimeRef 已清零),
// 因此不会把上一章的位置写到新章节头上。
useEffect(() => {
const flush = () => {
if (lastTimeRef.current > 0) commitRef.current(lastTimeRef.current)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') flush()
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('pagehide', flush)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('pagehide', flush)
flush()
}
}, [])
// ── 定时关闭 ──
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
if (audio.paused) void audio.play().catch(() => undefined)
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 toggle = () => engine.toggle()
const seekBy = useCallback((delta: number) => engine.seekBy(delta), [engine])
const seekTo = useCallback((seconds: number) => engine.seekTo(seconds), [engine])
const changeSpeed = (v: number) => engine.setSpeed(v)
const applyTimer = (minutes: number) => engine.applyTimer(minutes)
const saveCredits = (open: number, close: number) => {
setCreditDraft({ open, close })
onCreditsChange(open, close)
engine.setCredits(open, close)
}
const sheetStyle = { backgroundColor: theme.bg, color: theme.text, borderColor: theme.text + '22' }
@@ -326,14 +119,11 @@ export function ReaderAudioPanel({
}
// 每次打开章节抽屉都把区间下拉对齐到当前章节:上次可能停在第 3 组,
// 续读已到第 12 组,沿用旧值会显示错误的区间。
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex ?? 0, chapters.length))
if (next === 'chapters') setGroupIndex(chapterGroupIndexOf(chapterIndex, chapters.length))
setSheet(next)
}
const chapterList = useMemo(
() => chapters.map((c, i) => ({ ...c, i })),
[chapters],
)
const chapterList = useMemo(() => chapters.map((c, i) => ({ ...c, i })), [chapters])
// 上千章的目录整段跳转:每 100 条一组,下拉里选区间即可(见 utils/chapterGroups.ts)。
// 目录不足一组(≤100 章)时不显示下拉——那时一屏能扫完,多了反而是噪声。
@@ -446,7 +236,7 @@ export function ReaderAudioPanel({
>
<button
type="button"
onClick={onPrevChapter}
onClick={() => engine.prev()}
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
aria-label="上一章"
title="上一章"
@@ -492,7 +282,7 @@ export function ReaderAudioPanel({
</button>
<button
type="button"
onClick={onNextChapter}
onClick={() => engine.next()}
className="flex flex-col items-center gap-0.5 opacity-80 hover:opacity-100"
aria-label="下一章"
title="下一章"
@@ -602,7 +392,7 @@ export function ReaderAudioPanel({
<Virtuoso
ref={tocRef}
data={chapterList}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
initialTopMostItemIndex={Math.max(0, chapterIndex)}
rangeChanged={(range) =>
setGroupIndex(chapterGroupIndexOf(range.startIndex, chapterList.length))
}
@@ -614,7 +404,7 @@ export function ReaderAudioPanel({
onClick={() => {
if (row.is_volume) return
closeSheet()
onSelectChapter(row.i)
engine.selectChapter(row.i)
}}
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
row.is_volume ? 'font-bold opacity-70' : ''
@@ -764,71 +554,6 @@ export function ReaderAudioPanel({
</div>
</>
)}
<audio
ref={audioRef}
className="hidden"
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={(e) => {
// 解码不了时浏览器只给错误码;这里把「能看懂的原因」摆到界面上。
// 服务端返回的正文说明(例如未装 ffmpeg)拿不到,只能按错误码给通用解释。
const code = e.currentTarget.error?.code
setLoading(false)
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 audio.play().catch(() => undefined)
}}
onTimeUpdate={(e) => {
const audio = e.currentTarget
const t = audio.currentTime
lastTimeRef.current = t
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}
/>
</div>
)
}
+42 -5
View File
@@ -3,12 +3,15 @@ import { Loader2 } from 'lucide-react'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { rememberImageSize } from '../../utils/comicSpread'
import { comicImageSizing, isSelfSizedComicFit } from '../../utils/comicImageFit'
import type { ComicImageFit } from '../../stores/readerSettings'
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
//
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
// 这里只负责把它们并排摆好、各自撑满视口高度。
// 滚动模式的显示尺寸由 imageFit 档位决定(见 utils/comicImageFit.ts)。
interface ReaderComicProps {
images: string[]
@@ -22,6 +25,8 @@ interface ReaderComicProps {
onScrolled: () => void
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
spread?: number[] | null
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
imageFit?: ComicImageFit
}
function ComicImage({
@@ -29,13 +34,19 @@ function ComicImage({
theme,
fit = false,
half = false,
imageFit = 'default',
viewportHeight = 0,
}: {
src: string
theme: { bg: string; text: string; accent: string }
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉(翻页模式)。
fit?: boolean
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
half?: boolean
// imageFit:滚动模式的显示尺寸档位。
imageFit?: ComicImageFit
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
viewportHeight?: number
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
@@ -45,6 +56,15 @@ function ComicImage({
setState('loading')
}, [src])
const loaded = state === 'ok'
// 翻页模式:整页缩放进视口。滚动模式:按用户选的尺寸档位摆(外层列宽已放开)。
const sizing = fit
? {
className: 'block max-h-full w-auto max-w-full object-contain',
style: loaded ? {} : { minHeight: '10rem' },
}
: comicImageSizing(imageFit, viewportHeight, loaded)
// <img> 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取
// display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会
// 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。
@@ -71,8 +91,8 @@ function ComicImage({
setState('ok')
}}
onError={() => setState('error')}
className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'}
style={state === 'ok' ? undefined : { minHeight: '10rem' }}
className={sizing.className}
style={sizing.style}
/>
{state === 'loading' && (
<div
@@ -94,10 +114,27 @@ function ComicImage({
)
}
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread }: ReaderComicProps) {
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const restoredRef = useRef(false)
// 滚动容器的高度:「一屏多高」以这个容器为准,而不是 window.innerHeight——正文区是
// root(fixed inset-0)里的 flex-1,将来上下再挂条之类的布局变化就不等于窗口高度了。
// 用 ResizeObserver 跟着它,改窗口大小时 height/long 档位自动重算。
const [viewportHeight, setViewportHeight] = useState(0)
const selfSized = isSelfSizedComicFit(imageFit)
useEffect(() => {
// 只有 height/long/original 用得到,默认档位不挂观察器,免得每次改窗口都重渲染整章。
if (mode !== 'scroll' || !selfSized) return
const el = scrollRef.current
if (!el) return
const update = () => setViewportHeight(el.clientHeight)
update()
const observer = new ResizeObserver(update)
observer.observe(el)
return () => observer.disconnect()
}, [mode, selfSized])
// 滚动模式:恢复进度(图片序号)并上报当前图
useEffect(() => {
@@ -183,7 +220,7 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
}
}}
>
<ComicImage src={src} theme={theme} />
<ComicImage src={src} theme={theme} imageFit={imageFit} viewportHeight={viewportHeight} />
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
{i + 1} / {images.length}
</p>
+74
View File
@@ -0,0 +1,74 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { Loader2, Pause, Play, X } from 'lucide-react'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { readerCoverSrc } from '../../utils/readerCover'
import { getReaderAudioEngine } from './readerAudioEngine'
// 全局听书迷你条。
//
// 播放引擎是常驻单例(见 readerAudioEngine.ts),离开阅读页后音频仍在播;这个条
// 保证在其他页面也能暂停/回到阅读,否则用户会「听到声音但找不到控制」。
// 面板所在的阅读页内部不显示它(那里有完整播放面板),避免两套控制打架。
export function ReaderMiniPlayer() {
const location = useLocation()
const navigate = useNavigate()
const track = useReaderAudioStore((s) => s.track)
const bookId = useReaderAudioStore((s) => s.bookId)
const title = useReaderAudioStore((s) => s.chapterTitle)
const bookName = useReaderAudioStore((s) => s.bookName)
const cover = useReaderAudioStore((s) => s.cover)
const status = useReaderAudioStore((s) => s.status)
const onReaderView = bookId !== '' && location.pathname === `/reader/view/${bookId}`
if (!track || !bookId || onReaderView || status === 'idle') return null
const engine = getReaderAudioEngine()
const playing = status === 'playing'
const loading = status === 'loading'
const coverSrc = readerCoverSrc(cover)
return (
<div
className="fixed inset-x-3 z-[60] flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]/95 p-2 shadow-2xl backdrop-blur-md lg:inset-x-auto lg:right-6 lg:w-96"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 3.75rem)' }}
>
<button
type="button"
onClick={() => navigate(`/reader/view/${bookId}`)}
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
title="回到阅读页"
>
<span className="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-brand-soft)]">
{coverSrc ? (
<img src={coverSrc} alt="" referrerPolicy="no-referrer" className="h-full w-full object-cover" />
) : null}
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-bold text-[var(--app-text)]">{title || bookName}</span>
<span className="block truncate text-[10px] text-[var(--app-muted)]">{bookName || 'MeBox 听书'}</span>
</span>
</button>
<button
type="button"
onClick={() => engine.toggle()}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm"
aria-label={playing ? '暂停' : '播放'}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : playing ? <Pause size={16} /> : <Play size={16} />}
</button>
<button
type="button"
onClick={() => engine.stop()}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[var(--app-muted)] hover:text-[var(--app-text)]"
aria-label="停止播放"
title="停止播放"
>
<X size={16} />
</button>
</div>
)
}
+92 -44
View File
@@ -20,11 +20,13 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
import { getReaderAudioEngine } from './readerAudioEngine'
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
@@ -149,6 +151,19 @@ export default function ReaderViewPage() {
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 0
// ── 漫画滚动模式的图片显示尺寸 ──
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
// 掺进来反而会和双页铺开的排版打架。所以翻页模式一律按 default 处理。
const comicScrollFit = contentType === 'image' && settings.pageMode === 'scroll' ? settings.comicImageFit : 'default'
/** 图片尺寸档位只在漫画的滚动模式下起作用,别的场景不显示这一行免得点了没反应。 */
const showComicImageFit = contentType === 'image' && settings.pageMode === 'scroll'
/**
* 正文列是否放开 900px 上限。两种情况:漫画双页铺开要吃满窗口宽度;
* 或者用户把图片尺寸调成了「适应宽度/高度/长边/原图」——这些档位由图片自己定尺寸,
* 900px 的列会把它们再压回去。
*/
const comicFullWidth = contentType === 'image' && (comicDoublePage || comicScrollFit !== 'default')
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
@@ -180,11 +195,28 @@ export default function ReaderViewPage() {
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
const explicitChapter = Number.isInteger(qChapter) && !!chs[qChapter] && !chs[qChapter].is_volume
if (explicitChapter) idx = qChapter
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
idx = b.dur_chapter_index
}
pendingPosRef.current = b.dur_chapter_pos ?? 0
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
const live = useReaderAudioStore.getState()
if (
!explicitChapter &&
live.bookId === b.id &&
live.track !== '' &&
(live.status === 'playing' || live.status === 'loading' || live.status === 'paused') &&
live.chapterIndex !== idx &&
chs[live.chapterIndex]
) {
idx = live.chapterIndex
// 位置由引擎自己掌握,服务端那份属于上一章,别写过去
pendingPosRef.current = 0
}
setChapterIndex(idx)
} catch (e) {
if (!cancelled) setError((e as Error).message || '加载失败')
@@ -227,6 +259,22 @@ export default function ReaderViewPage() {
setRestorePos(savedPos)
pendingPosRef.current = 0
}
// 听书:把这一章交给常驻播放引擎。引擎是全局单例,离开本页也不会被卸载,
// 因此换章/离开路由/息屏都继续播(面板卸载不再掐播放)。
if (ct.type === 'audio' && ct.tracks && ct.tracks.length > 0) {
getReaderAudioEngine().loadChapter({
bookId: book.id,
bookName: book.name,
cover: book.cover_url ?? '',
chapters,
chapterIndex,
track: ct.tracks[0],
transcoding: ct.transcoding ?? false,
openCredits: book.open_credits ?? 0,
closeCredits: book.close_credits ?? 0,
initialPos: savedPos,
})
}
// 进度上报(文本的 pos 保留原值,排版完成后才被消费清零)。
// 注意用 savedPos:非文本在上面已经把 pendingPosRef 清零了,直接读会把
// 刚恢复的进度又写成 0,退出重进就从头开始。
@@ -418,14 +466,17 @@ export default function ReaderViewPage() {
[chapterIndex, chapters.length, panel],
)
// 听书:是否还有下一章(片尾跳过/播完时决定续播还是停住)
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])
// 听书:引擎自己也可能切章(播完自动下一章、锁屏/耳机按键上一章下一章),把它的
// 当前章节回写进本页,目录与菜单才不会停在旧章节上。只认本书,避免串到别的书。
const audioChapterIndex = useReaderAudioStore((s) =>
book && s.bookId === book.id ? s.chapterIndex : null,
)
useEffect(() => {
if (contentType !== 'audio' || audioChapterIndex === null) return
if (audioChapterIndex === chapterIndex) return
pendingEndRef.current = false
setChapterIndex(audioChapterIndex)
}, [audioChapterIndex, chapterIndex, contentType])
// ── 换源(对应 legado 阅读页的「换源」) ──
//
@@ -475,19 +526,11 @@ export default function ReaderViewPage() {
// 本地导入的书没有书源,不显示换源入口
const canSwitchSource = !!book && !book.is_local
// 听书:片头/片尾跳过秒数按书写入(对应 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],
)
// 听书:片头/片尾跳过秒数的落库改由播放引擎负责(它持有当前书 ID,离开本页也能写)。
const goPrev = useCallback(() => {
if (contentType === 'audio') {
goChapter(-1)
getReaderAudioEngine().prev()
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
@@ -511,7 +554,7 @@ export default function ReaderViewPage() {
const goNext = useCallback(() => {
if (contentType === 'audio') {
goChapter(1)
getReaderAudioEngine().next()
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
@@ -711,30 +754,10 @@ export default function ReaderViewPage() {
<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 ${contentType === 'image' && comicDoublePage ? '' : 'max-w-[900px]'}`}>
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
{contentType === 'audio' ? (
media && media.tracks && media.tracks.length > 0 ? (
<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}
onCommitProgress={savePos}
onPrevChapter={() => goChapter(-1)}
onNextChapter={() => goChapter(1)}
onSelectChapter={jumpToChapter}
onEnded={() => goChapter(1)}
onCreditsChange={saveAudioCredits}
onToggleMenu={() => setMenuOpen((v) => !v)}
/>
<ReaderAudioPanel theme={theme} 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} /> : '本章没有可播放的音频'}
@@ -746,6 +769,7 @@ export default function ReaderViewPage() {
theme={theme}
mode={settings.pageMode}
page={comicPage}
imageFit={comicScrollFit}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
@@ -975,7 +999,7 @@ export default function ReaderViewPage() {
))}
</div>
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
{panel === 'style' && (
<div
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
@@ -1029,6 +1053,30 @@ export default function ReaderViewPage() {
</button>
</div>
</div>
{/* 漫画滚动模式的图片显示尺寸。默认档就是老样子(900px 居中),
「适应宽度」铺满窗口,「原图」按原始像素 1:1 看细节。 */}
{showComicImageFit && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<span className="opacity-70">图片尺寸</span>
{COMIC_IMAGE_FITS.map((item) => {
const active = settings.comicImageFit === item.id
return (
<button
key={item.id}
type="button"
onClick={() => settings.setComicImageFit(item.id)}
className={`rounded-lg border px-2 py-0.5 ${active ? 'font-bold' : 'opacity-70'}`}
style={{
borderColor: active ? theme.accent : theme.text + '44',
color: active ? theme.accent : theme.text,
}}
>
{item.label}
</button>
)
})}
</div>
)}
</div>
)}
</div>
+611
View File
@@ -0,0 +1,611 @@
// 听书播放引擎:模块级单例,持有真正的 <audio>(不进 React 树,也不进任何路由)。
//
// 为什么要脱离组件:音频面板原先挂在 ReaderViewPage 里,路由一变组件卸载,清理
// 逻辑就 removeAttribute('src') + load(),播放当场断掉;手机息屏、系统回收标签页
// 时更没有任何东西证明「这个页面正在播媒体」。把播放器做成常驻单例后:
// - 路由切换不影响播放;
// - Media Session 让锁屏/通知栏/耳机按键接管控制(Android Chrome 会据此判定
// 页面处于活跃媒体会话,后台存活率明显更高);
// - 定时关闭改由 timeupdate(后台播放时仍触发)+ 低频 ticker 驱动,不再依赖
// 被节流的 setInterval 来「到点暂停」。
//
// 进度上报也在这里:引擎自己知道当前书/章,离开页面、暂停、切章时都能补报一次,
// 不依赖调用方在卸载时回调。
import toast from 'react-hot-toast'
import { readerAPI, type ReaderChapter } from '../../api/reader'
import { useReaderAudioStore, resetReaderAudio } from '../../stores/readerAudio'
import { useReaderSettingsStore } from '../../stores/readerSettings'
import { readerCoverSrc } from '../../utils/readerCover'
/** 播放进度上报节流(毫秒):退出最多丢 5 秒。 */
const PROGRESS_SAVE_INTERVAL_MS = 5_000
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
const SEEK_STEP = 15
export interface LoadChapterInput {
bookId: string
bookName: string
cover: string
chapters: ReaderChapter[]
chapterIndex: number
/** 当前章的音轨地址(已签名)。 */
track: string
transcoding: boolean
openCredits: number
closeCredits: number
/** 恢复进度(秒);0 表示从头。 */
initialPos: number
}
class ReaderAudioEngine {
private audio: HTMLAudioElement
/** hls.js 实例(只用到 destroy)。 */
private hls: { destroy: () => void } | null = null
/** 元数据就绪后是否已经套用过进度/片头跳转。 */
private restored = false
/** 本章是否已经触发过片尾跳过,避免 timeupdate 反复触发。 */
private skippedEnd = false
/** 最近一次播放位置(秒):节流窗口外补报进度时要用。 */
private lastTime = 0
private lastSavedAt = 0
/** 上一次落库的进度标识(书|章|秒),相同则不重复请求。 */
private savedKey = ''
/** 定时关闭截止时间戳(毫秒),0 表示未开启。 */
private timerDeadline = 0
/** 暂停开始的毫秒时间戳,0 表示当前在播(定时倒计时只在播放中走)。 */
private pausedAt = 0
private timerTicker: number | null = null
/** 一次性的初始进度,等 metadata 就绪后消费。 */
private pendingInitialPos = 0
/** 是否已经套用过「上次的定时设置」,只在首次加载时套一次。 */
private timerBooted = false
/** 正在拆卸音源(避免拆卸触发的 pause 事件被当成用户暂停)。 */
private detaching = false
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
private loadToken = 0
constructor() {
const audio = new Audio()
audio.preload = 'metadata'
this.audio = audio
this.bindAudio()
this.bindLifecycle()
this.bindSettings()
this.bindMediaSession()
}
// ─── 对外 API ─────────────────────────────────────────────────────────
/**
* 加载并播放一章。同一本书同一章重复调用(阅读页 effect 重跑、目录同步回写)
* 会被忽略,避免把正在播的进度重置掉。
*/
loadChapter(input: LoadChapterInput): void {
const s = useReaderAudioStore.getState()
if (
s.bookId === input.bookId &&
s.chapterIndex === input.chapterIndex &&
s.track === input.track &&
(s.status === 'loading' || s.status === 'playing' || s.status === 'paused')
) {
return
}
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
this.commitProgress()
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
useReaderAudioStore.setState({
bookId: input.bookId,
bookName: input.bookName,
cover: input.cover,
chapters: input.chapters,
chapterIndex: input.chapterIndex,
chapterTitle,
track: input.track,
transcoding: input.transcoding,
openCredits: input.openCredits,
closeCredits: input.closeCredits,
hasNext: this.computeHasNext(input.chapters, input.chapterIndex),
status: 'loading',
error: '',
cur: 0,
dur: 0,
})
this.restored = false
this.skippedEnd = false
this.lastTime = 0
this.lastSavedAt = 0
this.savedKey = ''
this.pendingInitialPos = Math.max(0, input.initialPos)
// 进场套用上次的定时设置(对应 legado 起朗读服务时 setTimer(AppConfig.ttsTimer))
if (!this.timerBooted) {
this.timerBooted = true
const preset = useReaderSettingsStore.getState().audioTimerMinutes
if (preset > 0) this.applyTimer(preset)
}
void this.attachSource(input.track)
}
play(): void {
void this.audio.play().catch(() => {
// 自动播放被浏览器拦下(典型是 iOS):退回暂停态,让用户点按钮手动触发,
// 而不是一直停在「加载中」。
if (useReaderAudioStore.getState().status === 'loading') {
useReaderAudioStore.setState({ status: 'paused' })
}
})
}
pause(): void {
this.audio.pause()
// 还没触发 play 事件时(status 仍是 loading),下面的 pause 事件处理会跳过状态
// 判定;这里补一次,保证按钮不会一直转圈。
if (useReaderAudioStore.getState().status === 'loading') {
if (this.pausedAt === 0) this.pausedAt = Date.now()
useReaderAudioStore.setState({ status: 'paused' })
this.setSessionPlaybackState('paused')
}
}
toggle(): void {
if (this.audio.paused) this.play()
else this.pause()
}
seekBy(delta: number): void {
const total = Number.isFinite(this.audio.duration)
? this.audio.duration
: this.audio.currentTime + Math.abs(delta)
this.seekTo(Math.min(Math.max(0, this.audio.currentTime + delta), Math.max(0, total - 0.2)))
}
seekTo(seconds: number): void {
if (!Number.isFinite(seconds)) return
this.audio.currentTime = Math.max(0, seconds)
this.lastTime = this.audio.currentTime
useReaderAudioStore.setState({ cur: this.audio.currentTime })
this.updateSessionPosition()
}
/** 倍速改动写回 settings store,订阅回调负责应用到 <audio>。 */
setSpeed(v: number): void {
useReaderSettingsStore.getState().setAudioSpeed(v)
}
/** 片头/片尾跳过秒数:立刻生效并按书持久化。 */
setCredits(open: number, close: number): void {
useReaderAudioStore.setState({ openCredits: open, closeCredits: close })
const { bookId } = useReaderAudioStore.getState()
if (!bookId) return
readerAPI.saveAudioConfig(bookId, { open_credits: open, close_credits: close }).catch(() => undefined)
}
/** 定时关闭:0 表示关闭定时。 */
applyTimer(minutes: number): void {
useReaderSettingsStore.getState().setAudioTimerMinutes(minutes)
useReaderAudioStore.setState({ timerPreset: minutes })
if (minutes <= 0) {
this.timerDeadline = 0
this.pausedAt = 0
this.stopTimerTicker()
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
return
}
this.timerDeadline = Date.now() + minutes * 60_000
// 暂停期间不倒计时:记下暂停起点,恢复播放时顺延截止时间
this.pausedAt = this.audio.paused ? Date.now() : 0
useReaderAudioStore.setState({ timerLeft: minutes * 60, timerActive: true })
this.startTimerTicker()
}
selectChapter(index: number): void {
void this.goToChapter(index, 0)
}
next(): void {
const i = this.neighborChapterIndex(1)
if (i === null) return
void this.goToChapter(i, 0)
}
prev(): void {
const i = this.neighborChapterIndex(-1)
if (i === null) return
void this.goToChapter(i, 0)
}
/** 完全停止并清空状态(迷你播放器的关闭按钮)。 */
stop(): void {
this.commitProgress()
this.detachSource()
this.timerDeadline = 0
this.pausedAt = 0
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
this.timerBooted = false
this.stopTimerTicker()
this.setSessionMetadata(null)
this.setSessionPlaybackState('none')
resetReaderAudio()
}
/** 立即补报一次进度(不吃节流窗口)。 */
commitProgress(): void {
this.lastSavedAt = 0
this.persistProgress()
}
// ─── 音源装载 ─────────────────────────────────────────────────────────
private async attachSource(src: string): Promise<void> {
const token = ++this.loadToken
this.detachSource()
this.setSessionMetadata(src)
if (!src) {
this.fail('本章没有可播放的音频')
return
}
if (src.includes('.m3u8')) {
const mod = await import('hls.js')
// 动态 import 期间可能已经切到别的章:丢弃这次结果
if (token !== this.loadToken) return
const Hls = mod.default
if (Hls.isSupported()) {
const inst = new Hls({ enableWorker: true })
inst.on(Hls.Events.ERROR, (_evt, data) => {
if (data.fatal) this.fail('音频加载失败,请检查网络或源文件是否还在')
})
inst.loadSource(src)
inst.attachMedia(this.audio)
this.hls = inst
return
}
// Safari 原生 HLS
this.audio.src = src
return
}
this.audio.src = src
}
private detachSource(): void {
this.detaching = true
this.hls?.destroy()
this.hls = null
this.audio.removeAttribute('src')
this.audio.load()
this.detaching = false
}
private async goToChapter(index: number, initialPos: number): Promise<void> {
const { bookId, chapters } = useReaderAudioStore.getState()
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
useReaderAudioStore.setState({ status: 'loading', error: '' })
try {
const ct = await readerAPI.bookContent(bookId, index)
const track = ct.tracks?.[0]
if (ct.type !== 'audio' || !track) {
this.fail('本章没有可播放的音频')
return
}
const s = useReaderAudioStore.getState()
this.loadChapter({
bookId,
bookName: s.bookName,
cover: s.cover,
chapters,
chapterIndex: index,
track,
transcoding: ct.transcoding ?? false,
openCredits: s.openCredits,
closeCredits: s.closeCredits,
initialPos,
})
} catch (e) {
this.fail((e as Error).message || '章节加载失败')
}
}
private fail(message: string): void {
useReaderAudioStore.setState({ status: 'error', error: message })
this.setSessionPlaybackState('paused')
}
// ─── <audio> 事件 ─────────────────────────────────────────────────────
private bindAudio(): void {
const audio = this.audio
audio.addEventListener('play', () => {
useReaderAudioStore.setState({ status: 'playing' })
this.setSessionPlaybackState('playing')
// 恢复播放:把暂停时长顺延到定时截止时间
if (this.pausedAt > 0) {
this.timerDeadline += Date.now() - this.pausedAt
this.pausedAt = 0
}
})
audio.addEventListener('pause', () => {
if (this.detaching) return
// 只认「播放中 -> 暂停」这一种。换源/停止时 load() 也会抛出 pause 事件,
// 那些时刻 status 是 loading/idle,直接忽略;否则会把刚进入的加载态改成暂停,
// 并给新章节写一条位置为 0 的进度。
if (useReaderAudioStore.getState().status !== 'playing') return
if (this.pausedAt === 0) this.pausedAt = Date.now()
useReaderAudioStore.setState({ status: 'paused' })
this.setSessionPlaybackState('paused')
this.commitProgress()
})
audio.addEventListener('ended', () => {
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
this.commitProgress()
if (useReaderAudioStore.getState().hasNext) {
this.next()
return
}
// 最后一章播完:ended 不会触发 pause,倒计时/暂停态要在这里自己落定
if (this.pausedAt === 0) this.pausedAt = Date.now()
useReaderAudioStore.setState({ status: 'paused' })
this.setSessionPlaybackState('paused')
})
audio.addEventListener('loadedmetadata', () => {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
useReaderAudioStore.setState({ dur: total })
if (!this.restored) {
const { openCredits } = useReaderAudioStore.getState()
if (this.pendingInitialPos > 0 && (total === 0 || this.pendingInitialPos < total)) {
// 有进度:按存档续播(legado: position != 0 时不套用片头)
audio.currentTime = this.pendingInitialPos
useReaderAudioStore.setState({ cur: this.pendingInitialPos })
} else if (openCredits > 0 && (total === 0 || total > openCredits + 1)) {
// 全新开播:跳到片头结束位置(legado: skipStartMs)
audio.currentTime = openCredits
useReaderAudioStore.setState({ cur: openCredits })
}
this.pendingInitialPos = 0
}
this.restored = true
audio.playbackRate = useReaderAudioStore.getState().speed
this.play()
})
audio.addEventListener('durationchange', () => {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
useReaderAudioStore.setState({ dur: total })
this.updateSessionPosition()
})
audio.addEventListener('timeupdate', () => {
const t = audio.currentTime
this.lastTime = t
useReaderAudioStore.setState({ cur: t })
this.updateSessionPosition()
this.reportProgress()
this.checkCreditsEnd()
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
this.checkTimer()
})
audio.addEventListener('error', () => {
const code = audio.error?.code
this.fail(
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
: code === MediaError.MEDIA_ERR_NETWORK
? '音频加载失败,请检查网络或源文件是否还在'
: '音频无法播放',
)
})
}
// ─── 页面生命周期:只负责补报进度,绝不暂停播放 ───────────────────────
private bindLifecycle(): void {
const flush = () => this.commitProgress()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
window.addEventListener('pagehide', flush)
}
private bindSettings(): void {
const apply = () => {
const { audioSpeed } = useReaderSettingsStore.getState()
this.audio.playbackRate = audioSpeed
useReaderAudioStore.setState({ speed: audioSpeed })
}
apply()
// 引擎是常驻单例,订阅不需要解除
useReaderSettingsStore.subscribe(apply)
}
// ─── 进度 ─────────────────────────────────────────────────────────────
private reportProgress(): void {
const now = Date.now()
if (now - this.lastSavedAt < PROGRESS_SAVE_INTERVAL_MS) return
this.lastSavedAt = now
this.persistProgress()
}
private persistProgress(): void {
const { bookId, chapterIndex, chapterTitle } = useReaderAudioStore.getState()
if (!bookId || this.lastTime <= 0) return
const pos = Math.max(0, Math.floor(this.lastTime))
const key = `${bookId}|${chapterIndex}|${pos}`
if (key === this.savedKey) return
this.savedKey = key
readerAPI.saveProgress(bookId, { chapter_index: chapterIndex, pos, chapter_title: chapterTitle }).catch(() => undefined)
}
// ─── 片头片尾 ─────────────────────────────────────────────────────────
private checkCreditsEnd(): void {
const { closeCredits, hasNext } = useReaderAudioStore.getState()
const audio = this.audio
if (closeCredits <= 0 || this.skippedEnd) return
if (!Number.isFinite(audio.duration) || audio.duration <= closeCredits + 1) return
if (audio.currentTime < audio.duration - closeCredits) return
this.skippedEnd = true
if (!hasNext) {
audio.pause()
audio.currentTime = Math.max(0, audio.duration - 0.5)
return
}
this.next()
}
// ─── 定时关闭 ─────────────────────────────────────────────────────────
/** 剩余秒数:暂停期间以暂停起点为准,倒计时不会在暂停时溜走。 */
private timerLeftSeconds(): number {
if (this.timerDeadline === 0) return 0
const now = this.pausedAt > 0 ? this.pausedAt : Date.now()
return Math.max(0, Math.round((this.timerDeadline - now) / 1000))
}
private checkTimer(): void {
const s = useReaderAudioStore.getState()
if (!s.timerActive || this.timerDeadline === 0) return
const left = this.timerLeftSeconds()
if (left !== s.timerLeft) useReaderAudioStore.setState({ timerLeft: left })
// 暂停中不判到期:恢复播放时截止时间已经顺延过
if (left <= 0 && this.pausedAt === 0) {
this.timerDeadline = 0
this.stopTimerTicker()
useReaderAudioStore.setState({ timerLeft: 0, timerActive: false })
this.audio.pause()
toast('定时结束,已暂停播放')
}
}
private startTimerTicker(): void {
this.stopTimerTicker()
this.timerTicker = window.setInterval(() => this.checkTimer(), 1000)
}
private stopTimerTicker(): void {
if (this.timerTicker !== null) {
window.clearInterval(this.timerTicker)
this.timerTicker = null
}
}
// ─── Media Session(锁屏 / 通知栏 / 耳机按键) ────────────────────────
private session(): MediaSession | null {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return null
return navigator.mediaSession
}
private setSessionMetadata(track: string | null): void {
const session = this.session()
if (!session || typeof MediaMetadata === 'undefined') return
if (track === null) {
session.metadata = null
return
}
const { bookName, chapterTitle, cover } = useReaderAudioStore.getState()
const coverSrc = readerCoverSrc(cover)
const artwork: MediaImage[] = coverSrc
? [{ src: new URL(coverSrc, window.location.origin).href, sizes: '512x512' }]
: []
try {
session.metadata = new MediaMetadata({
title: chapterTitle || bookName || 'MeBox 听书',
artist: bookName || 'MeBox',
album: 'MeBox 听书',
artwork,
})
} catch {
// 个别浏览器对 artwork 类型挑剔,失败不影响播放
}
}
private setSessionPlaybackState(state: MediaSessionPlaybackState): void {
const session = this.session()
if (!session) return
try {
session.playbackState = state
} catch {
// 忽略
}
}
private updateSessionPosition(): void {
const session = this.session()
if (!session || typeof session.setPositionState !== 'function') return
const { dur } = useReaderAudioStore.getState()
const position = this.audio.currentTime
if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(position)) return
const rate = this.audio.playbackRate
try {
session.setPositionState({
duration: dur,
position: Math.min(Math.max(0, position), dur),
playbackRate: Number.isFinite(rate) && rate > 0 ? rate : 1,
})
} catch {
// position 越界等情况下浏览器会抛错,忽略
}
}
private bindMediaSession(): void {
const session = this.session()
if (!session) return
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
['play', () => this.play()],
['pause', () => this.pause()],
['stop', () => this.stop()],
['seekbackward', () => this.seekBy(-SEEK_STEP)],
['seekforward', () => this.seekBy(SEEK_STEP)],
['previoustrack', () => this.prev()],
['nexttrack', () => this.next()],
[
'seekto',
(details) => {
if (typeof details.seekTime === 'number') this.seekTo(details.seekTime)
},
],
]
for (const [action, handler] of handlers) {
try {
session.setActionHandler(action, handler)
} catch {
// 该动作在当前浏览器不支持时会抛错,逐个忽略
}
}
}
// ─── 工具 ─────────────────────────────────────────────────────────────
private neighborChapterIndex(dir: 1 | -1): number | null {
const { chapters, chapterIndex } = useReaderAudioStore.getState()
for (let i = chapterIndex + dir; i >= 0 && i < chapters.length; i += dir) {
if (!chapters[i].is_volume) return i
}
return null
}
private computeHasNext(chapters: ReaderChapter[], index: number): boolean {
for (let i = index + 1; i < chapters.length; i++) {
if (!chapters[i].is_volume) return true
}
return false
}
}
let engine: ReaderAudioEngine | null = null
/**
* 取全局听书引擎(首次调用时才构造,避免没听过书的应用也创建一个 <audio>)。
*/
export function getReaderAudioEngine(): ReaderAudioEngine {
if (!engine) engine = new ReaderAudioEngine()
return engine
}
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand'
import type { ReaderChapter } from '../api/reader'
// 听书全局播放状态(刻意不持久化)。
//
// 真正的播放器是模块级单例(见 pages/reader/readerAudioEngine.ts),React 组件只
// 读写这份状态、通过引擎的方法控制播放。这样音频不依附于任何路由:离开阅读页、
// 换到别的书、甚至回到影视首页,播放都继续,由 Media Session 提供锁屏/通知栏控制。
export type ReaderAudioStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error'
export interface ReaderAudioState {
/** 当前音轨所属书籍;空串表示没有加载任何音轨。 */
bookId: string
bookName: string
cover: string
chapterIndex: number
chapterTitle: string
chapters: ReaderChapter[]
/** 当前音轨地址(已签名,可直接交给 <audio> 或 hls.js)。 */
track: string
/** 该音轨由服务端转码,首次播放需要等待转码完成。 */
transcoding: boolean
openCredits: number
closeCredits: number
hasNext: boolean
status: ReaderAudioStatus
/** 加载/播放失败的可见原因(空串表示无错误)。 */
error: string
/** 当前播放位置(秒)。 */
cur: number
/** 总时长(秒),元数据未就绪时为 0。 */
dur: number
/** 播放倍速,与 readerSettings 的 audioSpeed 保持一致。 */
speed: number
timerActive: boolean
/** 定时剩余秒数,0 表示未开启。 */
timerLeft: number
timerPreset: number
}
const initialState: ReaderAudioState = {
bookId: '',
bookName: '',
cover: '',
chapterIndex: 0,
chapterTitle: '',
chapters: [],
track: '',
transcoding: false,
openCredits: 0,
closeCredits: 0,
hasNext: false,
status: 'idle',
error: '',
cur: 0,
dur: 0,
speed: 1,
timerActive: false,
timerLeft: 0,
timerPreset: 0,
}
export const useReaderAudioStore = create<ReaderAudioState>(() => ({ ...initialState }))
/** 引擎专用:整块重置回空闲态(停止播放时调用)。 */
export function resetReaderAudio(): void {
useReaderAudioStore.setState({ ...initialState })
}
+37 -2
View File
@@ -45,6 +45,30 @@ export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed'
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
/**
* 漫画图片的显示尺寸档位(上下滚动模式用)。
*
* legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),
* 桌面端看不到也放不大,所以补上这一组档位。屏幕越宽越该放大,属于设备级偏好,
* 和 comicDoublePage 一样只存本机、不参与账号同步。
*
* - `default` 现状:正文列封顶 900px 居中,图片铺满该列
* - `width` 适应宽度:正文列不再封顶,图片铺满窗口宽度
* - `height` 适应高度:每页高度贴合一屏(一屏一页),宽度按原始比例
* - `long` 适应长边:整页完整可见,宽高都不超出视口
* - `original` 原始像素 1:1:既不放大也不缩小,超出部分横向滚动
*/
export type ComicImageFit = 'default' | 'width' | 'height' | 'long' | 'original'
/** 漫画图片尺寸档位的展示顺序与标签(界面面板按此渲染)。 */
export const COMIC_IMAGE_FITS: { id: ComicImageFit; label: string }[] = [
{ id: 'default', label: '默认' },
{ id: 'width', label: '适应宽度' },
{ id: 'height', label: '适应高度' },
{ id: 'long', label: '适应长边' },
{ id: 'original', label: '原图' },
]
interface ReaderSettingsState {
// 首页模式切换(影视 / 阅读)
homeMode: ReaderHomeMode
@@ -81,6 +105,10 @@ interface ReaderSettingsState {
comicDoublePage: boolean
setComicDoublePage: (on: boolean) => void
/** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
comicImageFit: ComicImageFit
setComicImageFit: (fit: ComicImageFit) => void
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
/**
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
@@ -156,6 +184,12 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
comicDoublePage: true,
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
// 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
// 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
// 点「原图」。
comicImageFit: 'default',
setComicImageFit: (comicImageFit) => set({ comicImageFit }),
searchScopeIds: [],
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
@@ -185,8 +219,9 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好,
* 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。
* 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
* (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
* 取决于屏幕宽度和分辨率。
*/
export interface ReaderSettingsProfile {
theme_id: string
+55
View File
@@ -0,0 +1,55 @@
import { comicImageSizing, isSelfSizedComicFit } from './comicImageFit.ts'
import type { ComicImageFit } from '../stores/readerSettings'
// 漫画图片显示尺寸档位的纯逻辑回归测试(对齐 utils/coverUrl.test.ts 的写法)。
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`comicImageFit: ${name}`)
}
// ── 是否由图片自身约束尺寸 ──
const ALL_FITS: ComicImageFit[] = ['default', 'width', 'height', 'long', 'original']
check('default 不是自尺寸档位', !isSelfSizedComicFit('default'))
check('width 不是自尺寸档位', !isSelfSizedComicFit('width'))
check('height 是自尺寸档位', isSelfSizedComicFit('height'))
check('long 是自尺寸档位', isSelfSizedComicFit('long'))
check('original 是自尺寸档位', isSelfSizedComicFit('original'))
check(
'自尺寸档位与列宽档位互斥且覆盖全部取值',
ALL_FITS.every((fit) => isSelfSizedComicFit(fit) === (fit === 'height' || fit === 'long' || fit === 'original')),
)
// ── default / width:宽度交给外层列宽,图片只负责铺满 ──
for (const fit of ['default', 'width'] as ComicImageFit[]) {
const loaded = comicImageSizing(fit, 800, true)
check(`${fit} 加载完成时不留占位高度`, loaded.style.minHeight === undefined)
check(`${fit} 铺满外层列宽`, loaded.className === 'block w-full')
check(`${fit} 不自己定高`, loaded.style.height === undefined && loaded.style.maxHeight === undefined)
check(`${fit} 不改 maxWidth`, loaded.style.maxWidth === undefined)
check(`${fit} 加载中给占位高度`, comicImageSizing(fit, 800, false).style.minHeight === '10rem')
}
// ── height:一屏一页 ──
check('height 用滚动容器的实测高度', comicImageSizing('height', 592, true).style.height === '592px')
check('height 宽度按原始比例', comicImageSizing('height', 592, true).style.width === 'auto')
check('height 防超宽页横向溢出', comicImageSizing('height', 592, true).style.maxWidth === '100vw')
check('height 不额外设 maxHeight', comicImageSizing('height', 592, true).style.maxHeight === undefined)
check('height 类名不带 w-full(w-full 会顶掉高度约束)', comicImageSizing('height', 592, true).className === 'block')
// ── long:整页完整可见 ──
check('long 用容器高度封顶', comicImageSizing('long', 592, true).style.maxHeight === '592px')
check('long 不写死高度', comicImageSizing('long', 592, true).style.height === undefined)
check('long 同时约束宽度', comicImageSizing('long', 592, true).style.maxWidth === '100vw')
check('long 加载中给占位高度', comicImageSizing('long', 592, false).style.minHeight === '10rem')
// ── original:原始像素 1:1 ──
check('original 不限制宽度', comicImageSizing('original', 592, true).style.maxWidth === 'none')
check('original 不限制高度', comicImageSizing('original', 592, true).style.maxHeight === undefined)
check('original 按原始像素渲染', comicImageSizing('original', 592, true).style.width === 'auto')
// ── 视口高度还没量出来时退回视口单位,首帧不塌 ──
for (const fit of ['height', 'long'] as ComicImageFit[]) {
check(`${fit} 未量到高度时退回 100dvh`, comicImageSizing(fit, 0, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
check(`${fit} 高度为负同样退回 100dvh`, comicImageSizing(fit, -10, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
}
check('default 与视口高度无关', comicImageSizing('default', 0, true).style.height === undefined)
+59
View File
@@ -0,0 +1,59 @@
import type { CSSProperties } from 'react'
import type { ComicImageFit } from '../stores/readerSettings'
// 漫画图片的显示尺寸:把「档位」翻译成 <img> 的 class/style(纯逻辑,便于单测)。
//
// 只有上下滚动模式用得到:翻页模式是整页缩放进视口(object-contain),本来就没有
// 「太大/太小」的问题,一个档位反而会和双页铺开的排版打架。
//
// 两种控制方式:
// - default / width 由外层列宽决定(列封顶 900px 或铺满窗口),图片本身 w-full;
// - height / long / original 由图片自身约束,所以外层必须放开列宽上限
// (见 ReaderViewPage 的 comicFullWidth),否则 900px 的列会把它们再压回去。
/** 图片还没加载出来时的占位高度,避免懒加载期间整屏塌成 0 高。 */
const PLACEHOLDER_MIN_HEIGHT = '10rem'
export interface ComicImageSizing {
className: string
style: CSSProperties
}
/** 该档位是否由 <img> 自身约束尺寸(决定外层列宽要不要放开)。 */
export function isSelfSizedComicFit(fit: ComicImageFit): boolean {
return fit === 'height' || fit === 'long' || fit === 'original'
}
/**
* 滚动模式下漫画图片的尺寸样式。
*
* @param fit 显示尺寸档位。
* @param viewportHeight 滚动容器的高度(px)。height/long 按它换算;还没量出来
* (0)时退回 `100dvh`,首帧不会因为 0 而塌掉。
* @param loaded 图片是否已加载完成,未完成时给个占位高度。
*/
export function comicImageSizing(
fit: ComicImageFit,
viewportHeight: number,
loaded: boolean,
): ComicImageSizing {
const vh = viewportHeight > 0 ? `${viewportHeight}px` : '100dvh'
// height 档位自带高度,占位高度没有意义也不冲突,统一先铺再被档位覆盖。
const placeholder: CSSProperties = loaded ? {} : { minHeight: PLACEHOLDER_MIN_HEIGHT }
switch (fit) {
case 'height':
// 一屏一页:高度贴合视口,宽度按原始比例(maxWidth 防止超宽页横向溢出)
return { className: 'block', style: { ...placeholder, height: vh, width: 'auto', maxWidth: '100vw' } }
case 'long':
// 整页完整可见:长边贴合视口,宽高都不超出
return { className: 'block', style: { ...placeholder, maxHeight: vh, maxWidth: '100vw', width: 'auto' } }
case 'original':
// 原始像素 1:1:不放大也不缩小;比窗口宽时由滚动容器横向滚动
return { className: 'block', style: { ...placeholder, width: 'auto', maxWidth: 'none' } }
default:
// default / width:宽度交给外层列(900px 封顶或铺满窗口),这里只管铺满
return { className: 'block w-full', style: placeholder }
}
}