313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import {useAuth} from "../utils/AuthContext.tsx";
|
|
import Landing from "./Landing.tsx";
|
|
import {Link, useNavigate} from "react-router-dom";
|
|
import Card from "../components/Card.tsx";
|
|
import {useEffect, useState} from "react";
|
|
import IsLivePulse from "../components/IsLivePulse.tsx";
|
|
import type {Clip, Marker, StreamStatus, Vod} from "../utils/types.ts";
|
|
import {getCurrentStream} from "../utils/api/stream.ts";
|
|
import VideoCard from "../components/video/VideoCard.tsx";
|
|
import {createMarker, getMarkers} from "../utils/api/markers.ts";
|
|
import clsx from "clsx";
|
|
import {getVods} from "../utils/api/vods.ts";
|
|
import {getClips} from "../utils/api/clips.ts";
|
|
import Button from "../components/buttons/Button.tsx";
|
|
|
|
type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
|
|
|
|
function getTimeOfDay(date: Date = new Date()): TimeOfDay {
|
|
const hour = date.getHours();
|
|
|
|
if (hour >= 5 && hour < 12) return "morning";
|
|
if (hour >= 12 && hour < 17) return "afternoon";
|
|
if (hour >= 17 && hour < 22) return "evening";
|
|
return "night";
|
|
}
|
|
|
|
function getGreetingSubtext(date: Date = new Date()): string {
|
|
const timeOfDay = getTimeOfDay(date);
|
|
|
|
const messages: Record<TimeOfDay, string[]> = {
|
|
morning: [
|
|
"Good morning.",
|
|
"Coffee first, then clips.",
|
|
"Here's what's waiting for you.",
|
|
],
|
|
afternoon: [
|
|
"Good afternoon.",
|
|
"A quiet moment for your clips.",
|
|
"Catching up?",
|
|
],
|
|
evening: [
|
|
"Good evening.",
|
|
"Settle in.",
|
|
"Time to unwind.",
|
|
],
|
|
night: [
|
|
"Still up?",
|
|
"Quiet night for it.",
|
|
"Good night, when you're ready.",
|
|
]
|
|
}
|
|
|
|
const options = messages[timeOfDay];
|
|
return options[Math.floor(Math.random() * options.length)];
|
|
}
|
|
|
|
function getTimeFromISOString(date: string | null): string {
|
|
if (date == null) {
|
|
return "--:--";
|
|
}
|
|
|
|
const d = new Date(date);
|
|
return new Intl.DateTimeFormat('en-GB', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
}).format(d);
|
|
}
|
|
|
|
type SectionHeaderProps = { title: string; to: string; count: number };
|
|
|
|
const SectionHeader = ({ title, to, count }: SectionHeaderProps) => (
|
|
<div className={"flex flex-row items-baseline justify-between gap-4 pb-4"}>
|
|
<h2 className={"font-heading text-2xl"}>{title}</h2>
|
|
{count > 0 && (
|
|
<Link
|
|
to={to}
|
|
className={"font-body text-[13.5px] text-accent hover:text-accent-hover transition-colors duration-150"}
|
|
>
|
|
All {count} <span aria-hidden={true}>→</span>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const Home = () => {
|
|
const now = new Date();
|
|
const formatted: string = new Intl.DateTimeFormat('en-IE', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
}).format(now);
|
|
|
|
const { user } = useAuth();
|
|
const [ streamStatus, setStreamStatus ] = useState<StreamStatus | null>(null)
|
|
const [ elapsed, setElapsed ] = useState('00:00:00');
|
|
const [ markers, setMarkers ] = useState<Marker[]>([])
|
|
const [ clips, setClips ] = useState<Clip[]>([]);
|
|
const [ vods, setVods ] = useState<Vod[]>([]);
|
|
const [ counts, setCounts ] = useState({ vods: 0, clips: 0 });
|
|
const [ markerBusy, setMarkerBusy ] = useState(false);
|
|
const [ markerNote, setMarkerNote ] = useState<string | null>(null);
|
|
|
|
const isStreaming = streamStatus?.isStreaming ?? false;
|
|
|
|
const [ greeting ] = useState<string>(getGreetingSubtext(new Date()));
|
|
const navigate = useNavigate();
|
|
|
|
// Fetch Markers & Stream information
|
|
useEffect(() => {
|
|
getCurrentStream()
|
|
.then(setStreamStatus)
|
|
|
|
getMarkers()
|
|
.then(data => {
|
|
setMarkers(data);
|
|
})
|
|
|
|
// Get Top 4 Clips
|
|
getClips()
|
|
.then(data => {
|
|
const topSix = [...data]
|
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
.slice(0, 4);
|
|
setClips(topSix);
|
|
setCounts(prev => ({ ...prev, clips: data.length }));
|
|
})
|
|
|
|
// Get top 3 vods
|
|
getVods()
|
|
.then(data => {
|
|
const topSix = [...data]
|
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
.slice(0, 3);
|
|
setVods(topSix);
|
|
setCounts(prev => ({ ...prev, vods: data.length }));
|
|
})
|
|
}, []);
|
|
|
|
const handleDropMarker = async () => {
|
|
if (markerBusy) return;
|
|
setMarkerBusy(true);
|
|
try {
|
|
await createMarker(`Marker at ${elapsed}`);
|
|
setMarkers(await getMarkers());
|
|
setMarkerNote(`dropped at ${elapsed}`);
|
|
} catch (err) {
|
|
setMarkerNote(err instanceof Error ? err.message : "Couldn't drop that marker");
|
|
} finally {
|
|
setMarkerBusy(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (streamStatus?.startDate == null) {
|
|
setElapsed('00:00:00');
|
|
return;
|
|
}
|
|
|
|
const startMs = new Date(streamStatus?.startDate).getTime();
|
|
|
|
const tick = () => {
|
|
const diffSec = Math.floor((Date.now() - startMs) / 1000);
|
|
const hh = String(Math.floor(diffSec / 3600)).padStart(2, '0');
|
|
const mm = String(Math.floor((diffSec % 3600) / 60)).padStart(2, '0');
|
|
const ss = String(diffSec % 60).padStart(2, '0');
|
|
setElapsed(`${hh}:${mm}:${ss}`);
|
|
};
|
|
|
|
tick();
|
|
const interval = setInterval(tick, 1000);
|
|
return () => clearInterval(interval);
|
|
}, [streamStatus?.startDate]);
|
|
|
|
if (user == null) {
|
|
return (<Landing />)
|
|
}
|
|
|
|
return (
|
|
<div className={"w-10/12 m-auto pt-10"}>
|
|
<div className={"flex flex-row justify-between items-start pb-8"}>
|
|
<div>
|
|
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
|
|
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
|
|
</div>
|
|
|
|
<Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
|
|
Open Stream Panel
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Live status — always present, so the page has the same shape either way. */}
|
|
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
|
|
<div className={"flex flex-row items-center gap-2"}>
|
|
{isStreaming
|
|
? <IsLivePulse />
|
|
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
|
|
<p className={clsx(
|
|
"text-xs font-data tracking-[0.12em]",
|
|
isStreaming ? "text-live-red" : "text-faint-mono"
|
|
)}>
|
|
{isStreaming ? "LIVE NOW" : "OFF AIR"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className={"p-1"}></div>
|
|
|
|
{isStreaming ? (
|
|
<>
|
|
<h2 className={"font-heading text-3xl"}>
|
|
Session {String(streamStatus?.id).padStart(5, '0')} {' '}
|
|
recording since {getTimeFromISOString(streamStatus?.startDate ?? null)}.</h2>
|
|
|
|
<div className={"flex flex-row items-center gap-10 pt-4"}>
|
|
<div className={"flex flex-col"}>
|
|
<h2 className={"font-data text-xl"}>{elapsed}</h2>
|
|
<p className={"text-xs text-muted"}>elapsed</p>
|
|
</div>
|
|
|
|
<div className={"flex flex-col"}>
|
|
<h2 className={"font-data text-xl"}>{markers.length}</h2>
|
|
<p className={"text-xs text-muted"}>markers dropped</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={"mt-4 flex items-center gap-4"}>
|
|
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
|
|
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h2 className={"font-heading text-3xl"}>Nothing is streaming right now.</h2>
|
|
<p className={"pt-2 font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty"}>
|
|
Point OBS at the ingest URL with your stream key and this block turns over to
|
|
elapsed time and markers.
|
|
</p>
|
|
|
|
<div className={"flex flex-row items-center gap-10 pt-4"}>
|
|
<div className={"flex flex-col"}>
|
|
<h2 className={"font-data text-xl"}>{counts.vods}</h2>
|
|
<p className={"text-xs text-muted"}>vods saved</p>
|
|
</div>
|
|
|
|
<div className={"flex flex-col"}>
|
|
<h2 className={"font-data text-xl"}>{counts.clips}</h2>
|
|
<p className={"text-xs text-muted"}>clips cut</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={"mt-4"}>
|
|
<Link to={"/stream"}>
|
|
<Button variant={"secondary"}>Open the stream panel</Button>
|
|
</Link>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
<div className={"flex flex-col"}>
|
|
<SectionHeader title={"Recent VoDs"} to={"/my-vods"} count={counts.vods} />
|
|
|
|
<div className={"grid grid-cols-3 gap-5.5"}>
|
|
{vods.map((vod) => (
|
|
<VideoCard
|
|
key={vod.id}
|
|
id={vod.id}
|
|
title={vod.title}
|
|
duration={vod.duration}
|
|
createdAt={vod.createdAt}
|
|
width={vod.width}
|
|
height={vod.height}
|
|
fps={vod.fps}
|
|
fileSize={vod.fileSize}
|
|
mediaApiPath="/api/v1/vods"
|
|
playerPath="/vod"
|
|
itemLabel="vod"
|
|
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={"flex flex-col mt-9"}>
|
|
<SectionHeader title={"Recent Clips"} to={"/my-clips"} count={counts.clips} />
|
|
|
|
<div className={"grid grid-cols-4 gap-4.5"}>
|
|
{clips.map((clip) => (
|
|
<VideoCard
|
|
key={clip.id}
|
|
id={clip.id}
|
|
title={clip.title}
|
|
duration={clip.duration}
|
|
createdAt={clip.createdAt}
|
|
width={clip.width}
|
|
height={clip.height}
|
|
fps={clip.fps}
|
|
fileSize={clip.fileSize}
|
|
mediaApiPath="/api/v1/clips"
|
|
playerPath="/clip"
|
|
itemLabel="clip"
|
|
onEdit={() => navigate(`/clip/${clip.id}/edit`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Home;
|