324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { RotateCw, ListDetails, ArrowLeft, Star, Book, ChevronRight } from '../../lib/icons'
|
|
import { useTmdbMovie, useTmdbTvShow, useTmdbDiscoverMovies } from '../../hooks/use-tmdb'
|
|
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
|
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
|
import { tmdbMovieGenreId } from '../../lib/tmdb-genres'
|
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
|
import { usePersonalData } from '../../stores/personal-data-store'
|
|
import { useDiary } from '../../stores/diary-store'
|
|
import type { BaseItemDto } from '../../api/types'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
hasEpisodes: boolean
|
|
/** The just-finished item, used to source same-mood recommendations. */
|
|
item?: BaseItemDto | null
|
|
/** Next item in queue / series, if any. */
|
|
nextItem?: BaseItemDto | null
|
|
onReplay: () => void
|
|
onEpisodes: () => void
|
|
onBack: () => void
|
|
onPlayNext?: () => void
|
|
}
|
|
|
|
/**
|
|
* Card shown when a video ends and there's no auto-advance target.
|
|
* Three direct actions (Replay / Episodes / Back) plus two scrollable
|
|
* recommendation rows: "More like this" sourced from TMDB
|
|
* recommendations, and "Different vibe" pulling top-rated picks from
|
|
* a deliberately opposite genre.
|
|
*/
|
|
export default function EndOfVideoCard({
|
|
open,
|
|
hasEpisodes,
|
|
item,
|
|
nextItem,
|
|
onReplay,
|
|
onEpisodes,
|
|
onBack,
|
|
onPlayNext,
|
|
}: Props) {
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.22 }}
|
|
className="absolute inset-0 z-30 grid place-items-center bg-black/65 backdrop-blur-sm overflow-y-auto"
|
|
>
|
|
<motion.div
|
|
initial={{ y: 20, scale: 0.96 }}
|
|
animate={{ y: 0, scale: 1 }}
|
|
exit={{ y: 20, scale: 0.96 }}
|
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
|
className="bg-glass-strong backdrop-blur-2xl border border-white/14 rounded-2xl p-7 text-center shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] w-[min(960px,92vw)] my-8"
|
|
>
|
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-white/60 mb-2">
|
|
Finished
|
|
</p>
|
|
<h2 className="font-display text-2xl font-bold text-white mb-4 tracking-tight">
|
|
{item?.Name || "What's next?"}
|
|
</h2>
|
|
|
|
{item && <RateAndLogRow itemId={item.Id} itemName={item.Name || ''} />}
|
|
|
|
{nextItem && onPlayNext && (
|
|
<motion.button
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.15 }}
|
|
onClick={onPlayNext}
|
|
className="mb-5 w-full max-w-md mx-auto flex items-center gap-3 p-3 rounded-xl bg-white/8 hover:bg-white/14 border border-white/14 hover:border-white/25 transition text-left focus-ring"
|
|
>
|
|
<div className="w-10 h-10 rounded-lg bg-elevated grid place-items-center shrink-0">
|
|
<ChevronRight size={16} className="text-accent" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/55">
|
|
Up next
|
|
</p>
|
|
<p className="text-[13px] text-white font-medium truncate tracking-tight">
|
|
{nextItem.Name}
|
|
</p>
|
|
</div>
|
|
</motion.button>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2.5 justify-center flex-wrap mb-2">
|
|
<button
|
|
onClick={onReplay}
|
|
autoFocus
|
|
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight transition-[transform,background] duration-200 ease-out hover:scale-[1.03] active:scale-[0.97] focus-ring shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]"
|
|
>
|
|
<RotateCw size={14} stroke={2.25} />
|
|
Replay
|
|
</button>
|
|
{hasEpisodes && (
|
|
<button
|
|
onClick={onEpisodes}
|
|
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur text-white text-[13px] font-medium tracking-tight border border-white/14 hover:border-white/25 transition-all duration-200 ease-out active:scale-[0.97] focus-ring"
|
|
>
|
|
<ListDetails size={14} />
|
|
Episodes
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onBack}
|
|
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur text-white text-[13px] font-medium tracking-tight border border-white/14 hover:border-white/25 transition-all duration-200 ease-out active:scale-[0.97] focus-ring"
|
|
>
|
|
<ArrowLeft size={14} stroke={2} />
|
|
Back
|
|
</button>
|
|
</div>
|
|
{item && <EndOfVideoExtras item={item} />}
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|
|
|
|
function RateAndLogRow({ itemId, itemName }: { itemId?: string; itemName: string }) {
|
|
const [hoverRating, setHoverRating] = useState(0)
|
|
const personal = usePersonalData(s => itemId ? s.entries[itemId] : undefined)
|
|
const setRating = usePersonalData(s => s.setRating)
|
|
const addDiary = useDiary(s => s.add)
|
|
const current = personal?.rating || 0
|
|
const [logged, setLogged] = useState(false)
|
|
|
|
if (!itemId || String(itemId).startsWith('tmdb-')) return null
|
|
|
|
return (
|
|
<div className="flex items-center justify-center gap-3 mb-5">
|
|
<div className="flex items-center gap-1">
|
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(star => (
|
|
<button
|
|
key={star}
|
|
type="button"
|
|
onMouseEnter={() => setHoverRating(star)}
|
|
onMouseLeave={() => setHoverRating(0)}
|
|
onClick={() => setRating(itemId, star)}
|
|
className="w-6 h-6 grid place-items-center focus-ring rounded"
|
|
aria-label={`Rate ${star} out of 10`}
|
|
>
|
|
<Star
|
|
size={14}
|
|
className={
|
|
(hoverRating ? star <= hoverRating : star <= current)
|
|
? 'text-accent'
|
|
: 'text-white/25'
|
|
}
|
|
fill={(hoverRating ? star <= hoverRating : star <= current) ? 'currentColor' : 'none'}
|
|
stroke={2}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="text-[11px] text-white/40 tabular-nums w-6 text-left">
|
|
{hoverRating || current || '-'}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
if (logged) return
|
|
addDiary({ itemId, itemName, watchedAt: new Date().toISOString() })
|
|
setLogged(true)
|
|
}}
|
|
disabled={logged}
|
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-white/8 hover:bg-white/14 border border-white/14 text-[11.5px] text-white/80 hover:text-white transition disabled:opacity-40 disabled:cursor-default focus-ring"
|
|
>
|
|
<Book size={12} />
|
|
{logged ? 'Logged' : 'Add to diary'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EndOfVideoExtras({ item }: { item: BaseItemDto }) {
|
|
const showMoreLikeThis = usePreferencesStore(s => s.endVideo.show.moreLikeThis)
|
|
const showAntiRec = usePreferencesStore(s => s.endVideo.show.antiRec)
|
|
if (!showMoreLikeThis && !showAntiRec) return null
|
|
return (
|
|
<div className="mt-6 text-left">
|
|
{showMoreLikeThis && <MoreLikeThis item={item} />}
|
|
{showAntiRec && <DifferentVibe item={item} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────────── */
|
|
/* Recommendation rows */
|
|
/* ────────────────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* "More like this" - pulls TMDB recommendations on the just-watched
|
|
* item. For episodes, sources from the parent series since per-episode
|
|
* recommendations don't exist.
|
|
*/
|
|
function MoreLikeThis({ item }: { item: BaseItemDto }) {
|
|
// Episodes: fall back to series TMDB id when present (passed via item.SeriesId
|
|
// would require an extra fetch; the player already has it in the parent's
|
|
// ProviderIds when we navigate from a series detail page, but for safety
|
|
// we use the item's own ProviderIds or null).
|
|
const tmdbId = item.ProviderIds?.Tmdb ? Number(item.ProviderIds.Tmdb) : null
|
|
const isSeries = item.Type === 'Series' || item.Type === 'Episode'
|
|
const movieFull = useTmdbMovie(!isSeries ? tmdbId : null)
|
|
const tvFull = useTmdbTvShow(isSeries ? tmdbId : null)
|
|
const libraryByTmdbId = useLibraryByTmdbId()
|
|
|
|
const items = useMemo(() => {
|
|
const recs = isSeries ? tvFull.data?.recommendations?.results : movieFull.data?.recommendations?.results
|
|
if (!recs) return []
|
|
return mapTmdbToJf(recs.slice(0, 12), libraryByTmdbId.data)
|
|
}, [movieFull.data, tvFull.data, isSeries, libraryByTmdbId.data])
|
|
|
|
if (!tmdbId || items.length === 0) return null
|
|
return <RowStrip title="More like this" items={items} />
|
|
}
|
|
|
|
const VIBE_FLIP: Record<string, number> = {
|
|
Horror: 35, // → Comedy
|
|
Thriller: 35,
|
|
Crime: 35,
|
|
Drama: 12, // → Adventure
|
|
Documentary: 28, // → Action
|
|
Action: 18, // → Drama
|
|
Adventure: 18,
|
|
Comedy: 27, // → Horror
|
|
Family: 53, // → Thriller
|
|
Animation: 80, // → Crime
|
|
Romance: 878, // → Sci-Fi
|
|
'Science Fiction': 10749, // → Romance
|
|
}
|
|
|
|
/**
|
|
* "Different vibe" - top-rated picks from the genre roughly opposite
|
|
* the just-watched item. Useful when the user wants a palate cleanser
|
|
* after a heavy watch.
|
|
*/
|
|
function DifferentVibe({ item }: { item: BaseItemDto }) {
|
|
const oppositeGenreId = useMemo(() => {
|
|
const genres = item.Genres || []
|
|
for (const g of genres) {
|
|
if (VIBE_FLIP[g] != null) return VIBE_FLIP[g]
|
|
}
|
|
// Fall back: if the source genre is in our movie-genre map, swap to
|
|
// the most distant entry (Documentary) so we always emit something.
|
|
return tmdbMovieGenreId('Documentary')
|
|
}, [item.Genres])
|
|
|
|
const discover = useTmdbDiscoverMovies(
|
|
oppositeGenreId
|
|
? {
|
|
with_genres: String(oppositeGenreId),
|
|
'vote_count.gte': '500',
|
|
sort_by: 'vote_average.desc',
|
|
}
|
|
: ({} as Record<string, string>),
|
|
)
|
|
const libraryByTmdbId = useLibraryByTmdbId()
|
|
|
|
const items = useMemo(() => {
|
|
const raw = (discover.data?.results || []).slice(0, 10).map(m => ({ ...m, media_type: 'movie' }))
|
|
return mapTmdbToJf(raw, libraryByTmdbId.data)
|
|
}, [discover.data, libraryByTmdbId.data])
|
|
|
|
if (items.length === 0) return null
|
|
return <RowStrip title="Want a different vibe?" items={items} />
|
|
}
|
|
|
|
function RowStrip({ title, items }: { title: string; items: BaseItemDto[] }) {
|
|
const navigate = useNavigate()
|
|
return (
|
|
<section className="mb-5 last:mb-0">
|
|
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-white/55 mb-2.5 px-1">
|
|
{title}
|
|
</p>
|
|
<div className="flex gap-2 overflow-x-auto hide-scrollbar -mx-1 px-1 pb-1.5">
|
|
{items.map(it => {
|
|
const tmdbPoster = it._tmdbPoster
|
|
const inLibrary = it._inLibrary === true
|
|
const poster =
|
|
tmdbPoster ||
|
|
(it.ImageTags?.Primary && it.Id
|
|
? null // Local items go through real Jellyfin URL on click; we skip the thumb here
|
|
: null)
|
|
return (
|
|
<button
|
|
key={it.Id}
|
|
onClick={() => it.Id && navigate(`/item/${it.Id}`)}
|
|
className="shrink-0 w-[112px] text-left focus-ring rounded-md group"
|
|
>
|
|
<div className={`relative aspect-[2/3] rounded-md overflow-hidden bg-elevated/60 ring-1 transition ${
|
|
inLibrary ? 'ring-accent/60' : 'ring-white/10 group-hover:ring-white/30'
|
|
}`}>
|
|
{poster && (
|
|
<img src={poster} alt="" loading="lazy" className="w-full h-full object-cover group-hover:scale-[1.04] transition-transform duration-300" />
|
|
)}
|
|
{!poster && (
|
|
<div className="absolute inset-0 grid place-items-center text-white/45 text-2xl font-display font-semibold">
|
|
{it.Name?.[0]?.toUpperCase() || '?'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-[11.5px] text-white/85 font-medium truncate mt-1.5 leading-tight">
|
|
{it.Name}
|
|
</p>
|
|
{it.ProductionYear && (
|
|
<p className="text-[10px] text-white/45 tabular-nums">
|
|
{it.ProductionYear}
|
|
</p>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|