player components
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
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 }) {
|
||||
if (!itemId || String(itemId).startsWith('tmdb-')) return null
|
||||
const [hoverRating, setHoverRating] = useState(0)
|
||||
const personal = usePersonalData(s => s.entries[itemId])
|
||||
const setRating = usePersonalData(s => s.setRating)
|
||||
const addDiary = useDiary(s => s.add)
|
||||
const current = personal?.rating || 0
|
||||
const [logged, setLogged] = useState(false)
|
||||
|
||||
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 as any)._tmdbPoster as string | undefined
|
||||
const inLibrary = (it as any)._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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user