Files
gander/frontend/src/pages/StreamDashboardPage.tsx

481 lines
16 KiB
TypeScript

import React, { useState, useEffect } from "react";
import DynamicPageContent from "../components/Layout/DynamicPageContent";
import Button from "../components/Input/Button";
import Input from "../components/Input/Input";
import { useCategories } from "../hooks/useContent";
import {
X as CloseIcon,
Eye as ShowIcon,
EyeOff as HideIcon,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
import { debounce } from "lodash";
import VideoPlayer from "../components/Stream/VideoPlayer";
import { CategoryType } from "../types/CategoryType";
import { StreamListItem } from "../components/Layout/ListItem";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
interface StreamData {
title: string;
category_name: string;
viewer_count: number;
start_time: string;
stream_key: string;
}
const StreamDashboardPage: React.FC = () => {
const { username } = useAuth();
const [isStreaming, setIsStreaming] = useState(false);
const [streamData, setStreamData] = useState<StreamData>({
title: "",
category_name: "",
viewer_count: 0,
start_time: "",
stream_key: "",
});
const [streamDetected, setStreamDetected] = useState(false);
const [timeStarted, setTimeStarted] = useState("");
const [isCategoryFocused, setIsCategoryFocused] = useState(false);
const [filteredCategories, setFilteredCategories] = useState<CategoryType[]>(
[]
);
const [thumbnail, setThumbnail] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<{
url: string;
isCustom: boolean;
}>({ url: "", isCustom: false });
const [debouncedCheck, setDebouncedCheck] = useState<Function | null>(null);
const [showKey, setShowKey] = useState(false);
const {
categories,
isLoading: categoriesLoading,
error: categoriesError,
} = useCategories("/api/categories/popular/100");
useEffect(() => {
// Set filtered categories when categories load
if (categories.length > 0) {
setFilteredCategories(categories);
}
}, [categories]);
useEffect(() => {
const categoryCheck = debounce((categoryName: string) => {
const isValidCategory = categories.some(
(cat) => cat.title.toLowerCase() === categoryName.toLowerCase()
);
if (isValidCategory && !thumbnailPreview.isCustom) {
const defaultThumbnail = getCategoryThumbnail(categoryName);
setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
}
}, 300);
setDebouncedCheck(() => categoryCheck);
return () => {
categoryCheck.cancel();
};
}, [categories, thumbnailPreview.isCustom]);
useEffect(() => {
if (username) {
checkStreamStatus();
}
}, [username]);
const checkStreamStatus = async () => {
if (!username) return;
try {
const response = await fetch(`/api/user/${username}/status`);
const data = await response.json();
setIsStreaming(data.is_live);
if (data.is_live) {
const streamResponse = await fetch(
`/api/streams/${data.user_id}/data`,
{ credentials: "include" }
);
const streamData = await streamResponse.json();
setStreamData({
title: streamData.title,
category_name: streamData.category_name,
viewer_count: streamData.num_viewers,
start_time: streamData.start_time,
stream_key: streamData.stream_key,
});
console.log("Stream data:", streamData);
const time = Math.floor(
(Date.now() - new Date(streamData.start_time).getTime()) / 60000 // Convert to minutes
);
if (time < 60) setTimeStarted(`${time}m ago`);
else if (time < 1440)
setTimeStarted(`${Math.floor(time / 60)}h ${time % 60}m ago`);
else
setTimeStarted(
`${Math.floor(time / 1440)}d ${Math.floor((time % 1440) / 60)}h ${
time % 60
}m ago`
);
} else {
// Just need the stream key if not streaming
const response = await fetch(`/api/user/${username}/stream_key`);
const keyData = await response.json();
setStreamData((prev) => ({
...prev,
stream_key: keyData.stream_key,
}));
}
} catch (error) {
console.error("Error checking stream status:", error);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setStreamData((prev) => ({ ...prev, [name]: value }));
if (name === "category_name") {
const filtered = categories.filter((cat) =>
cat.title.toLowerCase().includes(value.toLowerCase())
);
setFilteredCategories(filtered);
if (debouncedCheck) {
debouncedCheck(value);
}
}
};
const handleCategorySelect = (categoryName: string) => {
console.log("Selected category:", categoryName);
setStreamData((prev) => ({ ...prev, category_name: categoryName }));
setFilteredCategories([]);
if (debouncedCheck) {
debouncedCheck(categoryName);
}
};
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setThumbnail(file);
setThumbnailPreview({
url: URL.createObjectURL(file),
isCustom: true,
});
} else {
setThumbnail(null);
if (streamData.category_name && debouncedCheck) {
debouncedCheck(streamData.category_name);
} else {
setThumbnailPreview({ url: "", isCustom: false });
}
}
};
const clearThumbnail = () => {
setThumbnail(null);
if (streamData.category_name) {
console.log(
"Clearing thumbnail as category is set and default category thumbnail will be used"
);
const defaultThumbnail = getCategoryThumbnail(streamData.category_name);
setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
} else {
setThumbnailPreview({ url: "", isCustom: false });
}
};
const isFormValid = () => {
return (
streamData.title.trim() !== "" &&
streamData.category_name.trim() !== "" &&
categories.some(
(cat) =>
cat.title.toLowerCase() === streamData.category_name.toLowerCase()
) &&
streamDetected
);
};
const handlePublishStream = async () => {
console.log("Starting stream with data:", streamData);
try {
const response = await fetch("/api/publish_stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(streamData),
});
if (response.ok) {
console.log("Stream published successfully");
window.location.reload();
} else if (response.status === 403) {
console.error("Unauthorized - Invalid stream key or already streaming");
} else {
console.error("Failed to publish stream");
}
} catch (error) {
console.error("Error publishing stream:", error);
}
};
const handleUpdateStream = async () => {
console.log("Updating stream with data:", streamData);
try {
const response = await fetch("/api/update_stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: streamData.stream_key,
title: streamData.title,
category_name: streamData.category_name,
thumbnail: thumbnail,
}),
});
if (response.ok) {
console.log("Stream updated successfully");
window.location.reload();
} else {
console.error("Failed to update stream");
}
} catch (error) {
console.error("Error updating stream:", error);
}
};
const handleEndStream = async () => {
console.log("Ending stream...");
try {
const response = await fetch("/api/end_stream", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: streamData.stream_key }),
});
if (response.ok) {
console.log("Stream ended successfully");
window.location.reload();
} else {
console.error("Failed to end stream");
}
} catch (error) {
console.error("Error ending stream:", error);
}
};
return (
<DynamicPageContent className="flex flex-col min-h-screen bg-gradient-radial from-purple-600 via-blue-900 to-black">
<div
id="streamer-dashboard"
className="flex flex-col flex-grow mx-auto px-4"
>
<h1 className="text-4xl font-bold text-white text-center">
{isStreaming ? "Stream Dashboard" : "Start Streaming"}
</h1>
<div className="flex flex-grow gap-8 items-stretch pb-4">
{/* Left side - Stream Settings */}
<div className="flex flex-col flex-grow">
<h2 className="text-center text-2xl font-bold text-white mb-4">
Stream Settings
</h2>
<div className="flex flex-col flex-grow justify-evenly space-y-6 bg-gray-800 rounded-lg p-6 shadow-xl">
<div>
<label className="block text-white mb-2">Stream Title</label>
<Input
name="title"
value={streamData.title}
onChange={handleInputChange}
placeholder="Enter your stream title"
extraClasses="w-[70%] focus:w-[70%]"
maxLength={50}
/>
</div>
<div className="relative">
<label className="block text-white mb-2">Category</label>
<Input
name="category_name"
value={streamData.category_name}
onChange={handleInputChange}
onFocus={() => setIsCategoryFocused(true)}
onBlur={() =>
setTimeout(() => setIsCategoryFocused(false), 200)
}
placeholder="Select or type a category"
extraClasses="w-[70%] focus:w-[70%]"
maxLength={50}
autoComplete="off"
type="search"
/>
{isCategoryFocused && filteredCategories.length > 0 && (
<div className="absolute z-10 w-full bg-gray-700 mt-1 rounded-md shadow-lg max-h-48 overflow-y-auto">
{filteredCategories.map((category) => (
<div
key={category.title}
className="px-4 py-2 hover:bg-gray-600 cursor-pointer text-white"
onClick={() => handleCategorySelect(category.title)}
>
{category.title}
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-white mb-2">
Stream Thumbnail
</label>
<div className="relative flex flex-row items-center justify-center">
<input
type="file"
accept="image/*"
onChange={handleThumbnailChange}
className="hidden"
id="thumbnail-upload"
/>
<label
htmlFor="thumbnail-upload"
className="cursor-pointer inline-block bg-gray-700 hover:bg-gray-600 text-white py-2 px-4 rounded-lg transition-colors"
>
{thumbnail ? "Change Thumbnail" : "Choose Thumbnail"}
</label>
<span className="ml-3 text-gray-400">
{thumbnail ? thumbnail.name : "No file selected"}
</span>
{thumbnailPreview.isCustom && (
<button
onClick={clearThumbnail}
className="absolute right-0 top-0 p-1 bg-red-500 rounded-full hover:bg-red-600 transition-colors"
>
<CloseIcon size={16} className="text-white" />
</button>
)}
</div>
{!thumbnailPreview.isCustom && (
<p className="text-gray-400 mt-2 text-sm text-center">
No thumbnail selected - the default category image will be
used
</p>
)}
</div>
{isStreaming && (
<div className="bg-gray-700 p-4 rounded-lg">
<h3 className="text-white font-semibold mb-2">Stream Info</h3>
<p className="text-gray-300">
Viewers: {streamData.viewer_count}
</p>
<p className="text-gray-300">
Started:{" "}
{new Date(streamData.start_time!).toLocaleTimeString()}
{` (${timeStarted})`}
</p>
</div>
)}
<div className="flex items-center mx-auto p-10 bg-gray-900 w-fit rounded-xl py-4">
<label className="block text-white mr-8">Stream Key</label>
<Input
type={showKey ? "text" : "password"}
value={streamData.stream_key}
readOnly
extraClasses="w-fit pr-[30px]"
disabled
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="-translate-x-[30px] top-1/2 h-6 w-6 text-white"
>
{showKey ? (
<HideIcon className="h-6 w-6" />
) : (
<ShowIcon className="h-6 w-6" />
)}
</button>
</div>
<div className="flex flex-col w-fit mx-auto items-center justify-center pt-6 gap-6">
<div className="flex gap-8">
<Button
onClick={
isStreaming ? handleUpdateStream : handlePublishStream
}
disabled={!isFormValid()}
extraClasses="text-2xl px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStreaming ? "Update Stream" : "Start Streaming"}
</Button>
{isStreaming && (
<Button
onClick={handleEndStream}
extraClasses="text-2xl px-8 py-4 hover:text-red-500 hover:border-red-500"
>
End Stream
</Button>
)}
</div>
{!streamDetected && (
<p className="text-red-500 text-sm">
No stream input detected. Please start streaming using your
broadcast software.
</p>
)}
</div>
</div>
</div>
{/* Right side - Preview */}
<div className="w-[25vw] flex flex-col">
<h2 className="text-center text-2xl font-bold text-white mb-4">
Stream Preview
</h2>
<div className="flex flex-col gap-4 bg-gray-800 rounded-lg p-4 w-full h-fit flex-grow justify-around">
<div className="flex flex-col">
<p className="text-white text-center pb-4">Video</p>
<VideoPlayer
streamer={username ?? undefined}
extraClasses="border border-white"
onStreamDetected={setStreamDetected}
/>
</div>
<div className="flex flex-col">
<p className="text-white text-center">List Item</p>
<StreamListItem
id={1}
title={streamData.title || "Stream Title"}
username={username || ""}
streamCategory={streamData.category_name || "Category"}
viewers={streamData.viewer_count}
thumbnail={thumbnailPreview.url || ""}
onItemClick={() => {}}
extraClasses="max-w-[20vw]"
/>
</div>
</div>
</div>
</div>
</div>
</DynamicPageContent>
);
};
export default StreamDashboardPage;