player components
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import UpNext from './UpNext'
|
||||
import ResumePrompt from './ResumePrompt'
|
||||
import RecapCard from './RecapCard'
|
||||
import ChaptersPanel from './ChaptersPanel'
|
||||
import BookmarksPanel from './BookmarksPanel'
|
||||
import EndOfVideoCard from './EndOfVideoCard'
|
||||
import KeyboardHints from './KeyboardHints'
|
||||
import SubtitleSearchPanel from './SubtitleSearchPanel'
|
||||
import SyncPlayPanel from './SyncPlayPanel'
|
||||
import type { BaseItemDto } from '../../api/types'
|
||||
|
||||
export interface ChapterMarker {
|
||||
StartPositionTicks: number
|
||||
Name?: string | null
|
||||
ImageTag?: string | null
|
||||
}
|
||||
|
||||
interface UpNextProps {
|
||||
item: BaseItemDto | null
|
||||
visible: boolean
|
||||
countdown: number
|
||||
onSkip: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
interface ResumeProps {
|
||||
open: boolean
|
||||
positionTicks: number
|
||||
lastPlayedDate?: string | null
|
||||
onResume: () => void
|
||||
onRestart: () => void
|
||||
}
|
||||
|
||||
interface RecapProps {
|
||||
open: boolean
|
||||
previousEpisodes: BaseItemDto[]
|
||||
daysSinceLastWatch: number | null
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
interface ChaptersProps {
|
||||
open: boolean
|
||||
itemId: string
|
||||
chapters: ChapterMarker[]
|
||||
serverUrl: string
|
||||
currentTime: number
|
||||
onClose: () => void
|
||||
onJump: (t: number) => void
|
||||
}
|
||||
|
||||
interface BookmarksProps {
|
||||
open: boolean
|
||||
itemId: string
|
||||
currentTime: number
|
||||
refreshKey: number
|
||||
onClose: () => void
|
||||
onAdd: () => void
|
||||
onJump: (t: number) => void
|
||||
}
|
||||
|
||||
interface EndCardProps {
|
||||
open: boolean
|
||||
hasEpisodes: boolean
|
||||
item: BaseItemDto | null | undefined
|
||||
nextItem?: BaseItemDto | null | undefined
|
||||
onReplay: () => void
|
||||
onEpisodes: () => void
|
||||
onBack: () => void
|
||||
onPlayNext?: () => void
|
||||
}
|
||||
|
||||
interface SkipPromptProps {
|
||||
seriesId: string | null
|
||||
onAccept: () => void
|
||||
onNotNow: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
interface HintsProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface SubSearchProps {
|
||||
open: boolean
|
||||
subtitleUrl: string | null
|
||||
onClose: () => void
|
||||
onJump: (t: number) => void
|
||||
}
|
||||
|
||||
interface SyncPlayProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
currentItemId: string | null
|
||||
currentPositionTicks: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
upNext: UpNextProps
|
||||
resume: ResumeProps
|
||||
recap: RecapProps
|
||||
chapters: ChaptersProps | null
|
||||
bookmarks: BookmarksProps | null
|
||||
endCard: EndCardProps
|
||||
nextItem?: BaseItemDto | null
|
||||
onPlayNext?: () => void
|
||||
skipPrompt: SkipPromptProps
|
||||
hints: HintsProps
|
||||
subSearch: SubSearchProps
|
||||
syncPlay: SyncPlayProps
|
||||
}
|
||||
|
||||
/**
|
||||
* All the modal / panel / card overlays that float above the video. Kept in
|
||||
* one component so PlayerPage doesn't have to render 9 sibling overlays
|
||||
* inline. State lives in PlayerPage; this is pure presentation.
|
||||
*/
|
||||
export default function PlayerOverlays({
|
||||
upNext,
|
||||
resume,
|
||||
recap,
|
||||
chapters,
|
||||
bookmarks,
|
||||
endCard,
|
||||
nextItem,
|
||||
onPlayNext,
|
||||
skipPrompt,
|
||||
hints,
|
||||
subSearch,
|
||||
syncPlay,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<UpNext
|
||||
nextItem={upNext.item}
|
||||
visible={upNext.visible}
|
||||
countdownSeconds={upNext.countdown}
|
||||
onSkip={upNext.onSkip}
|
||||
onDismiss={upNext.onDismiss}
|
||||
/>
|
||||
|
||||
<ResumePrompt
|
||||
open={resume.open}
|
||||
positionTicks={resume.positionTicks}
|
||||
lastPlayedDate={resume.lastPlayedDate}
|
||||
onResume={resume.onResume}
|
||||
onRestart={resume.onRestart}
|
||||
/>
|
||||
|
||||
<RecapCard
|
||||
open={recap.open}
|
||||
previousEpisodes={recap.previousEpisodes}
|
||||
daysSinceLastWatch={recap.daysSinceLastWatch}
|
||||
onDismiss={recap.onDismiss}
|
||||
/>
|
||||
|
||||
{chapters && (
|
||||
<ChaptersPanel
|
||||
open={chapters.open}
|
||||
onClose={chapters.onClose}
|
||||
chapters={chapters.chapters}
|
||||
itemId={chapters.itemId}
|
||||
serverUrl={chapters.serverUrl}
|
||||
currentTime={chapters.currentTime}
|
||||
onJump={chapters.onJump}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bookmarks && (
|
||||
<BookmarksPanel
|
||||
open={bookmarks.open}
|
||||
onClose={bookmarks.onClose}
|
||||
itemId={bookmarks.itemId}
|
||||
currentTime={bookmarks.currentTime}
|
||||
onJump={bookmarks.onJump}
|
||||
onAdd={bookmarks.onAdd}
|
||||
refreshKey={bookmarks.refreshKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EndOfVideoCard
|
||||
open={endCard.open}
|
||||
hasEpisodes={endCard.hasEpisodes}
|
||||
item={endCard.item ?? undefined}
|
||||
nextItem={endCard.nextItem ?? nextItem ?? undefined}
|
||||
onReplay={endCard.onReplay}
|
||||
onEpisodes={endCard.onEpisodes}
|
||||
onBack={endCard.onBack}
|
||||
onPlayNext={endCard.onPlayNext ?? onPlayNext}
|
||||
/>
|
||||
|
||||
{/* Smart skip prompt - third manual skip on the same series triggers
|
||||
a one-shot offer to auto-skip from now on. */}
|
||||
<AnimatePresence>
|
||||
{skipPrompt.seriesId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute bottom-32 left-1/2 -translate-x-1/2 z-toast inline-flex items-center gap-2 h-11 pl-4 pr-2 rounded-full bg-black/90 backdrop-blur-xl border border-white/12 shadow-2xl"
|
||||
role="alert"
|
||||
>
|
||||
<span className="text-[12px] text-white tracking-tight">
|
||||
Auto-skip intros on this show?
|
||||
</span>
|
||||
<button
|
||||
onClick={skipPrompt.onAccept}
|
||||
className="h-8 px-3 rounded-full bg-accent text-void text-[11.5px] font-semibold hover:bg-accent-hover transition-colors focus-ring"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={skipPrompt.onNotNow}
|
||||
className="h-8 px-3 rounded-full text-[11.5px] text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
onClick={skipPrompt.onDismiss}
|
||||
className="h-8 px-3 rounded-full text-[11.5px] text-white/55 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||
>
|
||||
Don't ask
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<KeyboardHints open={hints.open} onClose={hints.onClose} />
|
||||
|
||||
<SubtitleSearchPanel
|
||||
open={subSearch.open}
|
||||
onClose={subSearch.onClose}
|
||||
subtitleUrl={subSearch.subtitleUrl}
|
||||
onJump={subSearch.onJump}
|
||||
/>
|
||||
|
||||
<SyncPlayPanel
|
||||
open={syncPlay.open}
|
||||
onClose={syncPlay.onClose}
|
||||
currentItemId={syncPlay.currentItemId}
|
||||
currentPositionTicks={syncPlay.currentPositionTicks}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user