269 lines
9.8 KiB
TypeScript
269 lines
9.8 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { motion } from 'framer-motion'
|
|
import { Radio, CalendarEvent, FileVideo, Play, Clock } from '../lib/icons'
|
|
import { useLiveTvInfo, useLiveTvChannels, useLiveTvRecordings, useLiveTvTimers } from '../hooks/use-jellyfin'
|
|
import { jellyfinClient, getImageUrl } from '../api/jellyfin'
|
|
|
|
type Tab = 'channels' | 'recordings' | 'scheduled'
|
|
|
|
/**
|
|
* Live TV / DVR landing page. Three tabs surfaced off Jellyfin's
|
|
* LiveTv API:
|
|
* - Channels (with current-program inline)
|
|
* - Recordings (past + in-progress)
|
|
* - Scheduled timers
|
|
*
|
|
* If Live TV isn't configured server-side, the page shows a friendly
|
|
* placeholder instead of empty lists.
|
|
*/
|
|
export default function LiveTvPage() {
|
|
const info = useLiveTvInfo()
|
|
const [tab, setTab] = useState<Tab>('channels')
|
|
|
|
const enabled = info.data?.IsEnabled !== false
|
|
const noTuners = !info.data?.Services?.length
|
|
|
|
if (!info.isLoading && (!enabled || noTuners)) {
|
|
return (
|
|
<div className="px-7 pt-6 pb-12">
|
|
<Header />
|
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center max-w-xl mx-auto mt-10">
|
|
<Radio size={28} className="text-text-3 mx-auto mb-3" />
|
|
<p className="text-[14px] text-text-1 font-semibold mb-1.5">Live TV isn't set up on this server</p>
|
|
<p className="text-[12.5px] text-text-3 leading-relaxed">
|
|
Add a tuner and a guide provider in the Jellyfin dashboard under Live TV. Once configured, your
|
|
channels and DVR recordings will show up here.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="px-7 pt-6 pb-12">
|
|
<Header />
|
|
|
|
<div className="flex items-center gap-1 mb-6 border-b border-border">
|
|
<TabButton active={tab === 'channels'} onClick={() => setTab('channels')} icon={<Radio size={14} />}>
|
|
Channels
|
|
</TabButton>
|
|
<TabButton active={tab === 'recordings'} onClick={() => setTab('recordings')} icon={<FileVideo size={14} />}>
|
|
Recordings
|
|
</TabButton>
|
|
<TabButton active={tab === 'scheduled'} onClick={() => setTab('scheduled')} icon={<CalendarEvent size={14} />}>
|
|
Scheduled
|
|
</TabButton>
|
|
</div>
|
|
|
|
{tab === 'channels' && <ChannelsList />}
|
|
{tab === 'recordings' && <RecordingsList />}
|
|
{tab === 'scheduled' && <ScheduledList />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Header() {
|
|
return (
|
|
<header className="mb-6">
|
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
|
|
Live TV
|
|
</p>
|
|
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
|
|
Channels & recordings
|
|
</h1>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
function TabButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
icon,
|
|
}: {
|
|
active: boolean
|
|
onClick: () => void
|
|
children: React.ReactNode
|
|
icon: React.ReactNode
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`relative px-4 h-10 inline-flex items-center gap-2 text-[12.5px] font-medium tracking-tight transition-colors focus-ring ${
|
|
active ? 'text-text-1' : 'text-text-3 hover:text-text-1'
|
|
}`}
|
|
>
|
|
{icon}
|
|
{children}
|
|
{active && (
|
|
<motion.span
|
|
layoutId="livetv-tab"
|
|
className="absolute -bottom-px left-0 right-0 h-[2px] bg-accent rounded-t"
|
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
|
/>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function ChannelsList() {
|
|
const navigate = useNavigate()
|
|
const serverUrl = jellyfinClient.getAuthState()?.serverUrl || ''
|
|
const { data: channels = [], isLoading } = useLiveTvChannels()
|
|
|
|
if (isLoading) return <Skeleton />
|
|
if (channels.length === 0) {
|
|
return <EmptyMsg>No channels available right now.</EmptyMsg>
|
|
}
|
|
|
|
return (
|
|
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
|
|
{channels.map(ch => {
|
|
const program = (ch as any).CurrentProgram as { Name?: string; StartDate?: string; EndDate?: string } | undefined
|
|
const imageTag = ch.ImageTags?.Primary
|
|
const img = ch.Id && imageTag ? getImageUrl(serverUrl, ch.Id, 'Primary', 128, imageTag) : ''
|
|
return (
|
|
<li key={ch.Id}>
|
|
<button
|
|
onClick={() => ch.Id && navigate(`/play/${ch.Id}`)}
|
|
className="w-full text-left p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition flex items-center gap-3 focus-ring"
|
|
>
|
|
<div className="w-12 h-12 rounded-md bg-void/50 grid place-items-center shrink-0 overflow-hidden">
|
|
{img ? (
|
|
<img src={img} alt="" className="w-full h-full object-contain" />
|
|
) : (
|
|
<Radio size={18} className="text-text-3" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate flex items-baseline gap-2">
|
|
{ch.ChannelNumber && (
|
|
<span className="text-[11px] text-text-4 tabular-nums shrink-0">{ch.ChannelNumber}</span>
|
|
)}
|
|
{ch.Name || 'Channel'}
|
|
</p>
|
|
{program?.Name ? (
|
|
<p className="text-[11.5px] text-text-3 truncate mt-0.5">
|
|
{programTime(program.StartDate, program.EndDate)}{' '}<span className="text-text-2">{program.Name}</span>
|
|
</p>
|
|
) : (
|
|
<p className="text-[11.5px] text-text-4 italic mt-0.5">No program info</p>
|
|
)}
|
|
</div>
|
|
<Play size={14} className="text-accent shrink-0" />
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
function RecordingsList() {
|
|
const navigate = useNavigate()
|
|
const serverUrl = jellyfinClient.getAuthState()?.serverUrl || ''
|
|
const { data: recordings = [], isLoading } = useLiveTvRecordings()
|
|
|
|
if (isLoading) return <Skeleton />
|
|
if (recordings.length === 0) {
|
|
return <EmptyMsg>No recordings yet. Schedule one from a program to get started.</EmptyMsg>
|
|
}
|
|
|
|
return (
|
|
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
|
|
{recordings.map(r => {
|
|
const imageTag = r.ImageTags?.Primary
|
|
const img = r.Id && imageTag ? getImageUrl(serverUrl, r.Id, 'Primary', 220, imageTag) : ''
|
|
return (
|
|
<li key={r.Id}>
|
|
<button
|
|
onClick={() => r.Id && navigate(`/item/${r.Id}`)}
|
|
className="w-full text-left p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition flex items-center gap-3 focus-ring"
|
|
>
|
|
<div className="w-20 h-12 rounded-md bg-void/50 overflow-hidden shrink-0">
|
|
{img && <img src={img} alt="" className="w-full h-full object-cover" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate">
|
|
{r.Name || 'Untitled'}
|
|
</p>
|
|
<p className="text-[11.5px] text-text-3 truncate mt-0.5 flex items-center gap-1.5">
|
|
<Clock size={11} />
|
|
{r.StartDate ? new Date(r.StartDate).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
|
{r.ChannelName && <span className="text-text-4"> - {r.ChannelName}</span>}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
function ScheduledList() {
|
|
const { data: timers = [], isLoading } = useLiveTvTimers()
|
|
|
|
if (isLoading) return <Skeleton />
|
|
if (timers.length === 0) {
|
|
return <EmptyMsg>Nothing on the schedule.</EmptyMsg>
|
|
}
|
|
|
|
return (
|
|
<ul className="space-y-1.5">
|
|
{timers.map((t: any) => (
|
|
<li
|
|
key={t.Id}
|
|
className="p-3 rounded-lg bg-elevated/40 ring-1 ring-border flex items-center gap-3"
|
|
>
|
|
<div className="w-10 h-10 rounded-md bg-accent/10 grid place-items-center shrink-0">
|
|
<CalendarEvent size={16} className="text-accent" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate">
|
|
{t.Name || t.ProgramName || 'Scheduled recording'}
|
|
</p>
|
|
<p className="text-[11.5px] text-text-3 truncate mt-0.5">
|
|
{t.StartDate ? new Date(t.StartDate).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
|
{t.ChannelName && <span className="text-text-4"> - {t.ChannelName}</span>}
|
|
</p>
|
|
</div>
|
|
{t.Status && (
|
|
<span className="text-[10.5px] uppercase tracking-[0.14em] font-semibold text-text-4">
|
|
{t.Status}
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
function programTime(start?: string | null, end?: string | null): string {
|
|
if (!start) return ''
|
|
const s = new Date(start)
|
|
const e = end ? new Date(end) : null
|
|
if (Number.isNaN(s.getTime())) return ''
|
|
const fmt = (d: Date) => d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
return e ? `${fmt(s)}-${fmt(e)} ` : `${fmt(s)} `
|
|
}
|
|
|
|
function Skeleton() {
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-[68px] rounded-lg bg-elevated/20 animate-pulse" />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptyMsg({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
|
|
<p className="text-[12.5px] text-text-3">{children}</p>
|
|
</div>
|
|
)
|
|
}
|