main pages
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user