mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a3248f4bb | |||
| 0be7f8b97e |
@@ -41,10 +41,10 @@ export const MediaCard = memo(function MediaCard({
|
||||
const card = (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
<div className="relative aspect-[2/3] w-full shrink-0 overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<>
|
||||
{posterFit === 'contain' && (
|
||||
@@ -120,26 +120,34 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||||
compact ? 'p-2' : 'p-4'
|
||||
compact ? 'p-2' : 'p-2.5 sm:p-4'
|
||||
}`}>
|
||||
<p className={`truncate font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
{/* 手机端一张卡只有 ~100px 宽:标题放开到两行,并预留两行高度,
|
||||
这样同一行的卡片不会因为标题行数不同而参差不齐。 */}
|
||||
<p className={`font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact
|
||||
? 'truncate text-xs'
|
||||
: 'line-clamp-2 min-h-[2.75em] text-[13px] leading-snug sm:line-clamp-none sm:min-h-0 sm:truncate sm:text-sm sm:leading-normal'
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||||
<div className="flex min-h-[21px] items-center text-[10px] font-bold uppercase text-[var(--app-muted)] sm:min-h-0 sm:gap-x-2 sm:text-[11px] sm:tracking-wider">
|
||||
<span className="truncate">{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-0.5 sm:gap-1.5">
|
||||
{/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常
|
||||
自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */}
|
||||
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1 py-0.5 text-[var(--app-subtle)] sm:gap-1 sm:px-1.5">
|
||||
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||||
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{/* 编码格式在小卡片上没有信息价值,手机上直接隐藏,避免挤掉集数徽标。 */}
|
||||
{media.video_codec && (
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="hidden whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)] sm:inline-block">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
@@ -151,8 +159,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="group relative block w-full">
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
<div className="group relative block h-full w-full">
|
||||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actionContent && (
|
||||
@@ -166,8 +174,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} state={linkState} className="block">
|
||||
<div className="group relative block h-full">
|
||||
<Link to={href} state={linkState} className="block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
@@ -178,7 +186,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} state={linkState} className="group block">
|
||||
<Link to={href} state={linkState} className="group block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Virtuoso } from 'react-virtuoso'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
|
||||
// 手机上单卡只有约 100px 宽,间距收到 gap-3 给标题和徽标多留一点宽度。
|
||||
export const MEDIA_GRID_CLASS =
|
||||
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
'grid grid-cols-3 gap-3 sm:gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
|
||||
function getFallbackColumns(width: number): number {
|
||||
if (width >= 1536) return 8
|
||||
@@ -105,7 +106,7 @@ export function VirtualMediaGrid({
|
||||
itemContent={(rowIndex) => {
|
||||
const start = rowIndex * columns
|
||||
return (
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-3 sm:pb-4')}>
|
||||
{Array.from({ length: columns }, (_, colIndex) => {
|
||||
const itemIndex = start + colIndex
|
||||
if (itemIndex >= totalCount) {
|
||||
|
||||
@@ -64,6 +64,14 @@ class ReaderAudioEngine {
|
||||
private detaching = false
|
||||
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
|
||||
private loadToken = 0
|
||||
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
|
||||
private prefetched = new Map<number, { track: string; transcoding: boolean }>()
|
||||
/** 预取在途的章序号,避免同一章重复请求。 */
|
||||
private prefetching = new Set<number>()
|
||||
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
|
||||
private warmer: HTMLAudioElement | null = null
|
||||
/** warmer 当前装载的音轨,避免重复赋值。 */
|
||||
private warmedTrack = ''
|
||||
|
||||
constructor() {
|
||||
const audio = new Audio()
|
||||
@@ -93,7 +101,13 @@ class ReaderAudioEngine {
|
||||
}
|
||||
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
|
||||
this.commitProgress()
|
||||
// 换书:预取的音轨属于上一本,全部作废
|
||||
if (s.bookId !== input.bookId) this.clearPrefetch()
|
||||
this.applyChapter(input)
|
||||
}
|
||||
|
||||
/** 真正把一章装上播放器(同步部分):预取命中的续播也走这里。 */
|
||||
private applyChapter(input: LoadChapterInput): void {
|
||||
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
|
||||
useReaderAudioStore.setState({
|
||||
bookId: input.bookId,
|
||||
@@ -128,6 +142,9 @@ class ReaderAudioEngine {
|
||||
}
|
||||
|
||||
void this.attachSource(input.track)
|
||||
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex !== null) this.prefetchChapter(nextIndex)
|
||||
}
|
||||
|
||||
play(): void {
|
||||
@@ -222,6 +239,7 @@ class ReaderAudioEngine {
|
||||
stop(): void {
|
||||
this.commitProgress()
|
||||
this.detachSource()
|
||||
this.clearPrefetch()
|
||||
this.timerDeadline = 0
|
||||
this.pausedAt = 0
|
||||
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
|
||||
@@ -242,12 +260,22 @@ class ReaderAudioEngine {
|
||||
|
||||
private async attachSource(src: string): Promise<void> {
|
||||
const token = ++this.loadToken
|
||||
this.detachSource()
|
||||
this.setSessionMetadata(src)
|
||||
// 只销毁上一章的 hls,不再 removeAttribute('src') + load()。load() 会把媒体
|
||||
// 元素彻底卸载、释放系统音频会话,息屏时紧接着的 play() 就会被浏览器当成
|
||||
// 「无用户手势」拦下——表现正是切章卡住、解锁后必须手动点播放。直接覆盖 src,
|
||||
// 元素始终持有媒体,会话才接得上。
|
||||
this.hls?.destroy()
|
||||
this.hls = null
|
||||
if (!src) {
|
||||
this.audio.removeAttribute('src')
|
||||
this.audio.load()
|
||||
this.setSessionMetadata(null)
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
|
||||
if (!this.audio.paused) this.audio.pause()
|
||||
this.setSessionMetadata(src)
|
||||
if (src.includes('.m3u8')) {
|
||||
const mod = await import('hls.js')
|
||||
// 动态 import 期间可能已经切到别的章:丢弃这次结果
|
||||
@@ -270,6 +298,7 @@ class ReaderAudioEngine {
|
||||
this.audio.src = src
|
||||
}
|
||||
|
||||
/** 彻底卸载音源(停止播放时用)。换章不走这里,见 attachSource 的说明。 */
|
||||
private detachSource(): void {
|
||||
this.detaching = true
|
||||
this.hls?.destroy()
|
||||
@@ -279,9 +308,117 @@ class ReaderAudioEngine {
|
||||
this.detaching = false
|
||||
}
|
||||
|
||||
// ─── 预取与续播 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 提前解析某章的音轨并预热字节。息屏时 ended 与 play() 之间不能有网络空档,
|
||||
* 否则系统收走音频会话、标签页被冻结,浏览器就拒绝自动播放了。
|
||||
*/
|
||||
private prefetchChapter(index: number): void {
|
||||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||||
if (this.prefetched.has(index) || this.prefetching.has(index)) return
|
||||
// 反复跳章时旧的预取会变成垃圾,做个上限兜底
|
||||
if (this.prefetched.size > 32) this.prefetched.clear()
|
||||
this.prefetching.add(index)
|
||||
const forBook = bookId
|
||||
readerAPI
|
||||
.bookContent(bookId, index)
|
||||
.then((ct) => {
|
||||
// 期间换了书:结果作废
|
||||
if (useReaderAudioStore.getState().bookId !== forBook) return
|
||||
const track = ct.tracks?.[0]
|
||||
if (ct.type !== 'audio' || !track) return
|
||||
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false })
|
||||
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
|
||||
// 避免用户中途停下时白下整章的流量。
|
||||
if (ct.transcoding) this.warmNextBytes(track)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => this.prefetching.delete(index))
|
||||
}
|
||||
|
||||
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
|
||||
private warmNextBytes(track: string): void {
|
||||
// HLS 交给 hls.js 按需拉流,二次下载没有意义
|
||||
if (!track || track.includes('.m3u8') || this.warmedTrack === track) return
|
||||
this.warmedTrack = track
|
||||
if (!this.warmer) {
|
||||
const a = new Audio()
|
||||
a.preload = 'auto'
|
||||
this.warmer = a
|
||||
}
|
||||
this.warmer.src = track
|
||||
}
|
||||
|
||||
/** 本章快播完时把下一章的字节预热起来(30 秒足够开头缓冲)。 */
|
||||
private maybeWarmNext(): void {
|
||||
const audio = this.audio
|
||||
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return
|
||||
if (audio.duration - audio.currentTime > 30) return
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex === null) return
|
||||
const hit = this.prefetched.get(nextIndex)
|
||||
if (hit) this.warmNextBytes(hit.track)
|
||||
}
|
||||
|
||||
/** 用预取结果接续下一章;命中返回 true(未命中由调用方走异步加载兜底)。 */
|
||||
private playPrefetched(index: number): boolean {
|
||||
const hit = this.prefetched.get(index)
|
||||
if (!hit) return false
|
||||
this.prefetched.delete(index)
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.applyChapter({
|
||||
bookId: s.bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters: s.chapters,
|
||||
chapterIndex: index,
|
||||
track: hit.track,
|
||||
transcoding: hit.transcoding,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos: 0,
|
||||
})
|
||||
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
|
||||
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
|
||||
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
|
||||
if (!hit.track.includes('.m3u8')) this.play()
|
||||
return true
|
||||
}
|
||||
|
||||
private clearPrefetch(): void {
|
||||
this.prefetched.clear()
|
||||
this.prefetching.clear()
|
||||
this.warmedTrack = ''
|
||||
if (this.warmer) {
|
||||
this.warmer.removeAttribute('src')
|
||||
this.warmer.load()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 命中预取:直接装,不等网络(片尾自动续播大多走这里)
|
||||
const hit = this.prefetched.get(index)
|
||||
if (hit) {
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.loadChapter({
|
||||
bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters,
|
||||
chapterIndex: index,
|
||||
track: hit.track,
|
||||
transcoding: hit.transcoding,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos,
|
||||
})
|
||||
return
|
||||
}
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
try {
|
||||
const ct = await readerAPI.bookContent(bookId, index)
|
||||
@@ -344,6 +481,10 @@ class ReaderAudioEngine {
|
||||
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
|
||||
this.commitProgress()
|
||||
if (useReaderAudioStore.getState().hasNext) {
|
||||
// 预取命中:在 ended 的同一次事件里同步接上下一章。息屏时这段「零空档」
|
||||
// 很关键——空太久系统会收走音频会话、冻结标签页,浏览器随后拒绝自动播放。
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex !== null && this.playPrefetched(nextIndex)) return
|
||||
this.next()
|
||||
return
|
||||
}
|
||||
@@ -389,6 +530,7 @@ class ReaderAudioEngine {
|
||||
this.checkCreditsEnd()
|
||||
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
|
||||
this.checkTimer()
|
||||
this.maybeWarmNext()
|
||||
})
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
|
||||
Vendored
+6
@@ -1,6 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
future: {
|
||||
// hover 样式只对真正能悬停的设备(鼠标/触控板)生效。触屏点击会让浏览器
|
||||
// 保留 :hover 状态,导致海报墙上的卡片被放大、上移、盖上「立即观影」遮罩,
|
||||
// 看起来像是整行错位、海报显示不全。
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user