Files
jellybloom/src/components/player/EndOfVideoCard.tsx
T

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>
)
}