refactor: split media detail page

This commit is contained in:
ShukeBta
2026-06-25 07:32:52 +08:00
parent 087a00b5d0
commit c61d0190f2
4 changed files with 472 additions and 216 deletions
+39 -216
View File
@@ -1,236 +1,59 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
import toast from 'react-hot-toast'
import { useNavigate, useParams } from 'react-router-dom'
import { mediaAPI } from '../api/library'
import { playbackAPI } from '../api/playback'
import { recycleAPI } from '../api/recycle'
import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
import type { Media } from '../types'
import { confirmAction } from '../components/confirmAction'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
import { MediaDetailBackdrop, MediaDetailPoster } from './MediaDetailArtwork'
import { MediaDetailMetadata } from './MediaDetailMetadata'
function mediaLibraryBackTarget(media: Media): string {
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return ''
if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}`
const seriesKey = getSeriesKey(media)
const target = `/library/${encodeURIComponent(libraryID)}`
return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target
}
import { MediaDetailBackdrop } from './MediaDetailArtwork'
import {
MediaDetailBackButton,
MediaDetailDialogs,
MediaDetailLoading,
MediaDetailMainContent,
MediaDetailMissing,
} from './MediaDetailPageSections'
import { useMediaDetailPageState } from './useMediaDetailPageState'
export function MediaDetailPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
const role = useAuthStore((s) => s.user?.role)
const [media, setMedia] = useState<Media | null>(null)
const [favourite, setFavourite] = useState(false)
const [loading, setLoading] = useState(true)
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
const [organizeOpen, setOrganizeOpen] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
const detail = useMediaDetailPageState({ id, navigate })
const refresh = useCallback(async () => {
if (!id) return
setLoading(true)
try {
const m = await mediaAPI.get(id)
setMedia(m)
const favs = await playbackAPI.listFavourites().catch(() => [])
setFavourite(favs.some((f) => f.id === m.id))
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
refresh().catch(() => undefined)
}, [refresh])
const toggleFav = async () => {
if (!media) return
const state = await playbackAPI.toggleFavourite(media.id)
setFavourite(state)
toast.success(state ? '已加入我的收藏' : '已取消收藏')
}
const rescrape = async () => {
if (!media) return
await api.post(`/media/${media.id}/scrape`, {
episode_images: scrapeEpisodeArtwork,
refresh_matched: true,
include_matched: true,
})
toast.success('已触发重新刮削')
await refresh()
}
const reprobe = async () => {
if (!media) return
try {
const r = await api.post(`/media/${media.id}/probe`)
if (r.data?.code === 0) {
toast.success('重新探测成功')
} else {
toast.error(r.data?.error || '探测失败')
}
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'探测失败,请检查 ffprobe 是否已安装'
toast.error(msg)
}
}
const exportNFO = async () => {
if (!media) return
try {
const r = await recycleAPI.exportNFO(media.id)
toast.success(`NFO 已成功写入 ${r.path}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'导出失败'
toast.error(msg)
}
}
const softDelete = async () => {
if (!media) return
if (!(await confirmAction({ title: '移入回收站', message: `将「${media.title}」移至回收站? (磁盘文件保留)`, confirmText: '移入回收站' }))) return
await recycleAPI.softDelete(media.id)
toast.success('已移至回收站')
const backTarget = mediaLibraryBackTarget(media)
if (backTarget) navigate(backTarget, { replace: true })
else navigate(-1)
}
if (loading) {
return (
<div className="flex items-center justify-center py-48">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-100 border-t-gray-900" />
</div>
)
}
if (!media) {
return (
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
<p className="text-gray-500">媒体资源已被移除或不存在</p>
</div>
)
}
if (detail.loading) return <MediaDetailLoading />
if (!detail.media) return <MediaDetailMissing />
const media = detail.media
return (
<div className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
<MediaDetailBackdrop media={media} />
<div className="relative z-20 px-6 pt-6 sm:px-10 sm:pt-8">
<button
type="button"
onClick={() => {
const backTarget = mediaLibraryBackTarget(media)
if (backTarget) navigate(backTarget)
else navigate(-1)
}}
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
>
<ArrowLeft size={16} />
<span>返回媒体库</span>
</button>
</div>
<MediaDetailBackButton onBack={detail.goBack} />
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
<MediaDetailPoster media={media} />
<div className="flex-1 space-y-6">
<MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" />
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link
to={`/play/${media.id}?mode=hls`}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
<ExternalPlayerButton mediaId={media.id} />
<button
onClick={toggleFav}
className={
'btn-outline gap-2 ' +
(favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
}
>
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
</button>
</div>
{role === 'admin' && (
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
onSmartScrape={rescrape}
onManualScrape={() => setManualScrapeOpen(true)}
onMetadataEdit={() => setMetadataEditOpen(true)}
onOrganize={() => setOrganizeOpen(true)}
onProbe={reprobe}
onExportNFO={exportNFO}
onSoftDelete={softDelete}
/>
)}
</div>
</div>
</div>
<ManualScrapeDialog
open={manualScrapeOpen}
<MediaDetailMainContent
media={media}
defaultQuery={media.title}
mediaType={media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined}
scopeLabel={media.title}
episodeArtwork={scrapeEpisodeArtwork}
onClose={() => setManualScrapeOpen(false)}
onApplied={refresh}
isAdmin={role === 'admin'}
favourite={detail.favourite}
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
onToggleFavourite={detail.toggleFavourite}
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
onSmartScrape={detail.rescrape}
onManualScrape={() => detail.setManualScrapeOpen(true)}
onMetadataEdit={() => detail.setMetadataEditOpen(true)}
onOrganize={() => detail.setOrganizeOpen(true)}
onProbe={detail.reprobe}
onExportNFO={detail.exportNFO}
onSoftDelete={detail.softDelete}
/>
<MetadataEditDialog
open={metadataEditOpen}
<MediaDetailDialogs
media={media}
onClose={() => setMetadataEditOpen(false)}
onSaved={async (next) => {
setMedia(next)
await refresh()
}}
/>
<OrganizeMediaDialog
open={organizeOpen}
media={media}
onClose={() => setOrganizeOpen(false)}
onOrganized={refresh}
manualScrapeOpen={detail.manualScrapeOpen}
metadataEditOpen={detail.metadataEditOpen}
organizeOpen={detail.organizeOpen}
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
onManualScrapeClose={() => detail.setManualScrapeOpen(false)}
onMetadataEditClose={() => detail.setMetadataEditOpen(false)}
onOrganizeClose={() => detail.setOrganizeOpen(false)}
onManualScrapeApplied={detail.refresh}
onMetadataSaved={detail.handleMetadataSaved}
onOrganized={detail.refresh}
/>
</div>
)
+16
View File
@@ -0,0 +1,16 @@
import type { Media } from '../types'
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
export function mediaLibraryBackTarget(media: Media): string {
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return ''
if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}`
const seriesKey = getSeriesKey(media)
const target = `/library/${encodeURIComponent(libraryID)}`
return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target
}
export function mediaDetailScrapeMediaType(media: Media): string | undefined {
return media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined
}
+225
View File
@@ -0,0 +1,225 @@
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import type { Media } from '../types'
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
import { MediaDetailPoster } from './MediaDetailArtwork'
import { MediaDetailMetadata } from './MediaDetailMetadata'
import { mediaDetailScrapeMediaType } from './MediaDetailPageModel'
interface MediaDetailPlaybackActionsProps {
media: Media
favourite: boolean
onToggleFavourite: () => void
}
interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps {
isAdmin: boolean
scrapeEpisodeArtwork: boolean
onScrapeEpisodeArtworkChange: (checked: boolean) => void
onSmartScrape: () => void
onManualScrape: () => void
onMetadataEdit: () => void
onOrganize: () => void
onProbe: () => void
onExportNFO: () => void
onSoftDelete: () => void
}
interface MediaDetailDialogsProps {
media: Media
manualScrapeOpen: boolean
metadataEditOpen: boolean
organizeOpen: boolean
scrapeEpisodeArtwork: boolean
onManualScrapeClose: () => void
onMetadataEditClose: () => void
onOrganizeClose: () => void
onManualScrapeApplied: () => void
onMetadataSaved: (media: Media) => void | Promise<void>
onOrganized: () => void
}
interface MediaDetailManualScrapeDialogProps {
open: boolean
media: Media
episodeArtwork: boolean
onClose: () => void
onApplied: () => void
}
export function MediaDetailLoading() {
return (
<div className="flex items-center justify-center py-48">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-100 border-t-gray-900" />
</div>
)
}
export function MediaDetailMissing() {
return (
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
<p className="text-gray-500">媒体资源已被移除或不存在</p>
</div>
)
}
export function MediaDetailBackButton({ onBack }: { onBack: () => void }) {
return (
<div className="relative z-20 px-6 pt-6 sm:px-10 sm:pt-8">
<button
type="button"
onClick={onBack}
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
>
<ArrowLeft size={16} />
<span>返回媒体库</span>
</button>
</div>
)
}
export function MediaDetailPlaybackActions({
media,
favourite,
onToggleFavourite,
}: MediaDetailPlaybackActionsProps) {
return (
<div className="flex flex-wrap gap-3">
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link
to={`/play/${media.id}?mode=hls`}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
<ExternalPlayerButton mediaId={media.id} />
<button
onClick={onToggleFavourite}
className={
'btn-outline gap-2 ' +
(favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
}
>
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
</button>
</div>
)
}
export function MediaDetailMainContent({
media,
isAdmin,
favourite,
scrapeEpisodeArtwork,
onToggleFavourite,
onScrapeEpisodeArtworkChange,
onSmartScrape,
onManualScrape,
onMetadataEdit,
onOrganize,
onProbe,
onExportNFO,
onSoftDelete,
}: MediaDetailMainContentProps) {
return (
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
<MediaDetailPoster media={media} />
<div className="flex-1 space-y-6">
<MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" />
<div className="flex flex-col gap-5">
<MediaDetailPlaybackActions media={media} favourite={favourite} onToggleFavourite={onToggleFavourite} />
{isAdmin && (
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
onSmartScrape={onSmartScrape}
onManualScrape={onManualScrape}
onMetadataEdit={onMetadataEdit}
onOrganize={onOrganize}
onProbe={onProbe}
onExportNFO={onExportNFO}
onSoftDelete={onSoftDelete}
/>
)}
</div>
</div>
</div>
)
}
export function MediaDetailDialogs({
media,
manualScrapeOpen,
metadataEditOpen,
organizeOpen,
scrapeEpisodeArtwork,
onManualScrapeClose,
onMetadataEditClose,
onOrganizeClose,
onManualScrapeApplied,
onMetadataSaved,
onOrganized,
}: MediaDetailDialogsProps) {
return (
<>
<MediaDetailManualScrapeDialog
open={manualScrapeOpen}
media={media}
onClose={onManualScrapeClose}
onApplied={onManualScrapeApplied}
episodeArtwork={scrapeEpisodeArtwork}
/>
<MetadataEditDialog
open={metadataEditOpen}
media={media}
onClose={onMetadataEditClose}
onSaved={onMetadataSaved}
/>
<OrganizeMediaDialog
open={organizeOpen}
media={media}
onClose={onOrganizeClose}
onOrganized={onOrganized}
/>
</>
)
}
function MediaDetailManualScrapeDialog({
open,
media,
episodeArtwork,
onClose,
onApplied,
}: MediaDetailManualScrapeDialogProps) {
return (
<ManualScrapeDialog
open={open}
media={media}
defaultQuery={media.title}
mediaType={mediaDetailScrapeMediaType(media)}
scopeLabel={media.title}
episodeArtwork={episodeArtwork}
onClose={onClose}
onApplied={onApplied}
/>
)
}
+192
View File
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import toast from 'react-hot-toast'
import { api } from '../api/client'
import { mediaAPI } from '../api/library'
import { playbackAPI } from '../api/playback'
import { recycleAPI } from '../api/recycle'
import { confirmAction } from '../components/confirmAction'
import type { Media } from '../types'
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
interface MediaDetailPageStateParams {
id: string
navigate: NavigateFunction
}
interface MediaDetailRefreshParams {
id: string
setMedia: Dispatch<SetStateAction<Media | null>>
setFavourite: Dispatch<SetStateAction<boolean>>
setLoading: Dispatch<SetStateAction<boolean>>
}
interface MediaDetailActionsParams {
media: Media | null
scrapeEpisodeArtwork: boolean
navigate: NavigateFunction
refresh: () => Promise<void>
setFavourite: Dispatch<SetStateAction<boolean>>
}
export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStateParams) {
const [media, setMedia] = useState<Media | null>(null)
const [favourite, setFavourite] = useState(false)
const [loading, setLoading] = useState(true)
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
const [organizeOpen, setOrganizeOpen] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
const refresh = useMediaDetailRefresh({ id, setMedia, setFavourite, setLoading })
const actions = useMediaDetailActions({
media,
scrapeEpisodeArtwork,
navigate,
refresh,
setFavourite,
})
useEffect(() => {
refresh().catch(() => undefined)
}, [refresh])
const handleMetadataSaved = useCallback(async (next: Media) => {
setMedia(next)
await refresh()
}, [refresh])
return {
media,
favourite,
loading,
manualScrapeOpen,
metadataEditOpen,
organizeOpen,
scrapeEpisodeArtwork,
refresh,
handleMetadataSaved,
setManualScrapeOpen,
setMetadataEditOpen,
setOrganizeOpen,
setScrapeEpisodeArtwork,
...actions,
}
}
function useMediaDetailRefresh({
id,
setMedia,
setFavourite,
setLoading,
}: MediaDetailRefreshParams): () => Promise<void> {
return useCallback(async () => {
if (!id) return
setLoading(true)
try {
const nextMedia = await mediaAPI.get(id)
setMedia(nextMedia)
const favourites = await playbackAPI.listFavourites().catch(() => [])
setFavourite(favourites.some((item) => item.id === nextMedia.id))
} finally {
setLoading(false)
}
}, [id, setFavourite, setLoading, setMedia])
}
function useMediaDetailActions({
media,
scrapeEpisodeArtwork,
navigate,
refresh,
setFavourite,
}: MediaDetailActionsParams) {
const goBack = useCallback(() => goBackFromMediaDetail(media, navigate), [media, navigate])
const toggleFavourite = useCallback(
() => toggleMediaFavourite(media, setFavourite),
[media, setFavourite],
)
const rescrape = useCallback(
() => rescrapeMedia(media, scrapeEpisodeArtwork, refresh),
[media, refresh, scrapeEpisodeArtwork],
)
const reprobe = useCallback(() => reprobeMedia(media, refresh), [media, refresh])
const exportNFO = useCallback(() => exportMediaNFO(media), [media])
const softDelete = useCallback(
() => softDeleteMedia(media, navigate),
[media, navigate],
)
return { goBack, toggleFavourite, rescrape, reprobe, exportNFO, softDelete }
}
function goBackFromMediaDetail(media: Media | null, navigate: NavigateFunction, replace = false): void {
if (!media) return
const backTarget = mediaLibraryBackTarget(media)
if (backTarget) navigate(backTarget, replace ? { replace: true } : undefined)
else navigate(-1)
}
async function toggleMediaFavourite(
media: Media | null,
setFavourite: Dispatch<SetStateAction<boolean>>,
): Promise<void> {
if (!media) return
const state = await playbackAPI.toggleFavourite(media.id)
setFavourite(state)
toast.success(state ? '已加入我的收藏' : '已取消收藏')
}
async function rescrapeMedia(
media: Media | null,
scrapeEpisodeArtwork: boolean,
refresh: () => Promise<void>,
): Promise<void> {
if (!media) return
await api.post(`/media/${media.id}/scrape`, {
episode_images: scrapeEpisodeArtwork,
refresh_matched: true,
include_matched: true,
})
toast.success('已触发重新刮削')
await refresh()
}
async function reprobeMedia(media: Media | null, refresh: () => Promise<void>): Promise<void> {
if (!media) return
try {
const result = await api.post(`/media/${media.id}/probe`)
if (result.data?.code === 0) toast.success('重新探测成功')
else toast.error(result.data?.error || '探测失败')
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '探测失败,请检查 ffprobe 是否已安装'))
}
}
async function exportMediaNFO(media: Media | null): Promise<void> {
if (!media) return
try {
const result = await recycleAPI.exportNFO(media.id)
toast.success(`NFO 已成功写入 ${result.path}`)
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '导出失败'))
}
}
async function softDeleteMedia(media: Media | null, navigate: NavigateFunction): Promise<void> {
if (!media) return
const confirmed = await confirmAction({
title: '移入回收站',
message: `将「${media.title}」移至回收站? (磁盘文件保留)`,
confirmText: '移入回收站',
})
if (!confirmed) return
await recycleAPI.softDelete(media.id)
toast.success('已移至回收站')
goBackFromMediaDetail(media, navigate, true)
}
function apiErrorMessage(err: unknown, fallback: string): string {
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback
}