mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -10,6 +10,18 @@ export const historyAPI = {
|
||||
.get<HistoryItem[]>('/watch-history', { params: { limit } })
|
||||
.then((r) => r.data),
|
||||
|
||||
/**
|
||||
* 只要历史行、不要内嵌的 media。
|
||||
*
|
||||
* 媒体详情页解析「继续播放」只需要 media_id/position_ms;带上 media 会让
|
||||
* 300 条响应大一个数量级,而且服务端还得为每条去查 media(挂载条目逐条
|
||||
* 回源)。服务端已经支持 include_media=0 跳过 hydrate。
|
||||
*/
|
||||
listRows: (limit = 300) =>
|
||||
api
|
||||
.get<HistoryItem[]>('/watch-history', { params: { limit, include_media: false } })
|
||||
.then((r) => r.data),
|
||||
|
||||
stats: () => api.get<HistoryStats>('/watch-history/stats').then((r) => r.data),
|
||||
|
||||
continueWatching: (limit = 10) =>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
||||
import { useLibraryData } from './useLibraryData'
|
||||
import { useLibraryScanStatus } from './useLibraryScanStatus'
|
||||
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
|
||||
import { useSeriesEpisodeResume } from './useSeriesEpisodeResume'
|
||||
import { useLibraryAdminActions } from './useLibraryAdminActions'
|
||||
import { usePermission } from '../hooks/usePermission'
|
||||
import { useFavourites } from '../hooks/useFavourites'
|
||||
@@ -214,6 +215,13 @@ export function LibraryPage() {
|
||||
setSelectedSeason,
|
||||
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
||||
})
|
||||
// 抽屉里的「继续播放」:在整剧所有集里挑最近看过的一条(代表条目可能是 OVA)。
|
||||
const { resume: seriesResume, progressById: seriesEpisodeProgress } = useSeriesEpisodeResume(
|
||||
selectedSeries?.key ?? '',
|
||||
selectedSeries?.rep ?? null,
|
||||
selectedSeriesAllEpisodes,
|
||||
)
|
||||
|
||||
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
|
||||
?? selectedSeries?.rep
|
||||
?? null
|
||||
@@ -350,6 +358,8 @@ export function LibraryPage() {
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
allEpisodes={selectedSeriesEpisodes}
|
||||
resume={seriesResume}
|
||||
episodeProgress={seriesEpisodeProgress}
|
||||
loadingEpisodes={loadingSeriesEpisodes}
|
||||
playbackFrom={`${location.pathname}${location.search}`}
|
||||
isAdmin={role === 'admin'}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
allEpisodes: Media[]
|
||||
resume: MediaDetailResume | null
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
canFavorite: boolean
|
||||
@@ -32,6 +34,7 @@ type LibrarySeriesDetailHeaderProps = {
|
||||
export function LibrarySeriesDetailHeader({
|
||||
series,
|
||||
allEpisodes,
|
||||
resume,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
canFavorite,
|
||||
@@ -51,6 +54,9 @@ export function LibrarySeriesDetailHeader({
|
||||
const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
|
||||
const theatricalCount = allEpisodes.length - tvEpisodes.length
|
||||
const firstEpisode = firstPlayableEpisode(tvEpisodes)
|
||||
// 看过的剧先给「继续播放 · 第N集 · mm:ss」,指向真正看的那一集;
|
||||
// 没有可用进度时保持原来的「从第一集开始播放」。
|
||||
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,14 +96,32 @@ export function LibrarySeriesDetailHeader({
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{firstEpisode && (
|
||||
{resumeTarget ? (
|
||||
<>
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Link to={`/play/${resumeTarget.mediaId}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
{continueButtonLabel(resumeTarget)}
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
<Link
|
||||
to={`/play/${resumeTarget.mediaId}?restart=1`}
|
||||
state={{ from: playbackFrom }}
|
||||
className="btn-outline inline-flex"
|
||||
title="忽略续播进度,从 0 开始播放"
|
||||
>
|
||||
从头播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={resumeTarget.mediaId} label="外部播放器播放" />
|
||||
</>
|
||||
) : (
|
||||
firstEpisode && (
|
||||
<>
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{canFavorite && (
|
||||
<MediaFavouriteButton
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import type { EpisodeProgress, MediaDetailResume } from './mediaDetailResumeModel'
|
||||
import { LibrarySeriesDetailHeader } from './LibrarySeriesDetailHeader'
|
||||
import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes'
|
||||
|
||||
@@ -16,6 +17,8 @@ type LibrarySeriesDetailSectionProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
allEpisodes: Media[]
|
||||
resume: MediaDetailResume | null
|
||||
episodeProgress: Map<string, EpisodeProgress>
|
||||
loadingEpisodes: boolean
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
@@ -42,6 +45,8 @@ export function LibrarySeriesDetailSection({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
allEpisodes,
|
||||
resume,
|
||||
episodeProgress,
|
||||
loadingEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
@@ -73,6 +78,7 @@ export function LibrarySeriesDetailSection({
|
||||
<LibrarySeriesDetailHeader
|
||||
series={selectedSeries}
|
||||
allEpisodes={allEpisodes}
|
||||
resume={resume}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
canFavorite={canFavorite}
|
||||
@@ -95,6 +101,7 @@ export function LibrarySeriesDetailSection({
|
||||
selectedEpisodes={selectedEpisodes}
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
progressById={episodeProgress}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
@@ -111,6 +118,7 @@ type LibrarySeriesEpisodesPanelProps = {
|
||||
selectedEpisodes: SeasonEpisodes[]
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
progressById: Map<string, EpisodeProgress>
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
@@ -122,6 +130,7 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedEpisodes,
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
progressById,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
onSeasonChange,
|
||||
@@ -134,6 +143,7 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedEpisodes={selectedEpisodes}
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
progressById={progressById}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
import {
|
||||
formatResumeClock,
|
||||
progressPercent,
|
||||
type EpisodeProgress,
|
||||
} from './mediaDetailResumeModel'
|
||||
|
||||
type SeasonGroup = {
|
||||
season: number
|
||||
@@ -24,6 +29,8 @@ type LibrarySeriesEpisodesProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
/** 每集(按条目 id)的续播进度,用于卡片上的进度条。 */
|
||||
progressById?: Map<string, EpisodeProgress>
|
||||
isAdmin?: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrape?: (media: Media) => void
|
||||
@@ -35,6 +42,7 @@ export function LibrarySeriesEpisodes({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
progressById,
|
||||
isAdmin = false,
|
||||
onSeasonChange,
|
||||
onManualScrape,
|
||||
@@ -76,6 +84,7 @@ export function LibrarySeriesEpisodes({
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
const versionCount = ep.versions?.length ?? 1
|
||||
const progress = progressById?.get(ep.id)
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
@@ -117,6 +126,21 @@ export function LibrarySeriesEpisodes({
|
||||
: formatSize(ep.size_bytes)}
|
||||
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
|
||||
</p>
|
||||
{progress && (
|
||||
<div className="mt-1 flex items-center gap-1.5" title={progressTitle(progress)}>
|
||||
<div className="h-1 w-14 overflow-hidden rounded-full bg-sand-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500"
|
||||
style={{ width: `${progressPercent(progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-brand-600 whitespace-nowrap">
|
||||
{progress.completed
|
||||
? '已看完'
|
||||
: `看到 ${formatResumeClock(progress.positionMs)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -142,6 +166,13 @@ export function LibrarySeriesEpisodes({
|
||||
)
|
||||
}
|
||||
|
||||
function progressTitle(progress: EpisodeProgress): string {
|
||||
if (progress.completed) return '已看完'
|
||||
const position = formatResumeClock(progress.positionMs)
|
||||
const duration = formatResumeClock(progress.durationMs)
|
||||
return duration ? `看到 ${position} / ${duration}` : `看到 ${position}`
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
let mainTitle = ''
|
||||
const epTitle = ep.episode_title?.trim()
|
||||
|
||||
@@ -33,6 +33,7 @@ export function MediaDetailPage() {
|
||||
isAdmin={role === 'admin'}
|
||||
favourite={detail.favourite}
|
||||
playTargetId={media.id || detail.firstPlayableEpisode?.id}
|
||||
resume={detail.resume}
|
||||
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
|
||||
onToggleFavourite={detail.toggleFavourite}
|
||||
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
|
||||
@@ -53,6 +54,7 @@ export function MediaDetailPage() {
|
||||
selectedEpisodes={detail.seasonGroups}
|
||||
selectedSeason={detail.selectedSeason}
|
||||
visibleEpisodes={detail.visibleEpisodes}
|
||||
progressById={detail.episodeProgress}
|
||||
playbackFrom={`/media/${media.id}`}
|
||||
onSeasonChange={detail.setSelectedSeason}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
|
||||
import type { Media } from '../types'
|
||||
import { isDirectStreamMedia } from './playerPageModel'
|
||||
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
|
||||
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
|
||||
import { MediaDetailPoster } from './MediaDetailArtwork'
|
||||
import { MediaDetailMetadata } from './MediaDetailMetadata'
|
||||
@@ -20,6 +21,7 @@ interface MediaDetailPlaybackActionsProps {
|
||||
favourite: boolean
|
||||
onToggleFavourite: () => void
|
||||
playTargetId?: string
|
||||
resume?: MediaDetailResume | null
|
||||
}
|
||||
|
||||
interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps {
|
||||
@@ -96,8 +98,12 @@ export function MediaDetailPlaybackActions({
|
||||
favourite,
|
||||
onToggleFavourite,
|
||||
playTargetId,
|
||||
resume,
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = media.id || playTargetId || ''
|
||||
// 代表条目可能是 OVA/特别篇:有续播记录时优先打开历史命中的那一集(或那集的
|
||||
// 其它版本),并直接把「看到第几秒」写在按钮上。播放器仍会自己读 /resume。
|
||||
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
|
||||
const targetId = resumeTarget?.mediaId || media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Link
|
||||
@@ -106,9 +112,20 @@ export function MediaDetailPlaybackActions({
|
||||
className="btn-primary h-11 px-6 shadow-sm"
|
||||
>
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
<span>{resumeTarget ? continueButtonLabel(resumeTarget) : '立即播放'}</span>
|
||||
</Link>
|
||||
|
||||
{resumeTarget && (
|
||||
<Link
|
||||
to={`/play/${resumeTarget.mediaId}?restart=1`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-ghost h-11 px-4 text-sm"
|
||||
title="忽略续播进度,从 0 开始播放"
|
||||
>
|
||||
<span>从头播放</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isDirectStreamMedia(media) && (
|
||||
<Link
|
||||
to={`/play/${targetId}?mode=hls`}
|
||||
@@ -143,6 +160,7 @@ export function MediaDetailMainContent({
|
||||
isAdmin,
|
||||
favourite,
|
||||
playTargetId,
|
||||
resume,
|
||||
scrapeEpisodeArtwork,
|
||||
onToggleFavourite,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
@@ -167,6 +185,7 @@ export function MediaDetailMainContent({
|
||||
favourite={favourite}
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
playTargetId={playTargetId}
|
||||
resume={resume}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} />
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
//
|
||||
// ?mode=hls force HLS even when direct play would work
|
||||
// ?mode=direct force direct play (default for browser-friendly codecs)
|
||||
// ?restart=1 忽略服务端续播进度,从 0 开始播(详情页的「从头播放」入口)
|
||||
//
|
||||
// ?mode= 只在进入某个媒体时读取一次(媒体详情页的「HLS 兼容转码播放」入口依赖它);
|
||||
// 播放模式不再写回 URL——写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
|
||||
@@ -102,6 +103,17 @@ function formatMarkTime(totalSec: number): string {
|
||||
const CLOUD_HLS_RELOAD_MAX = 3
|
||||
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 逐集切换时保留 ?mode=(HLS/直连是整段会话的选择),但丢掉 ?restart=1:
|
||||
* 「从头播放」只对用户点的那一集生效,下一集仍按自己的续播进度起播。
|
||||
*/
|
||||
function searchWithoutRestart(search: string): string {
|
||||
const params = new URLSearchParams(search)
|
||||
params.delete('restart')
|
||||
const next = params.toString()
|
||||
return next ? `?${next}` : ''
|
||||
}
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
@@ -1590,6 +1602,8 @@ export function PlayerPage() {
|
||||
if (!id) return
|
||||
setResumePosition(0)
|
||||
setInitialSeekDone(false)
|
||||
// 详情页的「从头播放」显式要求忽略续播:不再去读 /resume,直接从 0 起播。
|
||||
if (new URLSearchParams(window.location.search).get('restart') === '1') return
|
||||
const controller = new AbortController()
|
||||
playbackAPI
|
||||
.getResume(id, controller.signal)
|
||||
@@ -1855,7 +1869,7 @@ export function PlayerPage() {
|
||||
const target = autoPlayEpisodeFor(media, id, playlistEpisodes)
|
||||
if (!target) return
|
||||
navigate(
|
||||
{ pathname: `/play/${target.id}`, search: location.search },
|
||||
{ pathname: `/play/${target.id}`, search: searchWithoutRestart(location.search) },
|
||||
{ replace: true, state: location.state },
|
||||
)
|
||||
}, [media, id, playlistEpisodes, navigate, location.search, location.state])
|
||||
@@ -1937,7 +1951,9 @@ export function PlayerPage() {
|
||||
navigate(
|
||||
{
|
||||
pathname: `/play/${target.id}`,
|
||||
search: location.search,
|
||||
// 逐集切换保留 ?mode=,但丢掉 ?restart=1:「从头播放」只对用户点的那一集
|
||||
// 生效,下一集仍按各自的续播进度起播。
|
||||
search: searchWithoutRestart(location.search),
|
||||
},
|
||||
{ state: location.state },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
canContinuePlaying,
|
||||
continueButtonLabel,
|
||||
episodeProgressById,
|
||||
formatResumeClock,
|
||||
progressPercent,
|
||||
resolveMediaDetailResume,
|
||||
} from './mediaDetailResumeModel.ts'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
|
||||
function check(name: string, cond: boolean) {
|
||||
if (!cond) throw new Error(`mediaDetailResumeModel: ${name}`)
|
||||
}
|
||||
|
||||
function media(partial: Partial<Media> & { id: string }): Media {
|
||||
return {
|
||||
library_id: 'lib-1',
|
||||
title: '标题',
|
||||
path: `/media/${partial.id}.mkv`,
|
||||
size_bytes: 0,
|
||||
duration_sec: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
rating: 0,
|
||||
year: 2024,
|
||||
season_num: 0,
|
||||
episode_num: 0,
|
||||
scrape_status: 'matched',
|
||||
tmdb_id: 0,
|
||||
bangumi_id: 0,
|
||||
nsfw: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...partial,
|
||||
} as Media
|
||||
}
|
||||
|
||||
function historyRow(
|
||||
mediaID: string,
|
||||
positionMs: number,
|
||||
watchedAt: string,
|
||||
extra: Partial<HistoryItem> = {},
|
||||
): HistoryItem {
|
||||
return {
|
||||
id: `h-${mediaID}`,
|
||||
user_id: 'u1',
|
||||
media_id: mediaID,
|
||||
position_ms: positionMs,
|
||||
duration_ms: 1_440_000,
|
||||
watched_at: watchedAt,
|
||||
completed: false,
|
||||
...extra,
|
||||
} as HistoryItem
|
||||
}
|
||||
|
||||
// ── 电影:直接用本条的进度 ────────────────────────────────────────────────
|
||||
const movie = media({ id: 'movie-1' })
|
||||
const movieResume = resolveMediaDetailResume(movie, [], [historyRow('movie-1', 754_000, '2024-05-01T10:00:00Z')])
|
||||
check('movie resume found', movieResume?.mediaId === 'movie-1')
|
||||
check('movie label', continueButtonLabel(movieResume!) === '继续播放 · 12:34')
|
||||
check('movie can continue', canContinuePlaying(movieResume))
|
||||
|
||||
// ── 剧集:代表条目是 OVA,续播要指向真正看过的那一集 ──────────────────────
|
||||
const ova = media({ id: 'ova', episode_num: 0, title: '特别篇' })
|
||||
const ep1 = media({ id: 'ep1', season_num: 1, episode_num: 1 })
|
||||
const ep2 = media({ id: 'ep2', season_num: 1, episode_num: 2 })
|
||||
const ep3 = media({ id: 'ep3', season_num: 1, episode_num: 3 })
|
||||
const episodes = [ep1, ep2, ep3]
|
||||
const seriesHistory = [
|
||||
historyRow('ep1', 600_000, '2024-05-01T09:00:00Z', { completed: true }),
|
||||
historyRow('ep2', 300_000, '2024-05-02T09:00:00Z'),
|
||||
historyRow('ep3', 0, '2024-05-03T09:00:00Z'),
|
||||
historyRow('other-show', 900_000, '2024-05-04T09:00:00Z'),
|
||||
]
|
||||
const seriesResume = resolveMediaDetailResume(ova, episodes, seriesHistory)
|
||||
check('series picks newest watched episode', seriesResume?.mediaId === 'ep2')
|
||||
check('series label', continueButtonLabel(seriesResume!) === '继续播放 · 第 2 集 · 5:00')
|
||||
|
||||
// 命中的是某一集的版本时,打开版本、但集号仍按它属于哪一集显示。
|
||||
const ep2Uhd = media({ id: 'ep2-uhd', season_num: 1, episode_num: 2 })
|
||||
const versionedEpisodes = [ep1, { ...ep2, versions: [ep2Uhd] }, ep3]
|
||||
const versionResume = resolveMediaDetailResume(ova, versionedEpisodes, [
|
||||
historyRow('ep2-uhd', 120_000, '2024-05-02T09:00:00Z'),
|
||||
])
|
||||
check('version resume target', versionResume?.mediaId === 'ep2-uhd')
|
||||
check('version resume label uses owning episode', versionResume?.media.episode_num === 2)
|
||||
|
||||
// ── 不值得续播的情况 ──────────────────────────────────────────────────────
|
||||
check(
|
||||
'near-start is not a resume',
|
||||
!canContinuePlaying(resolveMediaDetailResume(movie, [], [historyRow('movie-1', 1_500, '2024-05-01T10:00:00Z')])),
|
||||
)
|
||||
check(
|
||||
'completed is not a resume',
|
||||
!canContinuePlaying(
|
||||
resolveMediaDetailResume(movie, [], [
|
||||
historyRow('movie-1', 1_400_000, '2024-05-01T10:00:00Z', { completed: true }),
|
||||
]),
|
||||
),
|
||||
)
|
||||
check('no history', resolveMediaDetailResume(movie, [], []) === null)
|
||||
check(
|
||||
'unrelated history is ignored',
|
||||
resolveMediaDetailResume(movie, [], [historyRow('other', 900_000, '2024-05-01T10:00:00Z')]) === null,
|
||||
)
|
||||
|
||||
// ── 时间格式 ──────────────────────────────────────────────────────────────
|
||||
check('clock minutes', formatResumeClock(754_000) === '12:34')
|
||||
check('clock hours', formatResumeClock(3_723_000) === '1:02:03')
|
||||
check('clock invalid', formatResumeClock(0) === '' && formatResumeClock(Number.NaN) === '')
|
||||
|
||||
// ── 每集进度(剧集卡片上的进度条) ────────────────────────────────────────
|
||||
const progress = episodeProgressById(versionedEpisodes, [
|
||||
historyRow('ep1', 700_000, '2024-05-01T09:00:00Z', { completed: true }),
|
||||
historyRow('ep2', 200_000, '2024-05-01T09:10:00Z'),
|
||||
historyRow('ep2-uhd', 400_000, '2024-05-01T09:20:00Z'),
|
||||
])
|
||||
check('episode progress skips untouched episodes', !progress.has('ep3'))
|
||||
check('episode progress prefers newest version row', progress.get('ep2')?.positionMs === 400_000)
|
||||
check('completed episode is full', progressPercent(progress.get('ep1')!) === 100)
|
||||
check('partial episode is capped', progressPercent({ positionMs: 1_430_000, durationMs: 1_440_000, completed: false }) === 99)
|
||||
check('unknown duration still visible', progressPercent({ positionMs: 10_000, durationMs: 0, completed: false }) === 5)
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import { formatEpisodeLabel } from '../utils/episodeNumber.ts'
|
||||
|
||||
/**
|
||||
* 媒体详情页的「继续播放」解析。
|
||||
*
|
||||
* 服务端存的是「(user, media) → 最近位置」的续播行。详情页要回答两个问题:
|
||||
* 1. 这部剧最新看到哪一集?(代表条目可能是 OVA/特别篇,不能直接用它自己的进度)
|
||||
* 2. 那一集看到第几秒?
|
||||
*
|
||||
* 因此这里拿到的是「整库最近 N 条历史」,在「本条目 + 全部剧集(含各自的版本)」
|
||||
* 这个集合里挑 watched_at 最新的一条。历史请求用 include_media=false,服务端
|
||||
* 不需要 join media,也不会为挂载条目逐条回源。
|
||||
*/
|
||||
|
||||
/** 播放器只在「>2 秒且未看完」时才真正续播,详情页用同一阈值决定按钮文案。 */
|
||||
export const RESUME_MIN_POSITION_MS = 2000
|
||||
|
||||
/** 详情页续播扫描的条数(服务端上限 500)。 */
|
||||
export const RESUME_SCAN_LIMIT = 300
|
||||
|
||||
export interface MediaDetailResume {
|
||||
/** 实际要打开的媒体 id:命中的那条历史,可能是某集的某个版本。 */
|
||||
mediaId: string
|
||||
/** 用来显示集号/标题的条目(命中的那集;电影就是本体)。 */
|
||||
media: Media
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface EpisodeProgress {
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
function versionIDs(media: Media | null | undefined): string[] {
|
||||
return (media?.versions ?? []).map((v) => v.id).filter(Boolean)
|
||||
}
|
||||
|
||||
/** 「本条 + 各集 + 各集版本」的 id 集合:续播候选范围。 */
|
||||
export function mediaDetailScopeIDs(media: Media | null, episodes: Media[]): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const add = (item: Media | null | undefined) => {
|
||||
if (!item?.id) return
|
||||
ids.add(item.id)
|
||||
for (const id of versionIDs(item)) ids.add(id)
|
||||
}
|
||||
add(media)
|
||||
for (const ep of episodes) add(ep)
|
||||
return ids
|
||||
}
|
||||
|
||||
/** 命中的 history.media_id 属于哪个条目:集优先,命中的是版本时归到它那一集。 */
|
||||
function ownerOf(mediaID: string, media: Media | null, episodes: Media[]): Media | null {
|
||||
for (const ep of episodes) {
|
||||
if (ep.id === mediaID) return ep
|
||||
if (versionIDs(ep).includes(mediaID)) return ep
|
||||
}
|
||||
if (media) {
|
||||
if (media.id === mediaID) return media
|
||||
if (versionIDs(media).includes(mediaID)) return media
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function watchedAtScore(row: HistoryItem): number {
|
||||
const at = Date.parse(row.watched_at)
|
||||
return Number.isFinite(at) ? at : 0
|
||||
}
|
||||
|
||||
function newestRow(rows: HistoryItem[]): HistoryItem | null {
|
||||
let best: HistoryItem | null = null
|
||||
for (const row of rows) {
|
||||
if (!row || !row.media_id) continue
|
||||
if (best == null || watchedAtScore(row) > watchedAtScore(best)) best = row
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页的续播目标;没有可用历史时返回 null(按钮退回「立即播放」)。
|
||||
*/
|
||||
export function resolveMediaDetailResume(
|
||||
media: Media | null,
|
||||
episodes: Media[],
|
||||
history: HistoryItem[],
|
||||
): MediaDetailResume | null {
|
||||
if (!media) return null
|
||||
const ids = mediaDetailScopeIDs(media, episodes)
|
||||
if (ids.size === 0) return null
|
||||
const rows = history.filter((row) => row?.media_id && ids.has(row.media_id) && row.position_ms > 0)
|
||||
const best = newestRow(rows)
|
||||
if (!best) return null
|
||||
return {
|
||||
mediaId: best.media_id,
|
||||
media: ownerOf(best.media_id, media, episodes) ?? media,
|
||||
positionMs: best.position_ms,
|
||||
durationMs: best.duration_ms,
|
||||
completed: Boolean(best.completed),
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否该显示「继续播放」(与播放器真正会续播的条件保持一致)。 */
|
||||
export function canContinuePlaying(resume: MediaDetailResume | null): boolean {
|
||||
if (!resume) return false
|
||||
return !resume.completed && resume.positionMs > RESUME_MIN_POSITION_MS
|
||||
}
|
||||
|
||||
/** 12:34 / 1:02:03;非法输入返回空串。 */
|
||||
export function formatResumeClock(positionMs: number): string {
|
||||
if (!Number.isFinite(positionMs) || positionMs <= 0) return ''
|
||||
const totalSec = Math.floor(positionMs / 1000)
|
||||
const hours = Math.floor(totalSec / 3600)
|
||||
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||
const seconds = totalSec % 60
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
/** 「继续播放 · 第 3 集 · 12:34」;电影没有集号时只带位置。 */
|
||||
export function continueButtonLabel(resume: MediaDetailResume): string {
|
||||
const parts = ['继续播放']
|
||||
const episode = formatEpisodeLabel(resume.media)
|
||||
if (episode) parts.push(episode)
|
||||
const clock = formatResumeClock(resume.positionMs)
|
||||
if (clock) parts.push(clock)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 每集(含版本)的续播进度,用于剧集卡片上的进度条。 */
|
||||
export function episodeProgressById(
|
||||
episodes: Media[],
|
||||
history: HistoryItem[],
|
||||
): Map<string, EpisodeProgress> {
|
||||
const map = new Map<string, EpisodeProgress>()
|
||||
if (episodes.length === 0 || history.length === 0) return map
|
||||
const byMediaID = new Map<string, HistoryItem>()
|
||||
for (const row of history) {
|
||||
if (row?.media_id && row.position_ms > 0) byMediaID.set(row.media_id, row)
|
||||
}
|
||||
for (const ep of episodes) {
|
||||
const rows: HistoryItem[] = []
|
||||
for (const id of [ep.id, ...versionIDs(ep)]) {
|
||||
const row = byMediaID.get(id)
|
||||
if (row) rows.push(row)
|
||||
}
|
||||
const best = newestRow(rows)
|
||||
if (!best) continue
|
||||
map.set(ep.id, {
|
||||
positionMs: best.position_ms,
|
||||
durationMs: best.duration_ms,
|
||||
completed: Boolean(best.completed),
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 进度条宽度(0-100)。时长未知时按「看过」显示一小段,避免整条空着。 */
|
||||
export function progressPercent(progress: EpisodeProgress): number {
|
||||
if (progress.completed) return 100
|
||||
if (progress.durationMs > 0) {
|
||||
const ratio = (progress.positionMs / progress.durationMs) * 100
|
||||
return Math.min(99, Math.max(2, ratio))
|
||||
}
|
||||
return 5
|
||||
}
|
||||
@@ -3,13 +3,19 @@ import { useLocation, type NavigateFunction } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { api } from '../api/client'
|
||||
import { historyAPI } from '../api/history'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import { seasonSortOrder } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import {
|
||||
episodeProgressById,
|
||||
resolveMediaDetailResume,
|
||||
RESUME_SCAN_LIMIT,
|
||||
} from './mediaDetailResumeModel'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
type MediaDetailLocationState = { from?: string } | null
|
||||
@@ -26,6 +32,7 @@ interface MediaDetailRefreshParams {
|
||||
setLoading: Dispatch<SetStateAction<boolean>>
|
||||
setEpisodes: Dispatch<SetStateAction<Media[]>>
|
||||
setLoadingEpisodes: Dispatch<SetStateAction<boolean>>
|
||||
setHistory: Dispatch<SetStateAction<HistoryItem[]>>
|
||||
}
|
||||
|
||||
interface MediaDetailActionsParams {
|
||||
@@ -43,6 +50,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [episodes, setEpisodes] = useState<Media[]>([])
|
||||
const [loadingEpisodes, setLoadingEpisodes] = useState(false)
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
@@ -57,6 +65,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
setLoading,
|
||||
setEpisodes,
|
||||
setLoadingEpisodes,
|
||||
setHistory,
|
||||
})
|
||||
const location = useLocation()
|
||||
const backFrom = (location.state as MediaDetailLocationState)?.from
|
||||
@@ -108,6 +117,17 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
return (visibleEpisodes.length > 0 ? visibleEpisodes[0] : episodes[0]) || null
|
||||
}, [episodes, visibleEpisodes])
|
||||
|
||||
// 「继续播放」:在「本条目 + 全部剧集(含版本)」里找最近看过的一条。
|
||||
// 详情页的代表条目可能是 OVA/特别篇,所以不能只看它自己的进度。
|
||||
const resume = useMemo(
|
||||
() => resolveMediaDetailResume(media, episodes, history),
|
||||
[episodes, history, media],
|
||||
)
|
||||
const episodeProgress = useMemo(
|
||||
() => episodeProgressById(episodes, history),
|
||||
[episodes, history],
|
||||
)
|
||||
|
||||
const handleMetadataSaved = useCallback(async (next: Media) => {
|
||||
setMedia(next)
|
||||
await refresh()
|
||||
@@ -124,6 +144,8 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
visibleEpisodes,
|
||||
hasEpisodes,
|
||||
firstPlayableEpisode,
|
||||
resume,
|
||||
episodeProgress,
|
||||
setSelectedSeason,
|
||||
manualScrapeOpen,
|
||||
metadataEditOpen,
|
||||
@@ -146,20 +168,26 @@ function useMediaDetailRefresh({
|
||||
setLoading,
|
||||
setEpisodes,
|
||||
setLoadingEpisodes,
|
||||
setHistory,
|
||||
}: MediaDetailRefreshParams): () => Promise<void> {
|
||||
return useCallback(async () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
setLoadingEpisodes(true)
|
||||
try {
|
||||
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
|
||||
// 四个请求并行发出;详情一到就解锁整页渲染,收藏、分集列表、续播历史
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满所有往返)。
|
||||
const nextMediaPromise = mediaAPI.get(id)
|
||||
const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false)
|
||||
const episodesPromise = mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((r) => r.items ?? [])
|
||||
.catch(() => [])
|
||||
// 续播扫描只要历史行(include_media=false):服务端不 join media,
|
||||
// 300 条也只有几十 KB。
|
||||
const historyPromise = historyAPI
|
||||
.listRows(RESUME_SCAN_LIMIT)
|
||||
.catch(() => [] as HistoryItem[])
|
||||
|
||||
const nextMedia = await nextMediaPromise
|
||||
setMedia(nextMedia)
|
||||
@@ -169,11 +197,12 @@ function useMediaDetailRefresh({
|
||||
|
||||
const episodes = await episodesPromise
|
||||
setEpisodes(episodes)
|
||||
setHistory(await historyPromise)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingEpisodes(false)
|
||||
}
|
||||
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}, [id, setFavourite, setHistory, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}
|
||||
|
||||
function useMediaDetailActions({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import {
|
||||
episodeProgressById,
|
||||
resolveMediaDetailResume,
|
||||
RESUME_SCAN_LIMIT,
|
||||
type EpisodeProgress,
|
||||
type MediaDetailResume,
|
||||
} from './mediaDetailResumeModel'
|
||||
|
||||
interface SeriesEpisodeResume {
|
||||
resume: MediaDetailResume | null
|
||||
progressById: Map<string, EpisodeProgress>
|
||||
}
|
||||
|
||||
/**
|
||||
* 剧集面板(媒体库抽屉 / 媒体详情)用的续播数据。
|
||||
*
|
||||
* 代表条目可能是 OVA/特别篇,因此续播目标必须在「整剧所有集」里挑最近看过
|
||||
* 的一条;打开抽屉时扫一次整库最近历史即可,只取历史行(include_media=false)。
|
||||
*/
|
||||
export function useSeriesEpisodeResume(
|
||||
seriesID: string,
|
||||
rep: Media | null,
|
||||
episodes: Media[],
|
||||
): SeriesEpisodeResume {
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!seriesID) {
|
||||
setHistory([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
historyAPI
|
||||
.listRows(RESUME_SCAN_LIMIT)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setHistory(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHistory([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [seriesID])
|
||||
|
||||
const resume = useMemo(
|
||||
() => resolveMediaDetailResume(rep, episodes, history),
|
||||
[episodes, history, rep],
|
||||
)
|
||||
const progressById = useMemo(() => episodeProgressById(episodes, history), [episodes, history])
|
||||
return { resume, progressById }
|
||||
}
|
||||
Reference in New Issue
Block a user