player components
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X, Plus } from '../../lib/icons'
|
||||||
|
import {
|
||||||
|
loadBookmarks,
|
||||||
|
removeBookmark,
|
||||||
|
updateBookmark,
|
||||||
|
type Bookmark,
|
||||||
|
} from '../../lib/bookmarks'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
itemId: string
|
||||||
|
currentTime: number
|
||||||
|
onJump: (timeSec: number) => void
|
||||||
|
onAdd: () => void
|
||||||
|
/** Bumped when the parent adds a bookmark so the panel re-reads storage. */
|
||||||
|
refreshKey: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSec(sec: number): string {
|
||||||
|
if (!Number.isFinite(sec) || sec < 0) return '0:00'
|
||||||
|
const h = Math.floor(sec / 3600)
|
||||||
|
const m = Math.floor((sec % 3600) / 60)
|
||||||
|
const s = Math.floor(sec % 60)
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookmarksPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
itemId,
|
||||||
|
onJump,
|
||||||
|
onAdd,
|
||||||
|
refreshKey,
|
||||||
|
}: Props) {
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [draftNote, setDraftNote] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!itemId) return
|
||||||
|
setBookmarks(loadBookmarks(itemId))
|
||||||
|
}, [itemId, refreshKey])
|
||||||
|
|
||||||
|
function commitNote(bm: Bookmark) {
|
||||||
|
updateBookmark(itemId, bm.id, { note: draftNote.trim() || undefined })
|
||||||
|
setBookmarks(loadBookmarks(itemId))
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBookmark(bm: Bookmark) {
|
||||||
|
removeBookmark(itemId, bm.id)
|
||||||
|
setBookmarks(loadBookmarks(itemId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-40 w-[380px] max-w-[85vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Bookmarks"
|
||||||
|
>
|
||||||
|
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8 flex items-center justify-between">
|
||||||
|
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
|
||||||
|
Bookmarks
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-accent/15 hover:bg-accent/25 text-accent text-[11.5px] font-semibold transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
<Plus size={13} stroke={2.25} />
|
||||||
|
Mark
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-1.5">
|
||||||
|
{bookmarks.length === 0 && (
|
||||||
|
<div className="text-center py-12 px-4">
|
||||||
|
<p className="text-[13px] text-white/75 font-medium mb-1">No bookmarks yet</p>
|
||||||
|
<p className="text-[11.5px] text-white/45">
|
||||||
|
Press <kbd className="px-1 py-0.5 rounded bg-white/10 font-mono text-[10.5px]">B</kbd> while
|
||||||
|
watching to mark a moment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bookmarks.map(bm => (
|
||||||
|
<div
|
||||||
|
key={bm.id}
|
||||||
|
className="rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors p-3 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-3 mb-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onJump(bm.positionSec)}
|
||||||
|
className="text-[13px] font-mono font-semibold tabular-nums text-accent hover:text-accent-hover transition-colors focus-ring rounded"
|
||||||
|
>
|
||||||
|
{formatSec(bm.positionSec)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteBookmark(bm)}
|
||||||
|
className="ml-auto text-[10.5px] text-white/40 hover:text-error transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editingId === bm.id ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draftNote}
|
||||||
|
onChange={e => setDraftNote(e.target.value)}
|
||||||
|
onBlur={() => commitNote(bm)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') commitNote(bm)
|
||||||
|
if (e.key === 'Escape') setEditingId(null)
|
||||||
|
}}
|
||||||
|
placeholder="Add a note..."
|
||||||
|
className="w-full bg-void/60 border border-border rounded px-2 py-1 text-[12px] text-text-1 placeholder:text-text-4 focus:outline-none focus:border-accent/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(bm.id)
|
||||||
|
setDraftNote(bm.note || '')
|
||||||
|
}}
|
||||||
|
className="text-left w-full text-[12px] text-white/75 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{bm.note || <span className="text-white/35 italic">Add a note...</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ticksToSeconds } from '../../lib/format'
|
||||||
|
|
||||||
|
interface Chapter {
|
||||||
|
StartPositionTicks?: number | null
|
||||||
|
Name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chapters?: Chapter[] | null
|
||||||
|
duration: number
|
||||||
|
onJump?: (seconds: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChapterTicks({ chapters, duration, onJump }: Props) {
|
||||||
|
if (!chapters?.length || !duration) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{chapters.map((c, i) => {
|
||||||
|
const start = ticksToSeconds(c.StartPositionTicks)
|
||||||
|
if (start <= 0 || start >= duration) return null
|
||||||
|
const left = (start / duration) * 100
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onJump?.(start)}
|
||||||
|
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-2 bg-white/45 hover:bg-white hover:scale-y-150 rounded-sm transition-all duration-150 pointer-events-auto"
|
||||||
|
style={{ left: `${left}%` }}
|
||||||
|
aria-label={c.Name || `Chapter ${i + 1}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X } from '../../lib/icons'
|
||||||
|
import { ticksToSeconds, formatTimecode } from '../../lib/format'
|
||||||
|
|
||||||
|
interface Chapter {
|
||||||
|
Name?: string | null
|
||||||
|
StartPositionTicks?: number | null
|
||||||
|
ImageTag?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
chapters: Chapter[]
|
||||||
|
itemId: string
|
||||||
|
serverUrl: string
|
||||||
|
currentTime: number
|
||||||
|
onJump: (timeSec: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slide-in panel listing chapters with thumbnail + name + timecode. Same
|
||||||
|
* pattern as EpisodesPanel: 380px from the right, glass chrome, escape /
|
||||||
|
* backdrop click to close. The current chapter is highlighted with the
|
||||||
|
* accent rail.
|
||||||
|
*/
|
||||||
|
export default function ChaptersPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
chapters,
|
||||||
|
itemId,
|
||||||
|
serverUrl,
|
||||||
|
currentTime,
|
||||||
|
onJump,
|
||||||
|
}: Props) {
|
||||||
|
const currentIdx = (() => {
|
||||||
|
let idx = -1
|
||||||
|
for (let i = 0; i < chapters.length; i++) {
|
||||||
|
const t = ticksToSeconds(chapters[i].StartPositionTicks)
|
||||||
|
if (t <= currentTime) idx = i
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-40 w-[380px] max-w-[85vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Chapters"
|
||||||
|
>
|
||||||
|
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8 flex items-center justify-between">
|
||||||
|
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
|
||||||
|
Chapters
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-1.5">
|
||||||
|
{chapters.length === 0 && (
|
||||||
|
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">
|
||||||
|
No chapters in this item.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chapters.map((c, i) => {
|
||||||
|
const startSec = ticksToSeconds(c.StartPositionTicks)
|
||||||
|
const isCurrent = i === currentIdx
|
||||||
|
const thumb = c.ImageTag
|
||||||
|
? `${serverUrl}/Items/${itemId}/Images/Chapter/${i}?tag=${c.ImageTag}&maxWidth=240`
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
onClose()
|
||||||
|
onJump(startSec)
|
||||||
|
}}
|
||||||
|
className={`w-full text-left rounded-lg overflow-hidden border transition-colors duration-150 group ${
|
||||||
|
isCurrent
|
||||||
|
? 'border-accent/70 bg-accent/8'
|
||||||
|
: 'border-white/10 hover:border-white/25 hover:bg-white/5 focus-ring'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 p-2">
|
||||||
|
<div className="relative shrink-0 w-[120px] aspect-video rounded-md overflow-hidden bg-black">
|
||||||
|
{thumb ? (
|
||||||
|
<img src={thumb} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-elevated grid place-items-center text-text-4 font-mono text-[11px] tabular-nums">
|
||||||
|
{String(i + 1).padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-black/45">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] font-semibold text-accent">
|
||||||
|
Now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 py-0.5">
|
||||||
|
<p
|
||||||
|
className={`text-[10.5px] uppercase tracking-[0.14em] font-semibold mb-0.5 ${
|
||||||
|
isCurrent ? 'text-accent' : 'text-white/55'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{String(i + 1).padStart(2, '0')} · {formatTimecode(c.StartPositionTicks)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-[13px] font-medium leading-tight tracking-tight line-clamp-2 ${
|
||||||
|
isCurrent ? 'text-white' : 'text-white/90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.Name || `Chapter ${i + 1}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { X, Check, Play } from '../../lib/icons'
|
||||||
|
import { useEpisodes, useItemDetails, useSeasons } from '../../hooks/use-jellyfin'
|
||||||
|
import { useCinemeta } from '../../hooks/use-external'
|
||||||
|
import { getBestImage } from '../../api/jellyfin'
|
||||||
|
import { formatRuntime } from '../../lib/format'
|
||||||
|
import { buildCinemetaEpisodeMap } from '../../api/cinemeta'
|
||||||
|
import { useAnimeFiller, classifyEpisode } from '../../lib/anime-filler'
|
||||||
|
import { EpisodeMetaProvider } from '../../lib/episode-meta-context'
|
||||||
|
import EpisodeRatingChip from '../detail/EpisodeRatingChip'
|
||||||
|
import FillerChip from '../detail/FillerChip'
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
import Select, { type SelectOption } from '../ui/Select'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Netflix-style in-player episode browser. Slides in from the right when the
|
||||||
|
* user clicks the episodes button in the player chrome. Shows the current
|
||||||
|
* season's episodes with thumbnails, runtimes, watch progress, and a season
|
||||||
|
* dropdown at the top. Clicking an episode navigates the player to that
|
||||||
|
* episode's id.
|
||||||
|
*
|
||||||
|
* Mounted at the player's root level so it can sit above the click-capture
|
||||||
|
* layer and the controls bars without fighting their pointer events.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
seriesId: string
|
||||||
|
currentItemId: string
|
||||||
|
/** Falls back to the season currently in the URL if the current item lives elsewhere. */
|
||||||
|
initialSeasonId?: string
|
||||||
|
serverUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EpisodesPanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
seriesId,
|
||||||
|
currentItemId,
|
||||||
|
initialSeasonId,
|
||||||
|
serverUrl,
|
||||||
|
}: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const panelRef = useRef<HTMLElement>(null)
|
||||||
|
const { data: seasons = [] } = useSeasons(seriesId)
|
||||||
|
const [seasonId, setSeasonId] = useState<string | undefined>(initialSeasonId)
|
||||||
|
|
||||||
|
// Pick a sensible default season the first time data lands.
|
||||||
|
useEffect(() => {
|
||||||
|
if (seasonId) return
|
||||||
|
if (initialSeasonId) {
|
||||||
|
setSeasonId(initialSeasonId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (seasons.length > 0) setSeasonId(seasons[0].Id || undefined)
|
||||||
|
}, [seasons, initialSeasonId, seasonId])
|
||||||
|
|
||||||
|
const { data: episodes = [] } = useEpisodes(seriesId, seasonId)
|
||||||
|
// Pull series IMDB / TMDB ids so we can attach rating + filler chips to
|
||||||
|
// the episode rows the same way the detail page does.
|
||||||
|
const { data: seriesItem } = useItemDetails(seriesId)
|
||||||
|
const seriesImdbId = seriesItem?.ProviderIds?.Imdb || null
|
||||||
|
const seriesTmdbId = seriesItem?.ProviderIds?.Tmdb ? Number(seriesItem.ProviderIds.Tmdb) : null
|
||||||
|
const { data: cinemetaSeriesData } = useCinemeta(seriesImdbId, 'series')
|
||||||
|
const cinemetaMap = useMemo(
|
||||||
|
() => buildCinemetaEpisodeMap(cinemetaSeriesData),
|
||||||
|
[cinemetaSeriesData],
|
||||||
|
)
|
||||||
|
const fillerData = useAnimeFiller(seriesTmdbId)
|
||||||
|
const seasonStarts = useMemo(() => {
|
||||||
|
const m = new Map<number, number>()
|
||||||
|
if (!seasons) return m
|
||||||
|
let running = 0
|
||||||
|
const ordered = [...seasons]
|
||||||
|
.filter(s => (s.IndexNumber ?? 0) > 0)
|
||||||
|
.sort((a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0))
|
||||||
|
for (const s of ordered) {
|
||||||
|
const idx = s.IndexNumber ?? 0
|
||||||
|
m.set(idx, running)
|
||||||
|
running += s.ChildCount ?? 0
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}, [seasons])
|
||||||
|
const fillerOf = useMemo(() => {
|
||||||
|
return (season: number | null | undefined, episode: number | null | undefined) => {
|
||||||
|
if (!fillerData || season == null || episode == null) return null
|
||||||
|
const start = seasonStarts.get(season)
|
||||||
|
if (start == null) return null
|
||||||
|
return classifyEpisode(start + episode, fillerData)
|
||||||
|
}
|
||||||
|
}, [fillerData, seasonStarts])
|
||||||
|
const spoilerBlur = usePreferencesStore(s => s.episode.show.spoilerBlur)
|
||||||
|
|
||||||
|
// Specials always last to match the rest of the app.
|
||||||
|
const orderedEpisodes = useMemo(() => {
|
||||||
|
return [...episodes].sort((a: any, b: any) => {
|
||||||
|
const aSpecial = a.ParentIndexNumber === 0
|
||||||
|
const bSpecial = b.ParentIndexNumber === 0
|
||||||
|
if (aSpecial && !bSpecial) return 1
|
||||||
|
if (!aSpecial && bSpecial) return -1
|
||||||
|
return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)
|
||||||
|
})
|
||||||
|
}, [episodes])
|
||||||
|
|
||||||
|
// Close on Escape so the user can dismiss without reaching for the mouse.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
const seasonOptions: SelectOption<string>[] = seasons
|
||||||
|
.filter(s => !!s.Id)
|
||||||
|
.map(s => ({
|
||||||
|
value: s.Id as string,
|
||||||
|
label: s.IndexNumber === 0 ? 'Specials' : s.Name || `Season ${s.IndexNumber ?? '?'}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EpisodeMetaProvider cinemetaMap={cinemetaMap} fillerOf={fillerOf}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop - dims the player area, dismisses on click */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<motion.aside
|
||||||
|
ref={panelRef as any}
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-40 w-[380px] max-w-[85vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
|
||||||
|
data-episodes-panel
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Episodes"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
|
||||||
|
Episodes
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{seasonOptions.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={seasonId || ''}
|
||||||
|
onChange={v => setSeasonId(v)}
|
||||||
|
options={seasonOptions}
|
||||||
|
width="w-full"
|
||||||
|
ariaLabel="Season"
|
||||||
|
portalContainer={panelRef.current}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Episode list */}
|
||||||
|
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-2">
|
||||||
|
{orderedEpisodes.length === 0 && (
|
||||||
|
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">
|
||||||
|
No episodes found in this season.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{orderedEpisodes.map(ep => {
|
||||||
|
const epId = ep.Id || ''
|
||||||
|
const isCurrent = epId === currentItemId
|
||||||
|
const watched = ep.UserData?.Played === true
|
||||||
|
const progressTicks = Number(ep.UserData?.PlaybackPositionTicks ?? 0)
|
||||||
|
const runtimeTicks = Number(ep.RunTimeTicks ?? 0)
|
||||||
|
const progressPct =
|
||||||
|
progressTicks > 0 && runtimeTicks > 0
|
||||||
|
? Math.min(100, (progressTicks / runtimeTicks) * 100)
|
||||||
|
: 0
|
||||||
|
const epNum = ep.IndexNumber
|
||||||
|
const seasonNum = ep.ParentIndexNumber
|
||||||
|
const thumb = getBestImage(serverUrl, ep, 'thumb', 360)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={epId}
|
||||||
|
onClick={() => {
|
||||||
|
if (epId && !isCurrent) {
|
||||||
|
onClose()
|
||||||
|
navigate(`/play/${epId}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isCurrent}
|
||||||
|
className={`w-full text-left rounded-lg overflow-hidden border transition-colors duration-150 group ${
|
||||||
|
isCurrent
|
||||||
|
? 'border-accent/70 bg-accent/8 cursor-default'
|
||||||
|
: 'border-white/10 hover:border-white/25 hover:bg-white/5 focus-ring'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 p-2">
|
||||||
|
<div className="relative shrink-0 w-[120px] aspect-video rounded-md overflow-hidden bg-black">
|
||||||
|
{thumb ? (
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt=""
|
||||||
|
className={`w-full h-full object-cover transition-[filter,transform] duration-300 ${
|
||||||
|
isCurrent ? '' : 'group-hover:scale-[1.04]'
|
||||||
|
} ${
|
||||||
|
spoilerBlur && !watched && !isCurrent
|
||||||
|
? 'blur-[10px] saturate-[0.6] group-hover:blur-0 group-hover:saturate-100'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-elevated" />
|
||||||
|
)}
|
||||||
|
{/* Play overlay on hover (skipped for current episode) */}
|
||||||
|
{!isCurrent && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="w-9 h-9 rounded-full bg-white/95 grid place-items-center text-void">
|
||||||
|
<Play size={14} fill="currentColor" className="translate-x-px" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{watched && !isCurrent && (
|
||||||
|
<span className="absolute top-1 right-1 w-5 h-5 rounded-full bg-accent text-void grid place-items-center">
|
||||||
|
<Check size={11} strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="absolute top-1 left-1 flex gap-1">
|
||||||
|
<FillerChip season={seasonNum} episode={epNum} />
|
||||||
|
</span>
|
||||||
|
{progressPct > 0 && progressPct < 95 && !watched && (
|
||||||
|
<div className="absolute left-0 right-0 bottom-0 h-1 bg-black/45">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-black/45">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] font-semibold text-accent">
|
||||||
|
Now playing
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 py-0.5">
|
||||||
|
<p
|
||||||
|
className={`text-[10.5px] uppercase tracking-[0.14em] font-semibold mb-0.5 ${
|
||||||
|
isCurrent ? 'text-accent' : 'text-white/55'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{seasonNum != null && epNum != null
|
||||||
|
? `S${seasonNum} · E${epNum}`
|
||||||
|
: `Episode ${epNum ?? '?'}`}
|
||||||
|
{runtimeTicks > 0 && (
|
||||||
|
<span className="text-white/40 normal-case tracking-normal font-normal ml-1.5">
|
||||||
|
· {formatRuntime(runtimeTicks)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5 inline-flex">
|
||||||
|
<EpisodeRatingChip season={seasonNum} episode={epNum} />
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-[13px] font-medium leading-tight tracking-tight line-clamp-2 ${
|
||||||
|
isCurrent ? 'text-white' : 'text-white/90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ep.Name || `Episode ${epNum ?? ''}`}
|
||||||
|
</p>
|
||||||
|
{ep.Overview && (
|
||||||
|
<p
|
||||||
|
className={`text-[11.5px] text-white/55 leading-snug mt-1 line-clamp-2 transition-[filter,color] duration-200 ${
|
||||||
|
spoilerBlur && !watched && !isCurrent
|
||||||
|
? 'blur-[5px] text-white/35 hover:blur-0 hover:text-white/55'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ep.Overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</EpisodeMetaProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X } from '../../lib/icons'
|
||||||
|
import { SHORTCUTS, type ShortcutCategory } from '../../lib/player-shortcuts'
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<ShortcutCategory, string> = {
|
||||||
|
playback: 'Playback',
|
||||||
|
audio: 'Audio',
|
||||||
|
subtitles: 'Subtitles',
|
||||||
|
tools: 'Tools',
|
||||||
|
view: 'View',
|
||||||
|
navigation: 'Navigation',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: ShortcutCategory[] = [
|
||||||
|
'playback',
|
||||||
|
'audio',
|
||||||
|
'subtitles',
|
||||||
|
'view',
|
||||||
|
'tools',
|
||||||
|
'navigation',
|
||||||
|
]
|
||||||
|
|
||||||
|
function prettyKey(binding: string): string[] {
|
||||||
|
return binding.split('+').map(p => {
|
||||||
|
if (p === ' ') return 'Space'
|
||||||
|
if (p === 'ArrowLeft') return '←'
|
||||||
|
if (p === 'ArrowRight') return '→'
|
||||||
|
if (p === 'ArrowUp') return '↑'
|
||||||
|
if (p === 'ArrowDown') return '↓'
|
||||||
|
if (p === 'Escape') return 'Esc'
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KeyboardHints({ open, onClose }: Props) {
|
||||||
|
const overrides = usePreferencesStore(s => s.keyboardShortcuts)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' || e.key === '?') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="absolute inset-0 z-toast grid place-items-center bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<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] }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="bg-[#0c0a08]/96 backdrop-blur-2xl border border-white/14 rounded-2xl w-[640px] max-w-[88vw] max-h-[80vh] overflow-y-auto content-scroll shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
>
|
||||||
|
<header className="sticky top-0 px-6 py-4 bg-[#0c0a08]/96 backdrop-blur-2xl border-b border-white/8 flex items-center justify-between z-10">
|
||||||
|
<h2 className="font-display text-xl font-bold text-white tracking-tight">
|
||||||
|
Keyboard shortcuts
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="px-6 py-5 grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
|
||||||
|
{CATEGORY_ORDER.map(cat => {
|
||||||
|
const items = SHORTCUTS.filter(s => s.category === cat)
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={cat}>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-3 leading-none">
|
||||||
|
{CATEGORY_LABELS[cat]}
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{items.map(sc => {
|
||||||
|
const keys = overrides[sc.id]?.length ? overrides[sc.id] : sc.keys
|
||||||
|
return (
|
||||||
|
<li key={sc.id} className="flex items-baseline gap-3">
|
||||||
|
<span className="text-[12.5px] text-text-2 leading-tight flex-1">
|
||||||
|
{sc.description}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
|
{prettyKey(keys[0]).map((k, i) => (
|
||||||
|
<kbd
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center justify-center min-w-[22px] h-6 px-1.5 rounded text-[10.5px] font-mono font-medium text-text-1 bg-elevated border border-border tabular-nums"
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useRef, type RefObject } from 'react'
|
||||||
|
import type { MediaPlayerInstance } from '@vidstack/react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders ASS / SSA subtitles via libass-wasm (SubtitlesOctopus). The
|
||||||
|
* library spawns a Web Worker that compiles libass to WASM and renders
|
||||||
|
* each frame to an offscreen canvas, which it then transfers to the
|
||||||
|
* onscreen canvas we mount over the video.
|
||||||
|
*
|
||||||
|
* Why this exists: Jellyfin can transcode ASS to VTT for browsers that
|
||||||
|
* don't render ASS natively, but VTT loses the things that matter most
|
||||||
|
* about ASS - positioning, fonts, karaoke, overlapping cues, styled
|
||||||
|
* effects. For anime / fansub libraries this is the difference between
|
||||||
|
* "subtitles work" and "subtitles look right".
|
||||||
|
*
|
||||||
|
* The component takes a direct ASS URL (we construct it via
|
||||||
|
* getSubtitleUrl(..., 'ass') so Jellyfin returns the original) and the
|
||||||
|
* underlying <video> element. Mounts a fixed canvas overlay sized to
|
||||||
|
* match the player.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
playerRef: RefObject<MediaPlayerInstance | null>
|
||||||
|
/** URL pointing to a Jellyfin ASS/SSA stream. Pass null to disable. */
|
||||||
|
subtitleUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKER_URL = '/libass/subtitles-octopus-worker.js'
|
||||||
|
const LEGACY_WORKER_URL = '/libass/subtitles-octopus-worker-legacy.js'
|
||||||
|
|
||||||
|
export default function LibAssRenderer({ playerRef, subtitleUrl }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const octopusRef = useRef<any>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
if (!subtitleUrl) return
|
||||||
|
|
||||||
|
function getVideo(): HTMLVideoElement | null {
|
||||||
|
const player = playerRef.current
|
||||||
|
const el = (player as any)?.el as HTMLElement | undefined
|
||||||
|
return (el?.querySelector('video') as HTMLVideoElement | null) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const video = getVideo()
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!video || !canvas) return
|
||||||
|
try {
|
||||||
|
// SubtitlesOctopus is exposed as a global by the dist bundle. We
|
||||||
|
// dynamic-import the npm package so the wasm worker isn't pulled
|
||||||
|
// into the main bundle for users who never see ASS subs.
|
||||||
|
const mod: any = await import('libass-wasm')
|
||||||
|
const SubtitlesOctopus = mod.default || mod.SubtitlesOctopus || (window as any).SubtitlesOctopus
|
||||||
|
if (!SubtitlesOctopus || cancelled) return
|
||||||
|
if (octopusRef.current) {
|
||||||
|
try { octopusRef.current.dispose() } catch { /* noop */ }
|
||||||
|
octopusRef.current = null
|
||||||
|
}
|
||||||
|
octopusRef.current = new SubtitlesOctopus({
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
subUrl: subtitleUrl,
|
||||||
|
workerUrl: WORKER_URL,
|
||||||
|
legacyWorkerUrl: LEGACY_WORKER_URL,
|
||||||
|
// Render at 1.5x for sharper text on high-density displays
|
||||||
|
targetFps: 24,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[libass] failed to init', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (octopusRef.current) {
|
||||||
|
try { octopusRef.current.dispose() } catch { /* noop */ }
|
||||||
|
octopusRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [playerRef, subtitleUrl])
|
||||||
|
|
||||||
|
if (!subtitleUrl) return null
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none z-30"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Shuffle,
|
||||||
|
Repeat,
|
||||||
|
Repeat1,
|
||||||
|
ListMusic,
|
||||||
|
ChevronUp,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Heart,
|
||||||
|
} from '../../lib/icons'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useMusicStore } from '../../stores/music-store'
|
||||||
|
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
|
||||||
|
|
||||||
|
interface MiniPlayerProps {
|
||||||
|
onExpand: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MiniPlayer({ onExpand }: MiniPlayerProps) {
|
||||||
|
const {
|
||||||
|
currentTrack,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
shuffle,
|
||||||
|
repeat,
|
||||||
|
volume,
|
||||||
|
isMuted,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
nextTrack,
|
||||||
|
prevTrack,
|
||||||
|
toggleShuffle,
|
||||||
|
cycleRepeat,
|
||||||
|
toggleMute,
|
||||||
|
setVolume,
|
||||||
|
seekTo,
|
||||||
|
} = useMusicStore()
|
||||||
|
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
|
||||||
|
const imageUrl = currentTrack
|
||||||
|
? getBestImage(serverUrl, currentTrack, 'primary', 120)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const title = currentTrack?.Name || 'Unknown track'
|
||||||
|
const artist = currentTrack?.AlbumArtist || currentTrack?.Artists?.[0] || ''
|
||||||
|
|
||||||
|
const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
function formatTime(s: number): string {
|
||||||
|
if (!s || !isFinite(s)) return '0:00'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const sec = Math.floor(s % 60)
|
||||||
|
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const RepeatIcon = repeat === 'one' ? Repeat1 : Repeat
|
||||||
|
const VolIcon = isMuted || volume === 0 ? VolumeX : Volume2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{currentTrack && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 80, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 80, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="relative shrink-0 z-mini-player"
|
||||||
|
>
|
||||||
|
{/* Top progress sliver - clickable */}
|
||||||
|
<ProgressSliver
|
||||||
|
playedPercent={playedPercent}
|
||||||
|
onSeek={seekTo}
|
||||||
|
duration={duration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="h-[72px] glass-strong border-t border-border flex items-center px-5 gap-4">
|
||||||
|
{/* Left: Art + Info */}
|
||||||
|
<button
|
||||||
|
onClick={onExpand}
|
||||||
|
className="group flex items-center gap-3 min-w-0 w-[260px] focus-ring rounded-lg p-1 -m-1"
|
||||||
|
>
|
||||||
|
<div className="relative w-12 h-12 shrink-0">
|
||||||
|
<div className="absolute inset-0 rounded-md overflow-hidden bg-elevated ring-1 ring-border">
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3">
|
||||||
|
<ListMusic size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Hover affordance */}
|
||||||
|
<div className="absolute inset-0 rounded-md grid place-items-center bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||||
|
<ChevronUp size={16} className="text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<p className="text-[13px] font-medium text-text-1 truncate tracking-tight">{title}</p>
|
||||||
|
<p className="text-[11.5px] text-text-3 truncate">{artist || 'Unknown artist'}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Center: Transport */}
|
||||||
|
<div className="flex-1 flex flex-col items-center max-w-2xl gap-1">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<TransportButton
|
||||||
|
onClick={toggleShuffle}
|
||||||
|
active={shuffle}
|
||||||
|
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
|
||||||
|
>
|
||||||
|
<Shuffle size={14} />
|
||||||
|
</TransportButton>
|
||||||
|
<TransportButton onClick={prevTrack} aria-label="Previous track">
|
||||||
|
<SkipBack size={16} fill="currentColor" />
|
||||||
|
</TransportButton>
|
||||||
|
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<button
|
||||||
|
onClick={isPlaying ? pause : resume}
|
||||||
|
className="relative w-9 h-9 rounded-full bg-text-1 text-void grid place-items-center transition-all duration-150 hover:scale-105 active:scale-95 focus-ring"
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={15} fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<Play size={15} fill="currentColor" className="translate-x-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<TransportButton onClick={nextTrack} aria-label="Next track">
|
||||||
|
<SkipForward size={16} fill="currentColor" />
|
||||||
|
</TransportButton>
|
||||||
|
<TransportButton
|
||||||
|
onClick={cycleRepeat}
|
||||||
|
active={repeat !== 'off'}
|
||||||
|
aria-label={`Repeat: ${repeat}`}
|
||||||
|
>
|
||||||
|
<RepeatIcon size={14} />
|
||||||
|
</TransportButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline scrubber */}
|
||||||
|
<div className="w-full flex items-center gap-2.5 text-[10px] text-text-3 tabular-nums">
|
||||||
|
<span className="w-9 text-right">{formatTime(currentTime)}</span>
|
||||||
|
<Scrubber
|
||||||
|
playedPercent={playedPercent}
|
||||||
|
onSeek={(pct) => seekTo(pct * duration)}
|
||||||
|
/>
|
||||||
|
<span className="w-9">{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Volume + Queue */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<TransportButton
|
||||||
|
onClick={() => {
|
||||||
|
if (currentTrack && currentTrack.UserData) {
|
||||||
|
// local optimistic - UI only
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Favorite"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
size={15}
|
||||||
|
className={currentTrack?.UserData?.IsFavorite ? 'text-accent' : ''}
|
||||||
|
fill={currentTrack?.UserData?.IsFavorite ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</TransportButton>
|
||||||
|
|
||||||
|
{/* Volume control */}
|
||||||
|
<div className="group/vol flex items-center">
|
||||||
|
<TransportButton onClick={toggleMute} aria-label={isMuted ? 'Unmute' : 'Mute'}>
|
||||||
|
<VolIcon size={15} />
|
||||||
|
</TransportButton>
|
||||||
|
<div className="w-0 group-hover/vol:w-24 transition-all duration-300 ease-out overflow-hidden">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={e => setVolume(Number(e.target.value))}
|
||||||
|
className="slider w-22 ml-1"
|
||||||
|
aria-label="Volume"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onExpand}
|
||||||
|
className="ml-1 w-9 h-9 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors focus-ring"
|
||||||
|
aria-label="Expand player"
|
||||||
|
>
|
||||||
|
<ChevronUp size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransportButton({
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={`w-8 h-8 grid place-items-center rounded-md transition-all duration-150 hover:bg-glass-light focus-ring ${
|
||||||
|
active ? 'text-accent' : 'text-text-2 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Scrubber({
|
||||||
|
playedPercent,
|
||||||
|
onSeek,
|
||||||
|
}: {
|
||||||
|
playedPercent: number
|
||||||
|
onSeek: (pct: number) => void
|
||||||
|
}) {
|
||||||
|
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
onSeek(pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className="relative flex-1 h-3 cursor-pointer group/scrub"
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-[3px] group-hover/scrub:h-1.5 rounded-full bg-white/10 transition-all duration-150 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-accent rounded-full"
|
||||||
|
style={{ width: `${playedPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)] opacity-0 group-hover/scrub:opacity-100 transition-opacity duration-150 pointer-events-none"
|
||||||
|
style={{ left: `${playedPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressSliver({
|
||||||
|
playedPercent,
|
||||||
|
onSeek,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
playedPercent: number
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
duration: number
|
||||||
|
}) {
|
||||||
|
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
onSeek(pct * duration)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className="absolute left-0 right-0 -top-px h-0.5 cursor-pointer group/sliver hover:h-1 transition-all duration-150"
|
||||||
|
>
|
||||||
|
<div className="w-full h-full bg-white/5">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all duration-100"
|
||||||
|
style={{ width: `${playedPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Shuffle,
|
||||||
|
Repeat,
|
||||||
|
Repeat1,
|
||||||
|
X,
|
||||||
|
ListMusic,
|
||||||
|
Heart,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
ChevronDown,
|
||||||
|
} from '../../lib/icons'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useMusicStore } from '../../stores/music-store'
|
||||||
|
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
|
||||||
|
|
||||||
|
interface NowPlayingProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NowPlaying({ isOpen, onClose }: NowPlayingProps) {
|
||||||
|
const [showQueue, setShowQueue] = useState(false)
|
||||||
|
const {
|
||||||
|
currentTrack,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
shuffle,
|
||||||
|
repeat,
|
||||||
|
queue,
|
||||||
|
queueIndex,
|
||||||
|
volume,
|
||||||
|
isMuted,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
nextTrack,
|
||||||
|
prevTrack,
|
||||||
|
toggleShuffle,
|
||||||
|
cycleRepeat,
|
||||||
|
seekTo,
|
||||||
|
toggleMute,
|
||||||
|
setVolume,
|
||||||
|
} = useMusicStore()
|
||||||
|
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
|
||||||
|
const imageUrl = currentTrack
|
||||||
|
? getBestImage(serverUrl, currentTrack, 'primary', 800)
|
||||||
|
: ''
|
||||||
|
const bgImageUrl = currentTrack
|
||||||
|
? getBestImage(serverUrl, currentTrack, 'primary', 320)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const title = currentTrack?.Name || 'Unknown track'
|
||||||
|
const artist = currentTrack?.AlbumArtist || currentTrack?.Artists?.[0] || ''
|
||||||
|
const album = currentTrack?.Album || ''
|
||||||
|
|
||||||
|
const RepeatIcon = repeat === 'one' ? Repeat1 : Repeat
|
||||||
|
const VolIcon = isMuted || volume === 0 ? VolumeX : Volume2
|
||||||
|
|
||||||
|
function formatTime(s: number): string {
|
||||||
|
if (!s || !isFinite(s)) return '0:00'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const sec = Math.floor(s % 60)
|
||||||
|
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
function handleScrubClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
seekTo(pct * duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && currentTrack && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="fixed inset-0 z-fullscreen flex"
|
||||||
|
>
|
||||||
|
{/* ── Cinematic background ────────────────────────────── */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
{bgImageUrl && (
|
||||||
|
<>
|
||||||
|
{/* Heavy blurred album art as ambient backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 1.04 }}
|
||||||
|
animate={{ scale: 1.12 }}
|
||||||
|
transition={{ duration: 30, ease: 'linear', repeat: Infinity, repeatType: 'reverse' }}
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${bgImageUrl})`,
|
||||||
|
filter: 'blur(80px) saturate(140%)',
|
||||||
|
opacity: 0.55,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Color wash */}
|
||||||
|
<div className="absolute inset-0 bg-void/65" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-void/40 via-transparent to-void/80" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Header ────────────────────────────────────────── */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-6 pt-5">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="group flex items-center gap-2 text-white/70 hover:text-white transition-colors px-2 py-1 rounded-md focus-ring"
|
||||||
|
>
|
||||||
|
<ChevronDown size={20} strokeWidth={2.25} className="transition-transform duration-200 group-hover:translate-y-0.5" />
|
||||||
|
<span className="text-[12px] font-medium uppercase tracking-[0.14em]">Hide</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.18em] text-white/55 font-semibold">
|
||||||
|
Now playing
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-9 h-9 rounded-full bg-white/8 hover:bg-white/15 backdrop-blur grid place-items-center text-white/70 hover:text-white transition-all duration-200 focus-ring"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main content ─────────────────────────────────── */}
|
||||||
|
<div className="relative flex-1 flex flex-col items-center justify-center px-8 pt-14 pb-10">
|
||||||
|
{/* Album art */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30, scale: 0.94 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
|
||||||
|
className="relative mb-9"
|
||||||
|
>
|
||||||
|
{/* Glow halo */}
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-3xl scale-110" />
|
||||||
|
{/* Reflection-like shadow */}
|
||||||
|
<div className="absolute -bottom-6 left-4 right-4 h-12 bg-black/60 blur-2xl rounded-full opacity-70" />
|
||||||
|
|
||||||
|
<div className="relative w-[300px] h-[300px] md:w-[340px] md:h-[340px] rounded-2xl overflow-hidden ring-1 ring-white/10 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.8)] bg-elevated">
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
|
||||||
|
<ListMusic size={72} className="text-text-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 14 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.3 }}
|
||||||
|
className="text-center mb-7 max-w-2xl"
|
||||||
|
>
|
||||||
|
<h2 className="font-display text-3xl md:text-4xl font-bold text-white tracking-tight mb-2 line-clamp-2">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[15px] text-white/70 truncate">
|
||||||
|
{artist}
|
||||||
|
{album && (
|
||||||
|
<>
|
||||||
|
<span className="text-white/30 mx-2">·</span>
|
||||||
|
<span className="text-white/55">{album}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Scrubber */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
|
||||||
|
className="w-full max-w-md mb-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={handleScrubClick}
|
||||||
|
className="relative h-5 cursor-pointer group/scrub"
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 group-hover/scrub:h-1.5 rounded-full bg-white/15 overflow-hidden transition-all duration-150">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-accent rounded-full"
|
||||||
|
style={{ width: `${playedPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-accent shadow-[0_0_12px_rgba(245,182,66,0.6)] pointer-events-none transition-opacity duration-150 opacity-0 group-hover/scrub:opacity-100"
|
||||||
|
style={{ left: `${playedPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[11px] text-white/55 mt-1.5 tabular-nums font-medium">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Transport */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.5 }}
|
||||||
|
className="flex items-center gap-5 mb-6"
|
||||||
|
>
|
||||||
|
<NPButton onClick={toggleShuffle} active={shuffle} aria-label="Shuffle">
|
||||||
|
<Shuffle size={18} />
|
||||||
|
</NPButton>
|
||||||
|
<NPButton onClick={prevTrack} aria-label="Previous">
|
||||||
|
<SkipBack size={22} fill="currentColor" />
|
||||||
|
</NPButton>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={isPlaying ? pause : resume}
|
||||||
|
className="relative w-16 h-16 rounded-full bg-white text-void grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 shadow-[0_8px_24px_-6px_rgba(0,0,0,0.6)] focus-ring"
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 rounded-full bg-white/30 blur-2xl scale-110 opacity-0 hover:opacity-100 transition-opacity" />
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={26} fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<Play size={26} fill="currentColor" className="translate-x-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NPButton onClick={nextTrack} aria-label="Next">
|
||||||
|
<SkipForward size={22} fill="currentColor" />
|
||||||
|
</NPButton>
|
||||||
|
<NPButton onClick={cycleRepeat} active={repeat !== 'off'} aria-label="Repeat">
|
||||||
|
<RepeatIcon size={18} />
|
||||||
|
</NPButton>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Secondary controls */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.6 }}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<NPButton aria-label="Favorite">
|
||||||
|
<Heart
|
||||||
|
size={16}
|
||||||
|
className={currentTrack.UserData?.IsFavorite ? 'text-accent' : ''}
|
||||||
|
fill={currentTrack.UserData?.IsFavorite ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</NPButton>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="flex items-center gap-2 px-3 h-9 rounded-full bg-white/8 hover:bg-white/12 transition-colors">
|
||||||
|
<button onClick={toggleMute} className="text-white/70 hover:text-white transition-colors">
|
||||||
|
<VolIcon size={14} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={e => setVolume(Number(e.target.value))}
|
||||||
|
className="slider w-24"
|
||||||
|
aria-label="Volume"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQueue(!showQueue)}
|
||||||
|
className={`flex items-center gap-2 h-9 px-3 rounded-full transition-all duration-200 text-[12px] font-medium focus-ring ${
|
||||||
|
showQueue
|
||||||
|
? 'bg-accent/15 text-accent border border-accent/25'
|
||||||
|
: 'bg-white/8 text-white/70 hover:bg-white/12 hover:text-white border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListMusic size={14} />
|
||||||
|
Queue
|
||||||
|
<span className="text-white/40">·</span>
|
||||||
|
<span className="tabular-nums">{queue.length}</span>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Queue panel ───────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showQueue && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: 360, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: 360, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="relative w-[340px] glass-strong border-l border-border overflow-y-auto content-scroll"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 px-6 py-4 bg-glass-strong backdrop-blur-xl border-b border-border z-10">
|
||||||
|
<p className="text-[10px] font-semibold text-text-2 uppercase tracking-[0.14em]">Up next</p>
|
||||||
|
<h3 className="text-[15px] font-semibold text-text-1 tracking-tight mt-0.5">
|
||||||
|
{queue.length} {queue.length === 1 ? 'track' : 'tracks'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-1">
|
||||||
|
{queue.map((track, i) => {
|
||||||
|
const isActive = i === queueIndex
|
||||||
|
const thumbUrl = getBestImage(serverUrl, track, 'primary', 100)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${track.Id}-${i}`}
|
||||||
|
className={`group flex items-center gap-3 p-2 rounded-lg transition-all duration-150 ${
|
||||||
|
isActive ? 'bg-accent/12 ring-1 ring-accent/25' : 'hover:bg-glass-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative w-10 h-10 rounded-md overflow-hidden bg-elevated shrink-0 ring-1 ring-border">
|
||||||
|
{thumbUrl ? (
|
||||||
|
<img src={thumbUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3">
|
||||||
|
<ListMusic size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isActive && isPlaying && <Equalizer />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`text-[13px] truncate font-medium tracking-tight ${isActive ? 'text-accent' : 'text-text-1'}`}>
|
||||||
|
{track.Name || 'Unknown'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-text-3 truncate">
|
||||||
|
{track.AlbumArtist || track.Artists?.[0] || 'Unknown artist'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-text-4 tabular-nums shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{track.RunTimeTicks
|
||||||
|
? formatTime(Number(track.RunTimeTicks) / 10000000)
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NPButton({
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={`w-10 h-10 grid place-items-center rounded-full transition-all duration-200 hover:bg-white/12 focus-ring ${
|
||||||
|
active ? 'text-accent bg-accent/12' : 'text-white/80 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Equalizer() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-black/55 backdrop-blur-sm">
|
||||||
|
<div className="flex items-end gap-[2px] h-3.5">
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-[3px] bg-accent rounded-full origin-bottom"
|
||||||
|
style={{
|
||||||
|
animation: `equalizer-bar 0.9s ease-in-out infinite`,
|
||||||
|
animationDelay: `${i * 0.15}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Palette, RotateCcw } from '../../lib/icons'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
brightness: number
|
||||||
|
contrast: number
|
||||||
|
saturation: number
|
||||||
|
onChange: (key: 'brightness' | 'contrast' | 'saturation', value: number) => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picture-tuning menu with three sliders. Values are 0.5..1.5 (50%..150%).
|
||||||
|
* Applied via CSS `filter` on the underlying <video> element (handled by
|
||||||
|
* the parent so libass canvas overlay isn't tinted along with it).
|
||||||
|
*/
|
||||||
|
export default function PictureMenu({
|
||||||
|
brightness,
|
||||||
|
contrast,
|
||||||
|
saturation,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDoc(e: MouseEvent) {
|
||||||
|
if (!ref.current) return
|
||||||
|
if (!ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('mousedown', onDoc)
|
||||||
|
return () => window.removeEventListener('mousedown', onDoc)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const isModified =
|
||||||
|
Math.abs(brightness - 1) > 0.001 ||
|
||||||
|
Math.abs(contrast - 1) > 0.001 ||
|
||||||
|
Math.abs(saturation - 1) > 0.001
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className={`w-9 h-9 rounded-full grid place-items-center transition-colors focus-ring ${
|
||||||
|
isModified
|
||||||
|
? 'text-accent bg-accent/15'
|
||||||
|
: open
|
||||||
|
? 'bg-white/15 text-white'
|
||||||
|
: 'text-white/85 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="Picture"
|
||||||
|
title="Picture"
|
||||||
|
>
|
||||||
|
<Palette size={17} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
role="dialog"
|
||||||
|
className="absolute right-0 top-full mt-2 w-[280px] bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10 flex items-center justify-between">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
|
||||||
|
Picture
|
||||||
|
</p>
|
||||||
|
{isModified && (
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="inline-flex items-center gap-1 text-[10.5px] text-white/65 hover:text-white transition-colors focus-ring rounded"
|
||||||
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<Slider label="Brightness" value={brightness} onChange={v => onChange('brightness', v)} />
|
||||||
|
<Slider label="Contrast" value={contrast} onChange={v => onChange('contrast', v)} />
|
||||||
|
<Slider label="Saturation" value={saturation} onChange={v => onChange('saturation', v)} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
|
<span className="text-[11.5px] font-medium text-white/80">{label}</span>
|
||||||
|
<span className="text-[11px] font-mono tabular-nums text-white/55">
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={1.5}
|
||||||
|
step={0.01}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(Number(e.target.value))}
|
||||||
|
onDoubleClick={() => onChange(1)}
|
||||||
|
className="slider w-full"
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { ButtonHTMLAttributes, PointerEvent as ReactPointerEvent } from 'react'
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
Volume1,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
} from '../../lib/icons'
|
||||||
|
import ChapterTicks from './ChapterTicks'
|
||||||
|
import TrickplayThumbnail from './TrickplayThumbnail'
|
||||||
|
import { loadBookmarks } from '../../lib/bookmarks'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId: string | undefined
|
||||||
|
item: BaseItemDto | null | undefined
|
||||||
|
serverUrl: string
|
||||||
|
token: string
|
||||||
|
duration: number
|
||||||
|
currentTime: number
|
||||||
|
bufferedPercent: number
|
||||||
|
playedPercent: number
|
||||||
|
scrubPercent: number | null
|
||||||
|
isPaused: boolean
|
||||||
|
isMuted: boolean
|
||||||
|
isFullscreen: boolean
|
||||||
|
volume: number
|
||||||
|
loopA: number | null
|
||||||
|
loopB: number | null
|
||||||
|
chapters: { StartPositionTicks: number; Name?: string | null; ImageTag?: string | null }[]
|
||||||
|
bookmarksRefreshKey: number
|
||||||
|
previousItem: BaseItemDto | null | undefined
|
||||||
|
nextItem: BaseItemDto | null | undefined
|
||||||
|
itemType: string | null | undefined
|
||||||
|
queueActive: boolean
|
||||||
|
onScrubPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => void
|
||||||
|
onScrubPointerLeave: () => void
|
||||||
|
onScrubPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => void
|
||||||
|
onScrubPointerUp: (e: ReactPointerEvent<HTMLDivElement>) => void
|
||||||
|
onChapterJump: (seconds: number) => void
|
||||||
|
onTogglePlay: () => void
|
||||||
|
onPrevious: () => void
|
||||||
|
onNext: () => void
|
||||||
|
onBack10: () => void
|
||||||
|
onForward10: () => void
|
||||||
|
onToggleMute: () => void
|
||||||
|
onVolumeChange: (v: number) => void
|
||||||
|
onToggleFullscreen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerBottomBar({
|
||||||
|
itemId,
|
||||||
|
item,
|
||||||
|
serverUrl,
|
||||||
|
token,
|
||||||
|
duration,
|
||||||
|
currentTime,
|
||||||
|
bufferedPercent,
|
||||||
|
playedPercent,
|
||||||
|
scrubPercent,
|
||||||
|
isPaused,
|
||||||
|
isMuted,
|
||||||
|
isFullscreen,
|
||||||
|
volume,
|
||||||
|
loopA,
|
||||||
|
loopB,
|
||||||
|
chapters,
|
||||||
|
bookmarksRefreshKey,
|
||||||
|
previousItem,
|
||||||
|
nextItem,
|
||||||
|
itemType,
|
||||||
|
queueActive,
|
||||||
|
onScrubPointerMove,
|
||||||
|
onScrubPointerLeave,
|
||||||
|
onScrubPointerDown,
|
||||||
|
onScrubPointerUp,
|
||||||
|
onChapterJump,
|
||||||
|
onTogglePlay,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onBack10,
|
||||||
|
onForward10,
|
||||||
|
onToggleMute,
|
||||||
|
onVolumeChange,
|
||||||
|
onToggleFullscreen,
|
||||||
|
}: Props) {
|
||||||
|
const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2
|
||||||
|
const isEpisode = itemType === 'Episode'
|
||||||
|
const showPrev = !!(previousItem || queueActive || isEpisode)
|
||||||
|
const showNext = !!(nextItem || queueActive || isEpisode)
|
||||||
|
const trickplay = (item as { Trickplay?: unknown } | null | undefined)?.Trickplay
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 10, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 10, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
data-player-bottom-bar
|
||||||
|
className="bg-gradient-to-t from-black/85 via-black/40 to-transparent pt-12 pb-5 px-7 pointer-events-auto"
|
||||||
|
>
|
||||||
|
{/* Scrubber */}
|
||||||
|
<div
|
||||||
|
className="relative h-5 mb-1 cursor-pointer group/scrub touch-none"
|
||||||
|
onPointerMove={onScrubPointerMove}
|
||||||
|
onPointerLeave={onScrubPointerLeave}
|
||||||
|
onPointerDown={onScrubPointerDown}
|
||||||
|
onPointerUp={onScrubPointerUp}
|
||||||
|
onPointerCancel={onScrubPointerUp}
|
||||||
|
>
|
||||||
|
{/* Track */}
|
||||||
|
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 group-hover/scrub:h-1.5 transition-all duration-150 rounded-full bg-white/15 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-white/25 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${bufferedPercent}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-accent rounded-full"
|
||||||
|
style={{ width: `${playedPercent}%` }}
|
||||||
|
/>
|
||||||
|
{scrubPercent !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-white/15 rounded-full pointer-events-none"
|
||||||
|
style={{ width: `${scrubPercent * 100}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{duration > 0 && loopA != null && loopB != null && loopB > loopA && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 bg-accent/40 ring-1 ring-accent/60 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: `${(loopA / duration) * 100}%`,
|
||||||
|
width: `${((loopB - loopA) / duration) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Bookmark dots */}
|
||||||
|
{itemId && duration > 0 && loadBookmarks(itemId).map(bm => {
|
||||||
|
void bookmarksRefreshKey
|
||||||
|
const left = (bm.positionSec / duration) * 100
|
||||||
|
if (left < 0 || left > 100) return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={bm.id}
|
||||||
|
title={bm.note || `Bookmark @ ${bm.positionSec.toFixed(0)}s`}
|
||||||
|
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-cool ring-2 ring-cool/40 pointer-events-none"
|
||||||
|
style={{ left: `${left}%` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Chapter ticks */}
|
||||||
|
{itemId && (
|
||||||
|
<ChapterTicks
|
||||||
|
chapters={chapters}
|
||||||
|
duration={duration}
|
||||||
|
onJump={onChapterJump}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Thumb */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-accent shadow-[0_0_12px_rgba(245,182,66,0.7)] opacity-0 group-hover/scrub:opacity-100 transition-opacity duration-150 pointer-events-none"
|
||||||
|
style={{ left: `${playedPercent}%` }}
|
||||||
|
/>
|
||||||
|
{/* Time tooltip + trickplay */}
|
||||||
|
{scrubPercent !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-6 -translate-x-1/2 flex flex-col items-center gap-1 pointer-events-none"
|
||||||
|
style={{ left: `${scrubPercent * 100}%` }}
|
||||||
|
>
|
||||||
|
{!!trickplay && item && (
|
||||||
|
<TrickplayThumbnail
|
||||||
|
item={item}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
token={token}
|
||||||
|
timeSec={scrubPercent * duration}
|
||||||
|
displayWidth={200}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="px-2 py-1 rounded-md bg-black/85 text-white text-[11px] font-medium tabular-nums whitespace-nowrap">
|
||||||
|
{formatTime(scrubPercent * duration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls row */}
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{showPrev && (
|
||||||
|
<button
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={!previousItem?.Id}
|
||||||
|
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
|
aria-label="Previous"
|
||||||
|
title={previousItem ? `Previous: ${previousItem.Name || ''}` : 'No previous'}
|
||||||
|
>
|
||||||
|
<SkipBack size={16} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onTogglePlay}
|
||||||
|
className="w-11 h-11 rounded-full grid place-items-center text-white hover:bg-white/10 transition-all duration-150 focus-ring"
|
||||||
|
aria-label={isPaused ? 'Play' : 'Pause'}
|
||||||
|
>
|
||||||
|
{isPaused ? (
|
||||||
|
<Play size={20} fill="currentColor" className="translate-x-0.5" />
|
||||||
|
) : (
|
||||||
|
<Pause size={20} fill="currentColor" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showNext && (
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!nextItem?.Id || nextItem.Id === item?.Id}
|
||||||
|
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
|
aria-label="Next"
|
||||||
|
title={nextItem ? `Next: ${nextItem.Name || ''}` : 'No next'}
|
||||||
|
>
|
||||||
|
<SkipForward size={16} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onBack10}
|
||||||
|
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring relative"
|
||||||
|
aria-label="Back 10 seconds"
|
||||||
|
>
|
||||||
|
<RotateCcw size={17} />
|
||||||
|
<span className="absolute text-[8px] font-bold tabular-nums">10</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onForward10}
|
||||||
|
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring relative"
|
||||||
|
aria-label="Forward 10 seconds"
|
||||||
|
>
|
||||||
|
<RotateCw size={17} />
|
||||||
|
<span className="absolute text-[8px] font-bold tabular-nums">10</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Volume cluster */}
|
||||||
|
<div className="group/vol flex items-center ml-1">
|
||||||
|
<button
|
||||||
|
onClick={onToggleMute}
|
||||||
|
className="w-9 h-9 grid place-items-center text-white/85 hover:text-white rounded-full hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
<VolumeIcon size={17} />
|
||||||
|
</button>
|
||||||
|
<div className="w-0 group-hover/vol:w-24 overflow-x-clip overflow-y-visible transition-all duration-300 ease-out">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={e => onVolumeChange(Number(e.target.value))}
|
||||||
|
className="slider w-22 ml-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<div className="ml-3 text-[12px] text-white/85 font-medium tabular-nums">
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
<span className="text-white/40 mx-1.5">/</span>
|
||||||
|
<span className="text-white/55">{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ChromeButton onClick={onToggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
|
||||||
|
{isFullscreen ? <Minimize size={17} /> : <Maximize size={17} />}
|
||||||
|
</ChromeButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChromeButton({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={`w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { ButtonHTMLAttributes } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
AudioLines,
|
||||||
|
Bookmark,
|
||||||
|
Download,
|
||||||
|
Info,
|
||||||
|
List,
|
||||||
|
ListDetails,
|
||||||
|
Moon,
|
||||||
|
PictureInPicture2,
|
||||||
|
Search,
|
||||||
|
Subtitles,
|
||||||
|
Users,
|
||||||
|
} from '../../lib/icons'
|
||||||
|
import SpeedMenu from './SpeedMenu'
|
||||||
|
import QualityMenu, { type QualityOption } from './QualityMenu'
|
||||||
|
import TrackMenu from './TrackMenu'
|
||||||
|
import SubtitleStyleMenu from './SubtitleStyleMenu'
|
||||||
|
import PictureMenu from './PictureMenu'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
|
||||||
|
interface TrackInfo {
|
||||||
|
Index?: number
|
||||||
|
Codec?: string | null
|
||||||
|
Profile?: string | null
|
||||||
|
Language?: string | null
|
||||||
|
Title?: string | null
|
||||||
|
Channels?: number | null
|
||||||
|
ChannelLayout?: string | null
|
||||||
|
IsDefault?: boolean | null
|
||||||
|
IsForced?: boolean | null
|
||||||
|
IsHearingImpaired?: boolean | null
|
||||||
|
IsExternal?: boolean | null
|
||||||
|
AudioSpatialFormat?: string | null
|
||||||
|
Type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto | null | undefined
|
||||||
|
playbackRate: number
|
||||||
|
onPlaybackRateChange: (rate: number) => void
|
||||||
|
qualityKey: string
|
||||||
|
onQualitySelect: (q: QualityOption) => void
|
||||||
|
onPictureInPicture: () => void
|
||||||
|
audioTracks: TrackInfo[]
|
||||||
|
audioIndex: number | null
|
||||||
|
onAudioSelect: (i: number | null) => void
|
||||||
|
subtitleTracks: TrackInfo[]
|
||||||
|
subtitleIndex: number | null
|
||||||
|
onSubtitleSelect: (i: number | null) => void
|
||||||
|
hasSeries: boolean
|
||||||
|
episodesOpen: boolean
|
||||||
|
onToggleEpisodes: () => void
|
||||||
|
hasChapters: boolean
|
||||||
|
chaptersOpen: boolean
|
||||||
|
onToggleChapters: () => void
|
||||||
|
bookmarksOpen: boolean
|
||||||
|
onToggleBookmarks: () => void
|
||||||
|
subSearchOpen: boolean
|
||||||
|
onToggleSubSearch: () => void
|
||||||
|
syncPlayOpen: boolean
|
||||||
|
onToggleSyncPlay: () => void
|
||||||
|
syncPlayActive: boolean
|
||||||
|
videoBrightness: number
|
||||||
|
videoContrast: number
|
||||||
|
videoSaturation: number
|
||||||
|
onPictureChange: (key: 'brightness' | 'contrast' | 'saturation', value: number) => void
|
||||||
|
onPictureReset: () => void
|
||||||
|
streamInfoOpen: boolean
|
||||||
|
onToggleStreamInfo: () => void
|
||||||
|
onBack: () => void
|
||||||
|
sleepRemainingSec?: number
|
||||||
|
onDownload?: () => void
|
||||||
|
isDownloaded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerTopBar({
|
||||||
|
item,
|
||||||
|
playbackRate,
|
||||||
|
onPlaybackRateChange,
|
||||||
|
qualityKey,
|
||||||
|
onQualitySelect,
|
||||||
|
onPictureInPicture,
|
||||||
|
audioTracks,
|
||||||
|
audioIndex,
|
||||||
|
onAudioSelect,
|
||||||
|
subtitleTracks,
|
||||||
|
subtitleIndex,
|
||||||
|
onSubtitleSelect,
|
||||||
|
hasSeries,
|
||||||
|
episodesOpen,
|
||||||
|
onToggleEpisodes,
|
||||||
|
hasChapters,
|
||||||
|
chaptersOpen,
|
||||||
|
onToggleChapters,
|
||||||
|
bookmarksOpen,
|
||||||
|
onToggleBookmarks,
|
||||||
|
subSearchOpen,
|
||||||
|
onToggleSubSearch,
|
||||||
|
syncPlayOpen,
|
||||||
|
onToggleSyncPlay,
|
||||||
|
syncPlayActive,
|
||||||
|
videoBrightness,
|
||||||
|
videoContrast,
|
||||||
|
videoSaturation,
|
||||||
|
onPictureChange,
|
||||||
|
onPictureReset,
|
||||||
|
streamInfoOpen,
|
||||||
|
onToggleStreamInfo,
|
||||||
|
onBack,
|
||||||
|
sleepRemainingSec,
|
||||||
|
onDownload,
|
||||||
|
isDownloaded,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -10, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -10, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
data-player-top-bar
|
||||||
|
className="bg-gradient-to-b from-black/75 via-black/30 to-transparent pt-5 pb-12 px-7 flex items-start justify-between pointer-events-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="group flex items-center gap-3 text-white/85 hover:text-white transition-colors focus-ring rounded-md p-1 -m-1"
|
||||||
|
>
|
||||||
|
<span className="w-9 h-9 rounded-full bg-black/40 backdrop-blur grid place-items-center group-hover:bg-black/60 transition-colors">
|
||||||
|
<ArrowLeft size={17} strokeWidth={2.25} />
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:flex flex-col items-start leading-tight">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.14em] text-white/55 font-semibold">
|
||||||
|
Now playing
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] font-medium tracking-tight">{item?.Name || 'Untitled'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<SpeedMenu speed={playbackRate} onChange={onPlaybackRateChange} />
|
||||||
|
<QualityMenu selectedKey={qualityKey} onSelect={onQualitySelect} />
|
||||||
|
{onDownload && (
|
||||||
|
<ChromeButton
|
||||||
|
onClick={onDownload}
|
||||||
|
aria-label={isDownloaded ? 'Downloaded' : 'Download'}
|
||||||
|
title={isDownloaded ? 'Downloaded' : 'Download'}
|
||||||
|
className={isDownloaded ? 'text-accent' : ''}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ChromeButton>
|
||||||
|
)}
|
||||||
|
{sleepRemainingSec != null && sleepRemainingSec > 0 && (
|
||||||
|
<span
|
||||||
|
className="hidden sm:inline-flex items-center gap-1 h-7 px-2 rounded-full bg-black/40 backdrop-blur text-[10.5px] text-white/70 font-mono tabular-nums"
|
||||||
|
title="Sleep timer active"
|
||||||
|
>
|
||||||
|
<Moon size={12} />
|
||||||
|
{formatSleep(sleepRemainingSec)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChromeButton onClick={onPictureInPicture} aria-label="Picture in picture">
|
||||||
|
<PictureInPicture2 size={17} />
|
||||||
|
</ChromeButton>
|
||||||
|
<TrackMenu
|
||||||
|
trigger={<AudioLines size={17} />}
|
||||||
|
title="Audio"
|
||||||
|
tracks={audioTracks}
|
||||||
|
selectedIndex={audioIndex ?? (audioTracks.find(t => t.IsDefault)?.Index ?? null)}
|
||||||
|
onSelect={onAudioSelect}
|
||||||
|
variant="audio"
|
||||||
|
/>
|
||||||
|
<TrackMenu
|
||||||
|
trigger={<Subtitles size={17} />}
|
||||||
|
title="Subtitles"
|
||||||
|
tracks={subtitleTracks}
|
||||||
|
selectedIndex={subtitleIndex}
|
||||||
|
onSelect={onSubtitleSelect}
|
||||||
|
variant="subtitle"
|
||||||
|
/>
|
||||||
|
<SubtitleStyleMenu />
|
||||||
|
{subtitleIndex != null && (
|
||||||
|
<ChromeButton
|
||||||
|
onClick={onToggleSubSearch}
|
||||||
|
aria-label="Search subtitles"
|
||||||
|
aria-pressed={subSearchOpen}
|
||||||
|
className={subSearchOpen ? 'bg-white/15 text-white' : ''}
|
||||||
|
>
|
||||||
|
<Search size={17} />
|
||||||
|
</ChromeButton>
|
||||||
|
)}
|
||||||
|
{hasSeries && (
|
||||||
|
<ChromeButton
|
||||||
|
onClick={onToggleEpisodes}
|
||||||
|
aria-label="Episodes"
|
||||||
|
aria-pressed={episodesOpen}
|
||||||
|
className={episodesOpen ? 'bg-white/15 text-white' : ''}
|
||||||
|
>
|
||||||
|
<ListDetails size={17} />
|
||||||
|
</ChromeButton>
|
||||||
|
)}
|
||||||
|
{hasChapters && (
|
||||||
|
<ChromeButton
|
||||||
|
onClick={onToggleChapters}
|
||||||
|
aria-label="Chapters"
|
||||||
|
aria-pressed={chaptersOpen}
|
||||||
|
className={chaptersOpen ? 'bg-white/15 text-white' : ''}
|
||||||
|
>
|
||||||
|
<List size={17} />
|
||||||
|
</ChromeButton>
|
||||||
|
)}
|
||||||
|
<ChromeButton
|
||||||
|
onClick={onToggleBookmarks}
|
||||||
|
aria-label="Bookmarks"
|
||||||
|
aria-pressed={bookmarksOpen}
|
||||||
|
className={bookmarksOpen ? 'bg-white/15 text-white' : ''}
|
||||||
|
>
|
||||||
|
<Bookmark size={17} />
|
||||||
|
</ChromeButton>
|
||||||
|
<ChromeButton
|
||||||
|
onClick={onToggleSyncPlay}
|
||||||
|
aria-label="Watch party"
|
||||||
|
aria-pressed={syncPlayOpen}
|
||||||
|
className={syncPlayOpen || syncPlayActive ? 'bg-white/15 text-white' : ''}
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<Users size={17} />
|
||||||
|
{syncPlayActive && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ChromeButton>
|
||||||
|
<PictureMenu
|
||||||
|
brightness={videoBrightness}
|
||||||
|
contrast={videoContrast}
|
||||||
|
saturation={videoSaturation}
|
||||||
|
onChange={onPictureChange}
|
||||||
|
onReset={onPictureReset}
|
||||||
|
/>
|
||||||
|
<ChromeButton
|
||||||
|
onClick={onToggleStreamInfo}
|
||||||
|
aria-label="Stream info"
|
||||||
|
aria-pressed={streamInfoOpen}
|
||||||
|
className={streamInfoOpen ? 'bg-white/15 text-white' : ''}
|
||||||
|
>
|
||||||
|
<Info size={17} />
|
||||||
|
</ChromeButton>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChromeButton({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={`w-9 h-9 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur grid place-items-center text-white/85 hover:text-white transition-colors focus-ring ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSleep(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = sec % 60
|
||||||
|
if (m >= 60) return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Adjustments, Check } from '../../lib/icons'
|
||||||
|
|
||||||
|
export interface QualityOption {
|
||||||
|
/** Stable key for the menu */
|
||||||
|
key: string
|
||||||
|
/** Bitrate cap in bps to send as MaxStreamingBitrate. 0 = "unlimited" (Original). */
|
||||||
|
bitrate: number
|
||||||
|
label: string
|
||||||
|
/** Optional sub-text shown smaller next to the label */
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUALITIES: QualityOption[] = [
|
||||||
|
{ key: 'auto', bitrate: 120_000_000, label: 'Auto', hint: 'Best for connection' },
|
||||||
|
{ key: 'original', bitrate: 0, label: 'Original', hint: 'Direct stream' },
|
||||||
|
{ key: '4k', bitrate: 20_000_000, label: '4K', hint: '20 Mbps' },
|
||||||
|
{ key: '1080', bitrate: 10_000_000, label: '1080p', hint: '10 Mbps' },
|
||||||
|
{ key: '720', bitrate: 4_000_000, label: '720p', hint: '4 Mbps' },
|
||||||
|
{ key: '480', bitrate: 2_000_000, label: '480p', hint: '2 Mbps' },
|
||||||
|
{ key: '360', bitrate: 800_000, label: '360p', hint: '800 Kbps' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedKey: string
|
||||||
|
onSelect: (q: QualityOption) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QualityMenu({ selectedKey, onSelect }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDoc(e: MouseEvent) {
|
||||||
|
if (!ref.current) return
|
||||||
|
if (!ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('mousedown', onDoc)
|
||||||
|
return () => window.removeEventListener('mousedown', onDoc)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const selected = QUALITIES.find(q => q.key === selectedKey) || QUALITIES[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className={`w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring ${
|
||||||
|
open ? 'bg-white/15 text-white' : ''
|
||||||
|
}`}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`Quality: ${selected.label}`}
|
||||||
|
title={`Quality: ${selected.label}`}
|
||||||
|
>
|
||||||
|
<Adjustments size={17} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
role="listbox"
|
||||||
|
className="absolute right-0 top-full mt-2 w-56 bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
|
||||||
|
Quality
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-1">
|
||||||
|
{QUALITIES.map(q => {
|
||||||
|
const sel = q.key === selectedKey
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={q.key}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(q)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
aria-selected={sel}
|
||||||
|
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left transition-colors duration-100 focus-ring ${
|
||||||
|
sel ? 'bg-accent/15' : 'hover:bg-white/8'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-4 h-4 grid place-items-center shrink-0 ${
|
||||||
|
sel ? 'text-accent' : 'text-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check size={13} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`block text-[12.5px] font-medium ${
|
||||||
|
sel ? 'text-accent' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{q.label}
|
||||||
|
</span>
|
||||||
|
{q.hint && (
|
||||||
|
<span className="block text-[10.5px] text-white/55 truncate">{q.hint}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { QUALITIES }
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Play, X } from '../../lib/icons'
|
||||||
|
import { getImageUrl } from '../../api/jellyfin'
|
||||||
|
import { jellyfinClient } from '../../api/jellyfin'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
previousEpisodes: BaseItemDto[]
|
||||||
|
daysSinceLastWatch: number | null
|
||||||
|
onDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_DISMISS_SEC = 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Previously on..." card shown before playback starts when the user is
|
||||||
|
* returning to a series after a long break. Surfaces stills + summaries
|
||||||
|
* of the 1-2 prior episodes so they re-enter the story without rewatching.
|
||||||
|
*
|
||||||
|
* Auto-dismisses after 12 seconds; clicking anywhere outside the cards
|
||||||
|
* (or the X) dismisses early.
|
||||||
|
*/
|
||||||
|
export default function RecapCard({ open, previousEpisodes, daysSinceLastWatch, onDismiss }: Props) {
|
||||||
|
const [countdown, setCountdown] = useState(AUTO_DISMISS_SEC)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setCountdown(AUTO_DISMISS_SEC)
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setCountdown(c => {
|
||||||
|
if (c <= 1) {
|
||||||
|
clearInterval(id)
|
||||||
|
onDismiss()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, onDismiss])
|
||||||
|
|
||||||
|
const auth = jellyfinClient.getAuthState()
|
||||||
|
const serverUrl = auth?.serverUrl || ''
|
||||||
|
|
||||||
|
const gapLabel = (() => {
|
||||||
|
if (daysSinceLastWatch == null) return null
|
||||||
|
if (daysSinceLastWatch < 30) return `${Math.round(daysSinceLastWatch)} days`
|
||||||
|
const months = Math.round(daysSinceLastWatch / 30)
|
||||||
|
if (months < 12) return `${months} ${months === 1 ? 'month' : 'months'}`
|
||||||
|
const years = Math.round(daysSinceLastWatch / 365 * 10) / 10
|
||||||
|
return `${years} ${years === 1 ? 'year' : 'years'}`
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.22 }}
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="absolute inset-0 z-30 grid place-items-center bg-black/65 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 24, scale: 0.96 }}
|
||||||
|
animate={{ y: 0, scale: 1 }}
|
||||||
|
exit={{ y: 24, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="bg-glass-strong backdrop-blur-2xl border border-white/14 rounded-2xl p-7 w-[min(720px,92vw)] shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss recap"
|
||||||
|
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full text-white/60 hover:text-white hover:bg-white/10 transition focus-ring"
|
||||||
|
>
|
||||||
|
<X size={14} stroke={2} />
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-white/60 mb-1">
|
||||||
|
Previously on
|
||||||
|
</p>
|
||||||
|
{gapLabel && (
|
||||||
|
<p className="text-[12px] text-white/45 mb-5 tracking-tight">
|
||||||
|
It has been {gapLabel} since you last watched
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`grid gap-3 mb-6 ${previousEpisodes.length > 1 ? 'sm:grid-cols-2' : ''}`}>
|
||||||
|
{previousEpisodes.map(ep => {
|
||||||
|
const tag = ep.ImageTags?.Primary
|
||||||
|
const thumb = tag
|
||||||
|
? getImageUrl(serverUrl, ep.Id!, 'Primary', 480, tag)
|
||||||
|
: null
|
||||||
|
const sLabel = ep.ParentIndexNumber != null && ep.IndexNumber != null
|
||||||
|
? `S${ep.ParentIndexNumber} · E${ep.IndexNumber}`
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
|
<div key={ep.Id} className="rounded-xl overflow-hidden bg-black/30 ring-1 ring-white/10">
|
||||||
|
{thumb && (
|
||||||
|
<div className="aspect-video bg-black/40 overflow-hidden">
|
||||||
|
<img src={thumb} alt={ep.Name || ''} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-3.5">
|
||||||
|
{sLabel && (
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-accent/90 mb-1">
|
||||||
|
{sLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[13.5px] font-semibold text-white/95 mb-1.5 leading-tight tracking-tight">
|
||||||
|
{ep.Name}
|
||||||
|
</p>
|
||||||
|
{ep.Overview && (
|
||||||
|
<p className="text-[11.5px] text-white/65 leading-relaxed line-clamp-3">
|
||||||
|
{ep.Overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
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)]"
|
||||||
|
>
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
Got it
|
||||||
|
<span className="text-void/65 tabular-nums text-[11.5px]">({countdown}s)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Play, RotateCcw } from '../../lib/icons'
|
||||||
|
import { formatRuntime } from '../../lib/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
positionTicks: number
|
||||||
|
/** ISO timestamp of the last play, if known. Renders "Last watched X
|
||||||
|
* ago" under the main message so the user remembers when they were
|
||||||
|
* on this item - useful when picking up after a long break or coming
|
||||||
|
* back from another device. */
|
||||||
|
lastPlayedDate?: string | null
|
||||||
|
onResume: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeAgo(iso: string): string {
|
||||||
|
const then = Date.parse(iso)
|
||||||
|
if (!Number.isFinite(then)) return ''
|
||||||
|
const ms = Date.now() - then
|
||||||
|
if (ms < 0) return ''
|
||||||
|
const minutes = Math.floor(ms / 60_000)
|
||||||
|
if (minutes < 1) return 'just now'
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`
|
||||||
|
const weeks = Math.floor(days / 7)
|
||||||
|
if (weeks < 5) return `${weeks} week${weeks === 1 ? '' : 's'} ago`
|
||||||
|
const months = Math.floor(days / 30)
|
||||||
|
if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`
|
||||||
|
const years = Math.floor(days / 365)
|
||||||
|
return `${years} year${years === 1 ? '' : 's'} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_CONFIRM_SEC = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight overlay shown on player entry when a resume position exists
|
||||||
|
* and the user has the "ask before resuming" pref on. Two buttons -
|
||||||
|
* Resume from M:SS / Start over - with a 6-second auto-confirm on Resume
|
||||||
|
* so it doesn't feel like a wall.
|
||||||
|
*/
|
||||||
|
export default function ResumePrompt({ open, positionTicks, lastPlayedDate, onResume, onRestart }: Props) {
|
||||||
|
const [countdown, setCountdown] = useState(AUTO_CONFIRM_SEC)
|
||||||
|
const ago = lastPlayedDate ? formatRelativeAgo(lastPlayedDate) : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setCountdown(AUTO_CONFIRM_SEC)
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setCountdown(c => {
|
||||||
|
if (c <= 1) {
|
||||||
|
clearInterval(id)
|
||||||
|
onResume()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, onResume])
|
||||||
|
|
||||||
|
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/55 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 24, scale: 0.96 }}
|
||||||
|
animate={{ y: 0, scale: 1 }}
|
||||||
|
exit={{ y: 24, 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 max-w-md text-center shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-white/60 mb-2">
|
||||||
|
Continue watching
|
||||||
|
</p>
|
||||||
|
<p className="text-[16px] text-white/90 mb-1 tracking-tight">
|
||||||
|
You left off at <span className="font-display font-bold text-accent tabular-nums">{formatRuntime(positionTicks)}</span>
|
||||||
|
</p>
|
||||||
|
{ago && (
|
||||||
|
<p className="text-[11.5px] text-white/55 mb-6 tracking-tight">
|
||||||
|
Last watched {ago}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!ago && <div className="mb-6" />}
|
||||||
|
<div className="flex items-center gap-2.5 justify-center flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={onResume}
|
||||||
|
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)]"
|
||||||
|
>
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
Resume
|
||||||
|
<span className="text-void/65 tabular-nums text-[11.5px]">({countdown}s)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRestart}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} stroke={2} />
|
||||||
|
Start over
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Check } from '../../lib/icons'
|
||||||
|
|
||||||
|
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
speed: number
|
||||||
|
onChange: (speed: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback-speed dropdown. Trigger shows the current speed badge in the
|
||||||
|
* top bar; clicking opens a small radio-style menu. Same chrome look as
|
||||||
|
* the existing TrackMenu so it slots in next to Audio / Subtitles.
|
||||||
|
*/
|
||||||
|
export default function SpeedMenu({ speed, onChange }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDoc(e: MouseEvent) {
|
||||||
|
if (!ref.current) return
|
||||||
|
if (!ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('mousedown', onDoc)
|
||||||
|
return () => window.removeEventListener('mousedown', onDoc)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const display = speed === 1 ? '1×' : `${speed}×`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className={`min-w-[44px] h-9 px-2 rounded-full grid place-items-center text-[12px] font-mono font-semibold tabular-nums transition-colors focus-ring ${
|
||||||
|
speed !== 1 ? 'bg-accent/20 text-accent' : 'text-white/85 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`Playback speed ${display}`}
|
||||||
|
title={`Playback speed: ${display}`}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
role="listbox"
|
||||||
|
className="absolute right-0 top-full mt-2 w-44 bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
|
||||||
|
Playback speed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-1">
|
||||||
|
{SPEEDS.map(s => {
|
||||||
|
const selected = Math.abs(speed - s) < 0.001
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(s)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left transition-colors duration-100 focus-ring ${
|
||||||
|
selected ? 'bg-accent/15' : 'hover:bg-white/8'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-4 h-4 grid place-items-center shrink-0 ${
|
||||||
|
selected ? 'text-accent' : 'text-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check size={13} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`flex-1 text-[12.5px] font-mono tabular-nums ${
|
||||||
|
selected ? 'text-accent font-semibold' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === 1 ? 'Normal (1×)' : `${s}×`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import {
|
||||||
|
pickPrimarySource,
|
||||||
|
getVideoStream,
|
||||||
|
getAudioStreams,
|
||||||
|
getSubtitleStreams,
|
||||||
|
videoCodecLabel,
|
||||||
|
audioFormatLabel,
|
||||||
|
framerateLabel,
|
||||||
|
resolutionLabel,
|
||||||
|
videoRangeLabel,
|
||||||
|
} from '../../lib/jellyfin-meta'
|
||||||
|
import { formatBitrate } from '../../lib/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item?: BaseItemDto | null
|
||||||
|
visible: boolean
|
||||||
|
/** PlayMethod from PlaybackInfo so users see whether they're transcoding. */
|
||||||
|
playMethod?: 'DirectPlay' | 'DirectStream' | 'Transcode' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveStats {
|
||||||
|
bufferAheadSec: number | null
|
||||||
|
droppedFrames: number | null
|
||||||
|
totalFrames: number | null
|
||||||
|
currentFps: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HwDecodeState {
|
||||||
|
supported: boolean | null
|
||||||
|
hwAccelerated: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLiveStats(): LiveStats {
|
||||||
|
const video = document.querySelector('video') as HTMLVideoElement | null
|
||||||
|
if (!video) return { bufferAheadSec: null, droppedFrames: null, totalFrames: null, currentFps: null }
|
||||||
|
let bufferAhead: number | null = null
|
||||||
|
try {
|
||||||
|
const buf = video.buffered
|
||||||
|
if (buf && buf.length) {
|
||||||
|
const t = video.currentTime
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
if (t >= buf.start(i) && t <= buf.end(i)) {
|
||||||
|
bufferAhead = Math.max(0, buf.end(i) - t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* timeranges throws sometimes */ }
|
||||||
|
const q = (video as any).getVideoPlaybackQuality?.()
|
||||||
|
return {
|
||||||
|
bufferAheadSec: bufferAhead,
|
||||||
|
droppedFrames: q?.droppedVideoFrames ?? null,
|
||||||
|
totalFrames: q?.totalVideoFrames ?? null,
|
||||||
|
currentFps: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHwDecode(stream: { codec?: string | null; width?: number | null; height?: number | null; bitrate?: number | null } | null): Promise<HwDecodeState> {
|
||||||
|
if (!stream || typeof (navigator as any).mediaCapabilities?.decodingInfo !== 'function') {
|
||||||
|
return { supported: null, hwAccelerated: null }
|
||||||
|
}
|
||||||
|
const codec = (stream.codec || stream.Codec || '').toLowerCase()
|
||||||
|
const w = stream.width ?? stream.Width ?? 1920
|
||||||
|
const h = stream.height ?? stream.Height ?? 1080
|
||||||
|
const br = stream.bitrate ?? stream.BitRate ?? 8000000
|
||||||
|
// Map common Jellyfin codec names to MIME codec strings
|
||||||
|
const mimeCodec =
|
||||||
|
codec === 'h264' ? 'avc1.640033'
|
||||||
|
: codec === 'hevc' ? 'hev1.1.6.L150.90'
|
||||||
|
: codec === 'av1' ? 'av01.0.05M.08'
|
||||||
|
: codec === 'vp9' ? 'vp09.00.50.08'
|
||||||
|
: null
|
||||||
|
if (!mimeCodec) return { supported: null, hwAccelerated: null }
|
||||||
|
try {
|
||||||
|
const info = await (navigator as any).mediaCapabilities.decodingInfo({
|
||||||
|
type: 'media-source',
|
||||||
|
video: {
|
||||||
|
contentType: `video/mp4; codecs="${mimeCodec}"`,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
bitrate: br,
|
||||||
|
framerate: 24,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { supported: info.supported, hwAccelerated: info.powerEfficient }
|
||||||
|
} catch {
|
||||||
|
return { supported: null, hwAccelerated: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer why transcoding is happening by comparing the original source
|
||||||
|
* streams against what the browser natively supports and what the user
|
||||||
|
* has selected (audio track, subtitle track, max bitrate).
|
||||||
|
*/
|
||||||
|
function inferTranscodeReasons(
|
||||||
|
item: BaseItemDto | null | undefined,
|
||||||
|
playMethod: Props['playMethod'],
|
||||||
|
): string[] {
|
||||||
|
if (playMethod !== 'Transcode') return []
|
||||||
|
const reasons: string[] = []
|
||||||
|
const source = pickPrimarySource(item || {})
|
||||||
|
const v = getVideoStream(item || {})
|
||||||
|
const a = getAudioStreams(item || {})[0]
|
||||||
|
if (v) {
|
||||||
|
const codec = (v.Codec || '').toLowerCase()
|
||||||
|
if (codec === 'hevc' || codec === 'h265') reasons.push('HEVC not natively supported')
|
||||||
|
else if (codec === 'av1') reasons.push('AV1 not natively supported')
|
||||||
|
else if (codec === 'mpeg2video') reasons.push('MPEG-2 not natively supported')
|
||||||
|
else if (codec === 'vc1') reasons.push('VC-1 not natively supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a) {
|
||||||
|
const codec = (a.Codec || '').toLowerCase()
|
||||||
|
if (codec === 'truehd') reasons.push('Dolby TrueHD requires transcode')
|
||||||
|
else if (codec === 'dts' && (a.Profile || '').toLowerCase().includes('hd')) reasons.push('DTS-HD requires transcode')
|
||||||
|
else if (codec === 'dts') reasons.push('DTS requires transcode')
|
||||||
|
else if (codec === 'eac3' && (a.Profile || '').toLowerCase().includes('atmos')) {
|
||||||
|
// Atmos in EAC3 may pass through on some browsers; if transcoding, note it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check container
|
||||||
|
const container = (source?.Container || '').toLowerCase()
|
||||||
|
if (container === 'mkv' || container === 'avi' || container === 'wmv') {
|
||||||
|
reasons.push(`Container .${container} not natively supported`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle burn-in: image-based subtitles usually force transcode
|
||||||
|
const subStreams = getSubtitleStreams(item || {})
|
||||||
|
const imageSub = subStreams.find((s: any) => {
|
||||||
|
const c = (s.Codec || '').toLowerCase()
|
||||||
|
return c === 'pgssub' || c === 'dvdsub' || c === 'vobsub' || c === 'sup'
|
||||||
|
})
|
||||||
|
if (imageSub) reasons.push('Image subtitles require burn-in')
|
||||||
|
|
||||||
|
// If we couldn't infer anything specific, give a generic reason
|
||||||
|
if (reasons.length === 0) reasons.push('Codec or container incompatible with browser')
|
||||||
|
|
||||||
|
return reasons
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StreamInfo({ item, visible, playMethod }: Props) {
|
||||||
|
const [live, setLive] = useState<LiveStats>({
|
||||||
|
bufferAheadSec: null,
|
||||||
|
droppedFrames: null,
|
||||||
|
totalFrames: null,
|
||||||
|
currentFps: null,
|
||||||
|
})
|
||||||
|
const [hw, setHw] = useState<HwDecodeState>({ supported: null, hwAccelerated: null })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
const tick = () => setLive(readLiveStats())
|
||||||
|
tick()
|
||||||
|
const id = setInterval(tick, 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
const v = getVideoStream(item || {})
|
||||||
|
checkHwDecode(v as any).then(setHw)
|
||||||
|
}, [visible, item?.Id])
|
||||||
|
|
||||||
|
if (!visible || !item) return null
|
||||||
|
const source = pickPrimarySource(item)
|
||||||
|
const v = getVideoStream(item)
|
||||||
|
const a = getAudioStreams(item)[0]
|
||||||
|
|
||||||
|
const rows: { label: string; value: string; accent?: boolean; warn?: boolean }[] = []
|
||||||
|
|
||||||
|
// Play method with color coding + hardware decode badge
|
||||||
|
if (playMethod) {
|
||||||
|
const hwLabel = hw.hwAccelerated === true ? ' · HW' : hw.hwAccelerated === false ? ' · SW' : ''
|
||||||
|
rows.push({
|
||||||
|
label: 'Method',
|
||||||
|
value: playMethod + hwLabel,
|
||||||
|
accent: playMethod === 'DirectPlay',
|
||||||
|
warn: playMethod === 'Transcode',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcoding reasons
|
||||||
|
const transcodeReasons = inferTranscodeReasons(item, playMethod)
|
||||||
|
if (transcodeReasons.length > 0) {
|
||||||
|
rows.push({ label: 'Why transcoding', value: transcodeReasons.join(', '), warn: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = resolutionLabel(item)
|
||||||
|
const range = videoRangeLabel(item)
|
||||||
|
if (res) rows.push({ label: 'Resolution', value: range ? `${res} · ${range}` : res })
|
||||||
|
if (v) {
|
||||||
|
rows.push({ label: 'Video', value: videoCodecLabel(v) })
|
||||||
|
const fps = framerateLabel(v)
|
||||||
|
if (fps) rows.push({ label: 'Framerate', value: fps })
|
||||||
|
if (v.BitRate) rows.push({ label: 'Video bitrate', value: formatBitrate(v.BitRate) })
|
||||||
|
}
|
||||||
|
if (a) {
|
||||||
|
rows.push({ label: 'Audio', value: audioFormatLabel(a) || '' })
|
||||||
|
if (a.BitRate) rows.push({ label: 'Audio bitrate', value: formatBitrate(a.BitRate) })
|
||||||
|
}
|
||||||
|
if (source?.Container) rows.push({ label: 'Container', value: source.Container.toUpperCase() })
|
||||||
|
if (source?.Bitrate) rows.push({ label: 'Total bitrate', value: formatBitrate(source.Bitrate) })
|
||||||
|
|
||||||
|
// Live block - only meaningful when there's a video element
|
||||||
|
const liveRows: { label: string; value: string }[] = []
|
||||||
|
if (live.bufferAheadSec != null) {
|
||||||
|
liveRows.push({ label: 'Buffer ahead', value: `${live.bufferAheadSec.toFixed(1)}s` })
|
||||||
|
}
|
||||||
|
if (live.droppedFrames != null && live.totalFrames != null) {
|
||||||
|
const pct = live.totalFrames > 0 ? (live.droppedFrames / live.totalFrames) * 100 : 0
|
||||||
|
liveRows.push({
|
||||||
|
label: 'Dropped frames',
|
||||||
|
value: `${live.droppedFrames} / ${live.totalFrames} (${pct.toFixed(2)}%)`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length && !liveRows.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="absolute top-20 right-6 z-20 w-80 rounded-lg bg-black/75 backdrop-blur-md border border-white/10 p-4 pointer-events-none"
|
||||||
|
>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/55 mb-3">
|
||||||
|
Stream info
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{rows.map(r => (
|
||||||
|
<div key={r.label} className="flex items-baseline justify-between gap-3 text-[11.5px]">
|
||||||
|
<span className="text-white/55 shrink-0">{r.label}</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono tabular-nums text-right ${
|
||||||
|
r.accent ? 'text-success' : r.warn ? 'text-warning' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{liveRows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/55 mt-4 mb-2">
|
||||||
|
Live
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{liveRows.map(r => (
|
||||||
|
<div key={r.label} className="flex items-baseline justify-between gap-3 text-[11.5px]">
|
||||||
|
<span className="text-white/55">{r.label}</span>
|
||||||
|
<span className="text-white font-mono tabular-nums">{r.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="text-[9.5px] text-white/45 mt-3 pt-2 border-t border-white/8">Press i to dismiss</p>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||||
|
import type { MediaPlayerInstance } from '@vidstack/react'
|
||||||
|
import { useSubtitleStyles } from '../../lib/subtitle-style'
|
||||||
|
import { usePlayerRuntimeStore } from '../../stores/player-runtime-store'
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom subtitle renderer that bypasses both the browser's <track> machinery
|
||||||
|
* and vidstack's <Captions> component. We fetch the VTT ourselves, parse it
|
||||||
|
* into a flat list of cues, then poll the player's currentTime against that
|
||||||
|
* list to figure out what to draw.
|
||||||
|
*
|
||||||
|
* Why not use TextTrack.activeCues? Two reasons:
|
||||||
|
* 1. vidstack's wrapped TextTrackList and the underlying <video>.textTracks
|
||||||
|
* don't always agree on `mode`, so cues sometimes never load.
|
||||||
|
* 2. vidstack's TextTrack uses a 'cue-change' event name, the DOM standard
|
||||||
|
* uses 'cuechange'; subscribing to one misses the other.
|
||||||
|
*
|
||||||
|
* Going direct sidesteps both problems entirely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Cue {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
playerRef: RefObject<MediaPlayerInstance | null>
|
||||||
|
subtitleUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeStamp(h: string | undefined, m: string, s: string, ms: string): number {
|
||||||
|
const hh = h ? parseInt(h.replace(':', ''), 10) : 0
|
||||||
|
return hh * 3600 + parseInt(m, 10) * 60 + parseInt(s, 10) + parseInt(ms, 10) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVTT(raw: string): Cue[] {
|
||||||
|
const text = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
const blocks = text.split(/\n\n+/)
|
||||||
|
const cues: Cue[] = []
|
||||||
|
for (const block of blocks) {
|
||||||
|
const lines = block.split('\n')
|
||||||
|
let tsLine = -1
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes('-->')) {
|
||||||
|
tsLine = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tsLine === -1) continue
|
||||||
|
// Accept both WebVTT dots and SRT commas for the milliseconds separator.
|
||||||
|
// Jellyfin sometimes serves SRT content even on the .vtt endpoint.
|
||||||
|
const m = lines[tsLine].match(
|
||||||
|
/(\d+:)?(\d+):(\d+)[\.,](\d+)\s+-->\s+(\d+:)?(\d+):(\d+)[\.,](\d+)/,
|
||||||
|
)
|
||||||
|
if (!m) continue
|
||||||
|
const start = parseTimeStamp(m[1], m[2], m[3], m[4])
|
||||||
|
const end = parseTimeStamp(m[5], m[6], m[7], m[8])
|
||||||
|
const body = lines
|
||||||
|
.slice(tsLine + 1)
|
||||||
|
.filter(l => l.trim() !== '')
|
||||||
|
.join('\n')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.trim()
|
||||||
|
if (body) cues.push({ start, end, text: body })
|
||||||
|
}
|
||||||
|
// Sort so the polling-loop break optimisation (and any out-of-order
|
||||||
|
// source files) behave correctly.
|
||||||
|
cues.sort((a, b) => a.start - b.start)
|
||||||
|
if (cues.length === 0 && text.trim().length > 100) {
|
||||||
|
console.warn('[subtitles] parseVTT produced zero cues from non-empty content. First 200 chars:', text.trim().slice(0, 200))
|
||||||
|
}
|
||||||
|
return cues
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubtitleOverlay({ playerRef, subtitleUrl }: Props) {
|
||||||
|
const { className, style } = useSubtitleStyles()
|
||||||
|
const cuesRef = useRef<Cue[]>([])
|
||||||
|
const [activeCues, setActiveCues] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cuesRef.current = []
|
||||||
|
setActiveCues([])
|
||||||
|
if (!subtitleUrl) return
|
||||||
|
let cancelled = false
|
||||||
|
fetch(subtitleUrl, { credentials: 'omit' })
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(`vtt fetch ${r.status}`)
|
||||||
|
return r.text()
|
||||||
|
})
|
||||||
|
.then(text => {
|
||||||
|
if (cancelled) return
|
||||||
|
cuesRef.current = parseVTT(text)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn('[subtitles] failed to load VTT', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [subtitleUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lastSig = ''
|
||||||
|
let cancelled = false
|
||||||
|
function tick() {
|
||||||
|
if (cancelled) return
|
||||||
|
const player = playerRef.current
|
||||||
|
const cues = cuesRef.current
|
||||||
|
if (!player || cues.length === 0) {
|
||||||
|
if (lastSig !== '') {
|
||||||
|
lastSig = ''
|
||||||
|
setActiveCues([])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Effective time = player time + (-offset). A positive offset means
|
||||||
|
// "subtitles later", which we implement by checking cues against an
|
||||||
|
// earlier time. Negative = "subtitles earlier" (cues against later).
|
||||||
|
const offsetSec = (usePreferencesStore.getState().subtitleDelayMs
|
||||||
|
+ usePlayerRuntimeStore.getState().subtitleOffsetMs) / 1000
|
||||||
|
const t = (player.currentTime ?? 0) - offsetSec
|
||||||
|
if (!Number.isFinite(t)) return
|
||||||
|
const matches: string[] = []
|
||||||
|
for (const c of cues) {
|
||||||
|
if (t >= c.start && t < c.end) matches.push(c.text)
|
||||||
|
}
|
||||||
|
const sig = matches.join('\x1f')
|
||||||
|
if (sig !== lastSig) {
|
||||||
|
lastSig = sig
|
||||||
|
setActiveCues(matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
const interval = setInterval(tick, 100)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [playerRef, subtitleUrl])
|
||||||
|
|
||||||
|
if (activeCues.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className} z-30`} style={style}>
|
||||||
|
{activeCues.map((cue, ci) => (
|
||||||
|
<div key={ci} className={ci > 0 ? 'mt-1.5' : ''}>
|
||||||
|
<span data-cue className="whitespace-pre-line">
|
||||||
|
{cue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X, Search } from '../../lib/icons'
|
||||||
|
|
||||||
|
interface Cue {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** URL to a VTT or ASS subtitle stream. */
|
||||||
|
subtitleUrl: string | null
|
||||||
|
onJump: (timeSec: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSec(sec: number): string {
|
||||||
|
if (!Number.isFinite(sec) || sec < 0) return '0:00'
|
||||||
|
const h = Math.floor(sec / 3600)
|
||||||
|
const m = Math.floor((sec % 3600) / 60)
|
||||||
|
const s = Math.floor(sec % 60)
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimestamp(s: string): number {
|
||||||
|
const m = s.match(/(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/)
|
||||||
|
if (!m) return 0
|
||||||
|
return (
|
||||||
|
Number(m[1] || 0) * 3600 +
|
||||||
|
Number(m[2]) * 60 +
|
||||||
|
Number(m[3]) +
|
||||||
|
Number(m[4]) / 1000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVTT(text: string): Cue[] {
|
||||||
|
const out: Cue[] = []
|
||||||
|
const blocks = text.replace(/\r\n/g, '\n').split(/\n\n+/)
|
||||||
|
for (const block of blocks) {
|
||||||
|
const lines = block.split('\n').filter(l => l.trim() !== '')
|
||||||
|
if (lines.length < 2) continue
|
||||||
|
const tsLine = lines.find(l => l.includes('-->'))
|
||||||
|
if (!tsLine) continue
|
||||||
|
const [a, b] = tsLine.split('-->').map(s => s.trim().split(' ')[0])
|
||||||
|
const start = parseTimestamp(a)
|
||||||
|
const end = parseTimestamp(b)
|
||||||
|
const textLines = lines.slice(lines.indexOf(tsLine) + 1)
|
||||||
|
const txt = textLines.join('\n').replace(/<[^>]+>/g, '').trim()
|
||||||
|
if (txt && end > start) out.push({ start, end, text: txt })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseASS(text: string): Cue[] {
|
||||||
|
const out: Cue[] = []
|
||||||
|
const lines = text.split('\n')
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('Dialogue:')) continue
|
||||||
|
// Format: Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello world
|
||||||
|
const fields = line.slice(9).split(',')
|
||||||
|
if (fields.length < 10) continue
|
||||||
|
const start = parseTimestamp(fields[1])
|
||||||
|
const end = parseTimestamp(fields[2])
|
||||||
|
// Text is the rest joined by comma; strip ASS override blocks {\\...}
|
||||||
|
const txt = fields.slice(9).join(',').replace(/\{[^}]*\}/g, '').replace(/\\N/gi, ' ').trim()
|
||||||
|
if (txt && end > start) out.push({ start, end, text: txt })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubtitleSearchPanel({ open, onClose, subtitleUrl, onJump }: Props) {
|
||||||
|
const [cues, setCues] = useState<Cue[]>([])
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !subtitleUrl) return
|
||||||
|
setLoading(true)
|
||||||
|
let cancelled = false
|
||||||
|
fetch(subtitleUrl, { credentials: 'omit' })
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(text => {
|
||||||
|
if (cancelled) return
|
||||||
|
const parsed = subtitleUrl.endsWith('.ass') || subtitleUrl.endsWith('.ssa')
|
||||||
|
? parseASS(text)
|
||||||
|
: parseVTT(text)
|
||||||
|
setCues(parsed)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open, subtitleUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) inputRef.current?.focus()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!query.trim()) return []
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const out: Cue[] = []
|
||||||
|
for (const c of cues) {
|
||||||
|
if (c.text.toLowerCase().includes(q)) out.push(c)
|
||||||
|
if (out.length >= 200) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [cues, query])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-40 w-[420px] max-w-[88vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Search subtitles"
|
||||||
|
>
|
||||||
|
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85">
|
||||||
|
Search subtitles
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-3 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder={subtitleUrl ? 'Search dialogue...' : 'No subtitle track active'}
|
||||||
|
disabled={!subtitleUrl}
|
||||||
|
className="w-full h-10 pl-9 pr-3 rounded-md bg-void/60 border border-border focus:border-accent/50 focus:ring-2 focus:ring-accent/15 text-[13px] text-text-1 placeholder:text-text-4 transition-all duration-150 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!loading && cues.length > 0 && (
|
||||||
|
<p className="text-[10.5px] text-white/45 mt-2 tabular-nums">
|
||||||
|
{cues.length} cues loaded · {results.length} match
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-y-auto content-scroll px-3 py-3 space-y-1">
|
||||||
|
{loading && (
|
||||||
|
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">Loading subtitles...</p>
|
||||||
|
)}
|
||||||
|
{!loading && !subtitleUrl && (
|
||||||
|
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">
|
||||||
|
Pick a subtitle track first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!loading && subtitleUrl && results.length === 0 && query.trim() && (
|
||||||
|
<p className="text-[12.5px] text-white/55 px-2 py-6 text-center">No matches.</p>
|
||||||
|
)}
|
||||||
|
{results.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={`${c.start}-${i}`}
|
||||||
|
onClick={() => {
|
||||||
|
onClose()
|
||||||
|
onJump(c.start)
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2.5 rounded-lg border border-white/8 hover:border-white/20 hover:bg-white/5 transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
<p className="text-[10.5px] font-mono tabular-nums text-accent mb-1">
|
||||||
|
{formatSec(c.start)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12.5px] text-white/85 leading-snug">{c.text}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Type } from '../../lib/icons'
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
import { usePlayerRuntimeStore } from '../../stores/player-runtime-store'
|
||||||
|
import { subtitleClasses } from '../../lib/subtitle-style'
|
||||||
|
import type { AppSettings } from '../../api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating panel inside the player that exposes the same subtitle-styling
|
||||||
|
* controls as the Settings page, so users can fine-tune captions without
|
||||||
|
* leaving playback. Writes straight to the preferences store, so changes
|
||||||
|
* apply live everywhere the styling hook is consumed.
|
||||||
|
*/
|
||||||
|
export default function SubtitleStyleMenu() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDoc(e: MouseEvent) {
|
||||||
|
if (!ref.current) return
|
||||||
|
if (!ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('mousedown', onDoc)
|
||||||
|
return () => window.removeEventListener('mousedown', onDoc)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const preview = subtitleClasses({
|
||||||
|
subtitleFontSize: prefs.subtitleFontSize,
|
||||||
|
subtitleFontFamily: prefs.subtitleFontFamily,
|
||||||
|
subtitleBackground: prefs.subtitleBackground,
|
||||||
|
subtitleEdge: prefs.subtitleEdge,
|
||||||
|
subtitlePosition: prefs.subtitlePosition,
|
||||||
|
subtitleColor: prefs.subtitleColor,
|
||||||
|
})
|
||||||
|
const previewCls = preview.className.replace(/\babsolute\b|\binset-x-0\b|\bbottom-32\b|\btop-24\b|\bpx-7\b/g, '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className={`w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring ${
|
||||||
|
open ? 'bg-white/15 text-white' : ''
|
||||||
|
}`}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="Subtitle appearance"
|
||||||
|
>
|
||||||
|
<Type size={17} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
role="dialog"
|
||||||
|
className="absolute right-0 top-full mt-2 w-[340px] max-h-[min(80vh,560px)] overflow-y-auto content-scroll bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
|
||||||
|
Subtitle appearance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<Field label={`Size (${prefs.subtitleFontSize}px)`}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={12}
|
||||||
|
max={72}
|
||||||
|
step={1}
|
||||||
|
value={prefs.subtitleFontSize}
|
||||||
|
onChange={e => prefs.setPreference('subtitleFontSize', Number(e.target.value))}
|
||||||
|
className="subtitle-size-slider w-full h-7 appearance-none bg-transparent cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[9px] text-white/40 -mt-0.5 px-0.5">
|
||||||
|
<span>12px</span>
|
||||||
|
<span>72px</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Background">
|
||||||
|
<Seg
|
||||||
|
value={prefs.subtitleBackground}
|
||||||
|
onChange={v => prefs.setPreference('subtitleBackground', v as AppSettings['subtitleBackground'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'subtle', label: 'Subtle' },
|
||||||
|
{ value: 'solid', label: 'Solid' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Edge">
|
||||||
|
<Seg
|
||||||
|
value={prefs.subtitleEdge}
|
||||||
|
onChange={v => prefs.setPreference('subtitleEdge', v as AppSettings['subtitleEdge'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'shadow', label: 'Shadow' },
|
||||||
|
{ value: 'outline', label: 'Outline' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Position">
|
||||||
|
<Seg
|
||||||
|
value={prefs.subtitlePosition}
|
||||||
|
onChange={v => prefs.setPreference('subtitlePosition', v as AppSettings['subtitlePosition'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'bottom', label: 'Bottom' },
|
||||||
|
{ value: 'top', label: 'Top' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Colour">
|
||||||
|
<Seg
|
||||||
|
value={prefs.subtitleColor}
|
||||||
|
onChange={v => prefs.setPreference('subtitleColor', v as AppSettings['subtitleColor'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'white', label: 'White' },
|
||||||
|
{ value: 'yellow', label: 'Yellow' },
|
||||||
|
{ value: 'cyan', label: 'Cyan' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Font">
|
||||||
|
<Seg
|
||||||
|
value={prefs.subtitleFontFamily}
|
||||||
|
onChange={v => prefs.setPreference('subtitleFontFamily', v as AppSettings['subtitleFontFamily'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'sans', label: 'Sans' },
|
||||||
|
{ value: 'serif', label: 'Display' },
|
||||||
|
{ value: 'mono', label: 'Mono' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SyncRow />
|
||||||
|
|
||||||
|
<div className="pt-1">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/45 mb-1.5">
|
||||||
|
Preview
|
||||||
|
</p>
|
||||||
|
<div className="relative h-20 rounded-md overflow-hidden bg-[radial-gradient(ellipse_at_30%_20%,rgba(245,182,66,0.15),transparent_55%),linear-gradient(180deg,#1a1610,#0c0a08)] grid place-items-center">
|
||||||
|
<div className={previewCls} style={preview.style}>
|
||||||
|
<span data-cue>Sample subtitle text</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncRow() {
|
||||||
|
const offsetMs = usePlayerRuntimeStore(s => s.subtitleOffsetMs)
|
||||||
|
const bump = usePlayerRuntimeStore(s => s.bumpSubtitleOffsetMs)
|
||||||
|
const reset = usePlayerRuntimeStore(s => s.setSubtitleOffsetMs)
|
||||||
|
const display = `${offsetMs >= 0 ? '+' : ''}${(offsetMs / 1000).toFixed(2)}s`
|
||||||
|
return (
|
||||||
|
<Field label="Sync (this session)">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => bump(-100)}
|
||||||
|
className="h-7 px-2.5 rounded bg-white/8 hover:bg-white/14 text-[11.5px] font-medium text-white/85 hover:text-white transition-colors focus-ring"
|
||||||
|
aria-label="Subtitles earlier 100ms"
|
||||||
|
>
|
||||||
|
−100ms
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => reset(0)}
|
||||||
|
className="flex-1 h-7 px-2.5 rounded text-[11.5px] font-mono font-semibold tabular-nums bg-void/60 border border-border text-text-1 hover:bg-void transition-colors focus-ring"
|
||||||
|
title="Click to reset"
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => bump(100)}
|
||||||
|
className="h-7 px-2.5 rounded bg-white/8 hover:bg-white/14 text-[11.5px] font-medium text-white/85 hover:text-white transition-colors focus-ring"
|
||||||
|
aria-label="Subtitles later 100ms"
|
||||||
|
>
|
||||||
|
+100ms
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10.5px] font-medium text-white/55 mb-1 tracking-wide uppercase">{label}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Seg<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
options: { value: T; label: string }[]
|
||||||
|
onChange: (v: T) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex w-full p-0.5 bg-white/8 border border-white/10 rounded-md">
|
||||||
|
{options.map(o => {
|
||||||
|
const active = o.value === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={`flex-1 h-7 px-2 rounded text-[11.5px] font-medium tracking-tight transition-colors ${
|
||||||
|
active ? 'bg-white text-void' : 'text-white/75 hover:text-white hover:bg-white/8'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X, Users, Plus, Loader2, LogOut } from '../../lib/icons'
|
||||||
|
import { listGroups, createGroup, joinGroup, leaveGroup, setNewQueue, type SyncPlayGroup } from '../../lib/syncplay'
|
||||||
|
import { useSyncPlay } from '../../stores/syncplay-store'
|
||||||
|
import { toast } from '../../stores/toast-store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** The item the host is currently playing - used to seed the group's
|
||||||
|
* queue when they create a party so joiners get pushed the right
|
||||||
|
* item via the server's PlayQueue update. */
|
||||||
|
currentItemId: string | null
|
||||||
|
currentPositionTicks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch-party panel. Slides in from the right, shows the open SyncPlay
|
||||||
|
* groups on the server, and lets the user create/join/leave one. Once
|
||||||
|
* the user is in a group, PlayerPage starts mirroring pause/play/seek
|
||||||
|
* across the group via the SyncPlay REST + WebSocket bridge.
|
||||||
|
*/
|
||||||
|
export default function SyncPlayPanel({ open, onClose, currentItemId, currentPositionTicks }: Props) {
|
||||||
|
const active = useSyncPlay(s => s.active)
|
||||||
|
const setActive = useSyncPlay(s => s.setActive)
|
||||||
|
const [groups, setGroups] = useState<SyncPlayGroup[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
listGroups()
|
||||||
|
.then(g => {
|
||||||
|
if (!cancelled) setGroups(g)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open, active])
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
if (!newName.trim() || busy) return
|
||||||
|
setBusy('create')
|
||||||
|
const name = newName.trim()
|
||||||
|
try {
|
||||||
|
const created = await createGroup(name)
|
||||||
|
if (created) {
|
||||||
|
setActive({ groupId: created.groupId, groupName: created.groupName, memberCount: created.participantCount })
|
||||||
|
// Seed the group queue so joiners get pushed our item. If there's
|
||||||
|
// no playback yet (creating a party from an empty player) the
|
||||||
|
// seed is skipped - the host can set it later by starting any
|
||||||
|
// item, and the server will broadcast a PlayQueue update.
|
||||||
|
if (currentItemId) {
|
||||||
|
setNewQueue(currentItemId, currentPositionTicks).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast('Watch party created', 'success')
|
||||||
|
setNewName('')
|
||||||
|
setGroups(await listGroups())
|
||||||
|
} catch {
|
||||||
|
toast('Could not create watch party', 'error')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onJoin(g: SyncPlayGroup) {
|
||||||
|
if (busy) return
|
||||||
|
setBusy(g.groupId)
|
||||||
|
try {
|
||||||
|
await joinGroup(g.groupId)
|
||||||
|
setActive({ groupId: g.groupId, groupName: g.groupName, memberCount: g.participantCount + 1 })
|
||||||
|
toast(`Joined ${g.groupName}`, 'success')
|
||||||
|
} catch {
|
||||||
|
toast('Could not join that party', 'error')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLeave() {
|
||||||
|
setBusy('leave')
|
||||||
|
try {
|
||||||
|
await leaveGroup()
|
||||||
|
setActive(null)
|
||||||
|
toast('Left the watch party', 'info')
|
||||||
|
} catch {
|
||||||
|
toast('Could not leave', 'error')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 z-30 bg-black/55 backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-40 w-[420px] max-w-[88vw] bg-[#0c0a08]/96 backdrop-blur-xl border-l border-white/10 flex flex-col shadow-[0_0_60px_rgba(0,0,0,0.6)]"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Watch party"
|
||||||
|
>
|
||||||
|
<header className="shrink-0 px-5 pt-5 pb-3 border-b border-white/8 flex items-center justify-between">
|
||||||
|
<h2 className="text-[13px] font-semibold uppercase tracking-[0.14em] text-white/85 inline-flex items-center gap-2">
|
||||||
|
<Users size={14} />
|
||||||
|
Watch party
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto content-scroll px-4 py-4 space-y-4">
|
||||||
|
{active ? (
|
||||||
|
<section className="rounded-lg bg-accent/8 border border-accent/25 p-3.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] font-semibold text-accent mb-1">
|
||||||
|
In a party
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] font-semibold text-white tracking-tight">
|
||||||
|
{active.groupName}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-white/65 mt-0.5">
|
||||||
|
{active.memberCount} {active.memberCount === 1 ? 'watcher' : 'watchers'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onLeave}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-[11.5px] font-medium bg-white/10 hover:bg-white/15 text-white border border-white/15 transition-colors focus-ring disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<LogOut size={12} stroke={2} />
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="rounded-lg bg-elevated/30 border border-border p-3.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
|
||||||
|
Start a new party
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
onCreate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Friday movie night"
|
||||||
|
className="flex-1 h-9 px-3 bg-void/55 rounded-md text-[12.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onCreate}
|
||||||
|
disabled={!newName.trim() || !!busy}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-accent hover:bg-accent-hover text-void disabled:opacity-40 disabled:cursor-not-allowed transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
{busy === 'create' ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} stroke={2} />}
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2.5">
|
||||||
|
Open parties
|
||||||
|
</p>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-[12px] text-text-3 py-3 px-2">
|
||||||
|
<Loader2 size={13} className="animate-spin" />
|
||||||
|
Looking for parties...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && groups.length === 0 && (
|
||||||
|
<p className="text-[12px] text-text-3 py-3 px-2 leading-snug">
|
||||||
|
No open watch parties. Start one above and tell a friend the group name.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!loading && groups.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{groups.map(g => {
|
||||||
|
const isActive = active?.groupId === g.groupId
|
||||||
|
return (
|
||||||
|
<li key={g.groupId}>
|
||||||
|
<button
|
||||||
|
onClick={() => !isActive && onJoin(g)}
|
||||||
|
disabled={isActive || !!busy}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left focus-ring ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent/10 border-accent/30 cursor-default'
|
||||||
|
: 'bg-elevated/30 border-border hover:border-border-strong hover:bg-elevated/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-8 h-8 grid place-items-center rounded-md bg-void/55 shrink-0">
|
||||||
|
<Users size={13} className="text-text-3" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block text-[13px] font-medium text-text-1 truncate tracking-tight">
|
||||||
|
{g.groupName}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[11px] text-text-3 truncate">
|
||||||
|
{g.participantCount} {g.participantCount === 1 ? 'watcher' : 'watchers'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{busy === g.groupId && <Loader2 size={13} className="text-text-3 animate-spin" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-[10.5px] text-text-4 leading-relaxed">
|
||||||
|
Pause, resume, and seek will mirror to everyone in the party. The host needs to keep their player open for state to flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Check } from '../../lib/icons'
|
||||||
|
import {
|
||||||
|
audioFormatLabel,
|
||||||
|
subtitleLabel,
|
||||||
|
} from '../../lib/jellyfin-meta'
|
||||||
|
import { languageLabel } from '../../lib/format'
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
Index?: number
|
||||||
|
Codec?: string | null
|
||||||
|
Profile?: string | null
|
||||||
|
Language?: string | null
|
||||||
|
Title?: string | null
|
||||||
|
Channels?: number | null
|
||||||
|
ChannelLayout?: string | null
|
||||||
|
IsDefault?: boolean | null
|
||||||
|
IsForced?: boolean | null
|
||||||
|
IsHearingImpaired?: boolean | null
|
||||||
|
IsExternal?: boolean | null
|
||||||
|
AudioSpatialFormat?: string | null
|
||||||
|
Type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
trigger: React.ReactNode
|
||||||
|
title: string
|
||||||
|
tracks: Track[]
|
||||||
|
selectedIndex: number | null
|
||||||
|
onSelect: (index: number | null) => void
|
||||||
|
variant: 'audio' | 'subtitle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrackMenu({
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
tracks,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
variant,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onDoc(e: MouseEvent) {
|
||||||
|
if (!ref.current) return
|
||||||
|
if (!ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('mousedown', onDoc)
|
||||||
|
return () => window.removeEventListener('mousedown', onDoc)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-9 h-9 rounded-full grid place-items-center text-white/85 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -4, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
role="listbox"
|
||||||
|
className="absolute right-0 top-full mt-2 w-72 max-h-[min(60vh,420px)] overflow-y-auto content-scroll bg-black/85 backdrop-blur-xl border border-white/12 rounded-lg shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 px-3 py-2.5 bg-black/90 border-b border-white/8 z-10">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-white/60">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-1">
|
||||||
|
{variant === 'subtitle' && (
|
||||||
|
<TrackOption
|
||||||
|
selected={selectedIndex === null}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(null)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
primary="Off"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && (
|
||||||
|
<p className="px-3 py-3 text-[12px] text-white/55">No tracks available</p>
|
||||||
|
)}
|
||||||
|
{tracks.map(t => {
|
||||||
|
const idx = t.Index ?? -1
|
||||||
|
const selected = selectedIndex === idx
|
||||||
|
const primary =
|
||||||
|
variant === 'audio'
|
||||||
|
? audioFormatLabel(t) || t.Title || 'Unknown'
|
||||||
|
: subtitleLabel(t)
|
||||||
|
const secondary =
|
||||||
|
variant === 'audio'
|
||||||
|
? [languageLabel(t.Language), t.IsDefault && 'Default']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
: [t.Codec?.toUpperCase(), t.IsExternal && 'External']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
return (
|
||||||
|
<TrackOption
|
||||||
|
key={idx}
|
||||||
|
selected={selected}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(idx)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
primary={primary}
|
||||||
|
secondary={secondary}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackOption({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
}: {
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
primary: string
|
||||||
|
secondary?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left transition-colors duration-100 focus-ring ${
|
||||||
|
selected ? 'bg-accent/15' : 'hover:bg-white/8'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-4 h-4 grid place-items-center shrink-0 ${
|
||||||
|
selected ? 'text-accent' : 'text-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check size={13} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`block text-[12.5px] font-medium truncate ${
|
||||||
|
selected ? 'text-accent' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{primary}
|
||||||
|
</span>
|
||||||
|
{secondary && <span className="block text-[10.5px] text-white/55 truncate">{secondary}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Renders a Jellyfin trickplay thumbnail for a given playback time. Jellyfin
|
||||||
|
* stores trickplay frames as a sprite sheet ("tile") - one image containing
|
||||||
|
* many small thumbnails in a grid. The tile's metadata (frame size, tile
|
||||||
|
* dimensions, interval, count) lives on `item.Trickplay`. We compute which
|
||||||
|
* tile + which cell within the tile corresponds to the requested second,
|
||||||
|
* then position a fixed-size box over the right cell with `background-image`
|
||||||
|
* + `background-position`.
|
||||||
|
*
|
||||||
|
* The component returns null silently if trickplay isn't generated for the
|
||||||
|
* source - that's the most common case when nothing was processed yet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TrickplayInfo {
|
||||||
|
Width: number
|
||||||
|
Height: number
|
||||||
|
TileWidth: number
|
||||||
|
TileHeight: number
|
||||||
|
ThumbnailCount: number
|
||||||
|
Interval: number // ms between frames
|
||||||
|
Bandwidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: any
|
||||||
|
serverUrl: string
|
||||||
|
token: string
|
||||||
|
timeSec: number
|
||||||
|
/** Visible width on screen. Aspect ratio is preserved off the source frame. */
|
||||||
|
displayWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the highest-resolution trickplay variant the server has, across all
|
||||||
|
* media sources. Jellyfin keys them by MediaSourceId, then by source width
|
||||||
|
* in pixels (e.g. "320", "640"). Larger wins when present.
|
||||||
|
*/
|
||||||
|
function pickBestVariant(
|
||||||
|
trickplay: any,
|
||||||
|
): { mediaSourceId: string; info: TrickplayInfo; widthKey: string } | null {
|
||||||
|
if (!trickplay || typeof trickplay !== 'object') return null
|
||||||
|
let best: { mediaSourceId: string; info: TrickplayInfo; widthKey: string } | null = null
|
||||||
|
for (const mediaSourceId of Object.keys(trickplay)) {
|
||||||
|
const variants = trickplay[mediaSourceId]
|
||||||
|
if (!variants || typeof variants !== 'object') continue
|
||||||
|
for (const widthKey of Object.keys(variants)) {
|
||||||
|
const info = variants[widthKey] as TrickplayInfo
|
||||||
|
if (!info?.Width || !info?.Height || !info?.TileWidth || !info?.TileHeight) continue
|
||||||
|
const w = Number(widthKey)
|
||||||
|
if (!best || w > Number(best.widthKey)) {
|
||||||
|
best = { mediaSourceId, info, widthKey }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrickplayThumbnail({
|
||||||
|
item,
|
||||||
|
serverUrl,
|
||||||
|
token,
|
||||||
|
timeSec,
|
||||||
|
displayWidth = 200,
|
||||||
|
}: Props) {
|
||||||
|
const variant = pickBestVariant(item?.Trickplay)
|
||||||
|
if (!variant || !item?.Id) return null
|
||||||
|
const { info, widthKey } = variant
|
||||||
|
const itemId = item.Id as string
|
||||||
|
|
||||||
|
const tilesPerSheet = info.TileWidth * info.TileHeight
|
||||||
|
const thumbIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(info.ThumbnailCount - 1, Math.floor((timeSec * 1000) / info.Interval)),
|
||||||
|
)
|
||||||
|
const tileIndex = Math.floor(thumbIndex / tilesPerSheet)
|
||||||
|
const positionInTile = thumbIndex % tilesPerSheet
|
||||||
|
const col = positionInTile % info.TileWidth
|
||||||
|
const row = Math.floor(positionInTile / info.TileWidth)
|
||||||
|
|
||||||
|
const tileUrl = `${serverUrl}/Videos/${itemId}/Trickplay/${widthKey}/${tileIndex}.jpg?api_key=${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}`
|
||||||
|
|
||||||
|
const aspectRatio = info.Width / info.Height
|
||||||
|
const displayHeight = displayWidth / aspectRatio
|
||||||
|
const scale = displayWidth / info.Width
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-md ring-1 ring-white/15 shadow-2xl bg-black"
|
||||||
|
style={{
|
||||||
|
width: `${displayWidth}px`,
|
||||||
|
height: `${displayHeight}px`,
|
||||||
|
backgroundImage: `url("${tileUrl}")`,
|
||||||
|
backgroundSize: `${info.Width * info.TileWidth * scale}px ${info.Height * info.TileHeight * scale}px`,
|
||||||
|
backgroundPosition: `-${col * info.Width * scale}px -${row * info.Height * scale}px`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Play, X } from '../../lib/icons'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
|
||||||
|
import { formatRuntime } from '../../lib/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nextItem?: BaseItemDto | null
|
||||||
|
visible: boolean
|
||||||
|
countdownSeconds: number
|
||||||
|
onSkip: () => void
|
||||||
|
onDismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpNext({
|
||||||
|
nextItem,
|
||||||
|
visible,
|
||||||
|
countdownSeconds,
|
||||||
|
onSkip,
|
||||||
|
onDismiss,
|
||||||
|
}: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
|
||||||
|
if (!nextItem) return null
|
||||||
|
|
||||||
|
const thumb = getBestImage(serverUrl, nextItem, 'thumb', 480)
|
||||||
|
const epLabel =
|
||||||
|
nextItem.ParentIndexNumber != null && nextItem.IndexNumber != null
|
||||||
|
? `S${nextItem.ParentIndexNumber} · E${nextItem.IndexNumber}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 30, y: 0 }}
|
||||||
|
animate={{ opacity: 1, x: 0, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 30 }}
|
||||||
|
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute bottom-32 right-6 w-[360px] glass-strong rounded-xl border border-white/15 overflow-hidden shadow-2xl pointer-events-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="absolute top-2 right-2 z-10 w-7 h-7 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur grid place-items-center text-white/70 hover:text-white transition-colors focus-ring"
|
||||||
|
aria-label="Dismiss up next"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative aspect-video bg-elevated">
|
||||||
|
{thumb && <img src={thumb} alt="" className="w-full h-full object-cover" />}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] text-accent font-semibold mb-0.5">
|
||||||
|
Up next in {countdownSeconds}s
|
||||||
|
</p>
|
||||||
|
{epLabel && (
|
||||||
|
<p className="text-[11px] text-white/65 font-mono mb-0.5">{epLabel}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[14px] text-white font-semibold tracking-tight line-clamp-1">
|
||||||
|
{nextItem.Name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{nextItem.Overview && (
|
||||||
|
<p className="text-[11.5px] text-white/65 line-clamp-2 leading-relaxed">{nextItem.Overview}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
className="flex-1 h-9 px-3 bg-white text-void rounded-md flex items-center justify-center gap-1.5 text-[12.5px] font-semibold transition-all duration-150 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
||||||
|
>
|
||||||
|
<Play size={13} fill="currentColor" />
|
||||||
|
Play now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => nextItem.Id && navigate(`/item/${nextItem.Id}`)}
|
||||||
|
className="h-9 px-3 bg-white/10 hover:bg-white/15 text-white border border-white/15 rounded-md text-[12.5px] font-medium transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
{nextItem.RunTimeTicks && (
|
||||||
|
<span className="text-[11px] text-white/55 tabular-nums">
|
||||||
|
{formatRuntime(nextItem.RunTimeTicks)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user