Files
jellybloom/src/pages/LiveTvPage.tsx
T
2026-03-30 13:40:42 +03:00

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