This commit is contained in:
EvanLin3141
2025-02-21 22:54:42 +00:00
28 changed files with 723 additions and 168 deletions

View File

@@ -1,17 +1,19 @@
{
"name": "frontend",
"version": "0.1.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.1.0",
"version": "0.5.0",
"dependencies": {
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"@types/video.js": "^7.3.58",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-chrome-dino": "^0.1.3",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3",
"socket.io-client": "^4.8.1",
@@ -20,6 +22,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/lodash": "^4.17.15",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
@@ -1458,6 +1461,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.14",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
@@ -3083,6 +3093,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -3682,6 +3698,12 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chrome-dino": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/react-chrome-dino/-/react-chrome-dino-0.1.3.tgz",
"integrity": "sha512-MrQ+gixctthQbMiT1tc2aNIKEskxGnXVGq0CdpzSLdY8zLeDUacmKEzuU0B3em7LY4R+nFY3xlb4S+63G1F53A==",
"license": "MIT"
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -15,7 +15,9 @@
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"@types/video.js": "^7.3.58",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-chrome-dino": "^0.1.3",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3",
"socket.io-client": "^4.8.1",
@@ -24,6 +26,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/lodash": "^4.17.15",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -12,6 +12,7 @@ import CategoriesPage from "./pages/AllCategoriesPage";
import ResultsPage from "./pages/ResultsPage";
import { SidebarProvider } from "./context/SidebarContext";
import { QuickSettingsProvider } from "./context/QuickSettingsContext";
import StreamDashboardPage from "./pages/StreamDashboardPage";
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -51,6 +52,7 @@ function App() {
)
}
/>
<Route path="/go-live" element={isLoggedIn ? <StreamDashboardPage /> : <Navigate to="/" replace />} />
<Route path="/:streamerName" element={<StreamerRoute />} />
<Route path="/user/:username" element={<UserPage />} />
<Route

View File

@@ -171,6 +171,10 @@ body {
color: var(--text-color);
}
.message {
word-wrap: anywhere;
}

View File

@@ -1,23 +1,22 @@
import React from "react";
interface ButtonProps {
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
type?: "button" | "submit" | "reset";
extraClasses?: string;
children?: React.ReactNode;
onClick?: () => void;
}
const Button: React.FC<ButtonProps> = ({
type = "button",
children = "Submit",
extraClasses = "",
onClick,
...props
}) => {
return (
<button
type={type}
className={`${extraClasses} p-2 text-[1.5rem] text-white hover:text-purple-600 bg-black/30 hover:bg-black/80 rounded-md border border-gray-300 hover:border-purple-500 hover:border-b-4 hover:border-l-4 active:border-b-2 active:border-l-2 transition-all`}
onClick={onClick}
{...props}
>
{children}
</button>

View File

@@ -27,7 +27,7 @@ const Input: React.FC<InputProps> = ({
onChange={onChange}
onKeyDown={onKeyDown}
{...props}
className={`${extraClasses} relative p-2 rounded-[1rem] w-[20vw] focus:w-[21vw] bg-black/40 border border-gray-300 focus:border-purple-500 focus:outline-purple-500 text-center text-white text-xl transition-all`}
className={`${extraClasses} relative p-2 rounded-[1rem] w-[20vw] focus:w-[22vw] bg-black/40 border border-gray-300 focus:border-purple-500 focus:outline-purple-500 text-center text-white text-xl transition-all`}
/>
</div>

View File

@@ -8,18 +8,6 @@ const SearchBar: React.FC = () => {
//const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
const navigate = useNavigate();
// Debounce the search query
{
/*
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 500); // Wait 500ms after user stops typing
return () => clearTimeout(timer);
}, [searchQuery]); */
}
const handleSearch = async () => {
if (!searchQuery.trim()) return;

View File

@@ -9,6 +9,7 @@ export interface ListItemProps {
viewers: number;
thumbnail?: string;
onItemClick?: () => void;
extraClasses?: string;
}
const ListItem: React.FC<ListItemProps> = ({
@@ -19,12 +20,12 @@ const ListItem: React.FC<ListItemProps> = ({
viewers,
thumbnail,
onItemClick,
extraClasses = "",
}) => {
return (
<div className="p-4">
<div
className="min-w-[25vw] overflow-hidden flex-shrink-0 flex flex-col bg-purple-900 rounded-lg
cursor-pointer hover:bg-pink-700 hover:scale-105 transition-all"
className={`${extraClasses} overflow-hidden flex-shrink-0 flex flex-col bg-purple-900 rounded-lg cursor-pointer mx-auto hover:bg-pink-700 hover:scale-105 transition-all`}
onClick={onItemClick}
>
<div className="relative w-full pt-[56.25%] overflow-hidden rounded-t-lg">

View File

@@ -14,6 +14,7 @@ interface ListRowProps {
wrap: boolean;
onClick: (itemName: string) => void;
extraClasses?: string;
itemExtraClasses?: string;
children?: React.ReactNode;
}
@@ -25,6 +26,7 @@ const ListRow: React.FC<ListRowProps> = ({
wrap,
onClick,
extraClasses = "",
itemExtraClasses = "",
children,
}) => {
const slider = useRef<HTMLDivElement>(null);
@@ -52,7 +54,7 @@ const ListRow: React.FC<ListRowProps> = ({
</div>
<div className="relative overflow-hidden flex items-center z-0">
{!wrap && items.length > 3 && (
{!wrap && items.length > 4 && (
<>
<ArrowLeftIcon
onClick={slideLeft}
@@ -91,6 +93,7 @@ const ListRow: React.FC<ListRowProps> = ({
? onClick?.(item.streamer)
: onClick?.(item.title)
}
extraClasses={`${itemExtraClasses} min-w-[25vw]`}
/>
))}
</div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import Logo from "../Layout/Logo";
import Button, { ToggleButton } from "../Input/Button";
import Sidebar from "./Sidebar";
@@ -7,6 +7,7 @@ import {
LogIn as LogInIcon,
LogOut as LogOutIcon,
Settings as SettingsIcon,
Radio as LiveIcon,
} from "lucide-react";
import SearchBar from "../Input/SearchBar";
import AuthModal from "../Auth/AuthModal";
@@ -15,6 +16,7 @@ import { useAuth } from "../../context/AuthContext";
import QuickSettings from "../Settings/QuickSettings";
import { useSidebar } from "../../context/SidebarContext";
import { useQuickSettings } from "../../context/QuickSettingsContext";
import { useNavigate } from "react-router-dom";
interface NavbarProps {
variant?: "home" | "default";
@@ -25,6 +27,7 @@ const Navbar: React.FC<NavbarProps> = ({ variant = "default" }) => {
const { showAuthModal, setShowAuthModal } = useAuthModal();
const { showSideBar, setShowSideBar } = useSidebar();
const { showQuickSettings, setShowQuickSettings } = useQuickSettings();
const navigate = useNavigate();
const handleLogout = () => {
console.log("Logging out...");
@@ -76,8 +79,10 @@ const Navbar: React.FC<NavbarProps> = ({ variant = "default" }) => {
}`}
>
<Logo variant={variant} />
{/* Login / Logout Button */}
<Button
extraClasses={`absolute top-[75px] ${
extraClasses={`absolute top-[2vh] ${
showSideBar
? "left-[16vw] duration-[0.5s]"
: "left-[20px] duration-[1s]"
@@ -102,7 +107,7 @@ const Navbar: React.FC<NavbarProps> = ({ variant = "default" }) => {
<>
<ToggleButton
onClick={() => handleSideBar()}
extraClasses={`absolute group text-[1rem] top-[20px] ${
extraClasses={`absolute group text-[1rem] top-[9vh] ${
showSideBar
? "left-[16vw] duration-[0.5s]"
: "left-[20px] duration-[1s]"
@@ -123,7 +128,7 @@ const Navbar: React.FC<NavbarProps> = ({ variant = "default" }) => {
{/* Quick Settings Sidebar */}
<ToggleButton
extraClasses={`absolute group text-[1rem] top-[20px] ${
extraClasses={`absolute group text-[1rem] top-[2vh] ${
showQuickSettings ? "right-[21vw]" : "right-[20px]"
} cursor-pointer`}
onClick={() => handleQuickSettings()}
@@ -141,6 +146,19 @@ const Navbar: React.FC<NavbarProps> = ({ variant = "default" }) => {
<SearchBar />
{/* Stream Button */}
{isLoggedIn && !window.location.pathname.includes('go-live') && (
<Button
extraClasses={`${
variant === "home" ? "absolute top-[2vh] right-[10vw]" : ""
} flex flex-row items-center`}
onClick={() => navigate("/go-live")}
>
<LiveIcon className="h-15 w-15 mr-2" />
Go Live
</Button>
)}
{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} />}
</div>
);

View File

@@ -177,7 +177,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
</span>
</div>
{/* Message content */}
<div className="w-full text-[0.9em] mt-0.5em flex flex-col overflow-hidden break-words">
<div className="message w-full text-[0.9em] mt-0.5em flex flex-col overflow-hidden" >
{msg.message}
</div>
</div>

View File

@@ -2,31 +2,50 @@ import React, { useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import videojs from "video.js";
import "video.js/dist/video-js.css";
interface VideoPlayerProps {
streamer?: string;
extraClasses?: string;
onStreamDetected?: (isStreamAvailable: boolean) => void;
}
const VideoPlayer: React.FC = () => {
const { streamerName } = useParams<{ streamerName: string }>(); // Get streamerName from URL
const VideoPlayer: React.FC<VideoPlayerProps> = ({
streamer,
extraClasses,
onStreamDetected,
}) => {
const { streamerName: urlStreamerName } = useParams<{
streamerName: string;
}>();
const videoRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<videojs.Player | null>(null);
// Use URL param if available, otherwise fall back to prop
const streamerName = urlStreamerName || streamer;
useEffect(() => {
if (!videoRef.current || !streamerName) return;
const streamUrl = `/stream/${streamerName}/index.m3u8`; // Updated URL with streamerName
const streamUrl = `/stream/${streamerName}/index.m3u8`;
if (!playerRef.current) {
const videoElement = document.createElement("video");
videoElement.classList.add("video-js", "vjs-big-play-centered", "w-full", "h-full");
videoElement.classList.add(
"video-js",
"vjs-big-play-centered",
"w-full",
"h-full"
);
videoElement.setAttribute("playsinline", "true");
videoRef.current.appendChild(videoElement);
playerRef.current = videojs(videoElement, {
controls: true,
controls: false,
autoplay: true,
muted: true,
muted: false,
fluid: true,
responsive: true,
aspectRatio: "16:9",
liveui: true,
liveui: false,
sources: [
{
src: streamUrl,
@@ -35,12 +54,19 @@ const VideoPlayer: React.FC = () => {
],
});
// Handle stream errors & retry
playerRef.current.on('loadeddata', () => {
if (onStreamDetected) onStreamDetected(true);
});
playerRef.current.on("error", () => {
console.error(`Stream failed to load: ${streamUrl}`);
if (onStreamDetected) onStreamDetected(false);
setTimeout(() => {
console.log("Retrying stream...");
playerRef.current?.src({ src: streamUrl, type: "application/x-mpegURL" });
playerRef.current?.src({
src: streamUrl,
type: "application/x-mpegURL",
});
playerRef.current?.play();
}, 5000);
});
@@ -58,7 +84,10 @@ const VideoPlayer: React.FC = () => {
}, [streamerName]);
return (
<div className="w-full h-full flex justify-center items-center bg-gray-900 rounded-lg">
<div
id="video-player"
className={`${extraClasses} w-full h-full mx-auto flex justify-center items-center bg-gray-900 rounded-lg`}
>
<div ref={videoRef} className="w-full max-w-[160vh] self-center" />
</div>
);

View File

@@ -1,21 +1,31 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import ListRow from "../components/Layout/ListRow";
import { useCategories } from "../context/ContentContext";
import DynamicPageContent from "../components/Layout/DynamicPageContent";
import { fetchContentOnScroll } from "../hooks/fetchContentOnScroll";
const AllCategoriesPage: React.FC = () => {
const { categories, setCategories } = useCategories();
const navigate = useNavigate();
const [categoryOffset, setCategoryOffset] = useState(0);
const [noCategories, setNoCategories] = useState(12);
const [hasMoreData, setHasMoreData] = useState(true);
useEffect(() => {
const fetchCategories = async () => {
try {
const response = await fetch("/api/categories/popular/8/0");
const response = await fetch(`/api/categories/popular/${noCategories}/${categoryOffset}`);
if (!response.ok) {
throw new Error("Failed to fetch categories");
}
const data = await response.json();
// Adds to offset once data is returned
if (data.length > 0) {
setCategoryOffset(prev => prev + data.length);
} else {
setHasMoreData(false);
}
// Transform the data to match CategoryItem interface
const processedCategories = data.map((category: any) => ({
@@ -37,6 +47,11 @@ const AllCategoriesPage: React.FC = () => {
fetchCategories();
}, [setCategories]);
const logOnScroll = () => {
console.log("hi")
};
fetchContentOnScroll(logOnScroll,hasMoreData)
if (!categories.length) {
return (
<div className="h-screen w-screen flex items-center justify-center text-white">

View File

@@ -38,7 +38,7 @@ const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => {
description={
variant === "personalised"
? "We think you might like these streams - Streamers recommended for you"
: "Streamers that are currently live"
: "Popular streamers that are currently live!"
}
items={streams}
wrap={false}
@@ -62,7 +62,7 @@ const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => {
description={
variant === "personalised"
? "Current streams from your followed categories"
: "Categories that have been 'popping off' lately"
: "Recently popular categories lately!"
}
items={categories}
wrap={false}

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Button from "../components/Input/Button";
// @ts-ignore
import ChromeDinoGame from "react-chrome-dino";
const NotFoundPage: React.FC = () => {
const navigate = useNavigate();
@@ -56,6 +58,7 @@ const NotFoundPage: React.FC = () => {
<div className="w-full text-center animate-floating">
<h1 className="text-6xl font-bold mb-4">404</h1>
<p className="text-2xl mb-8">Page Not Found</p>
<ChromeDinoGame />
<Button extraClasses="z-[100]" onClick={() => navigate("/")}>
Go Home
</Button>

View File

@@ -0,0 +1,457 @@
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 ListItem from "../components/Layout/ListItem";
import { X as XIcon, Eye as ShowIcon, EyeOff as HideIcon } from "lucide-react";
import { useAuth } from "../context/AuthContext";
import { debounce } from "lodash";
import VideoPlayer from "../components/Video/VideoPlayer";
interface StreamData {
title: string;
category_name: string;
viewer_count: number;
start_time: string;
stream_key: string;
}
interface Category {
category_id: number;
category_name: 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 [categories, setCategories] = useState<Category[]>([]);
const [isCategoryFocused, setIsCategoryFocused] = useState(false);
const [filteredCategories, setFilteredCategories] = useState<Category[]>([]);
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);
useEffect(() => {
const categoryCheck = debounce((categoryName: string) => {
const isValidCategory = categories.some(
(cat) => cat.category_name.toLowerCase() === categoryName.toLowerCase()
);
if (isValidCategory && !thumbnailPreview.isCustom) {
const defaultThumbnail = `/images/thumbnails/categories/${categoryName
.toLowerCase()
.replace(/ /g, "_")}.webp`;
setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
}
}, 300);
setDebouncedCheck(() => categoryCheck);
return () => {
categoryCheck.cancel();
};
}, [categories, thumbnailPreview.isCustom]);
useEffect(() => {
const checkStreamStatus = async () => {
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 {
const response = await fetch(`/api/user/${username}/stream_key`);
const keyData = await response.json();
setStreamData((prev) => ({
...prev,
stream_key: keyData.stream_key,
}));
console.log("Stream key:", keyData.stream_key);
}
} catch (error) {
console.error("Error checking stream status:", error);
}
};
const fetchCategories = async () => {
try {
const response = await fetch("/api/categories/popular/100");
const data = await response.json();
setCategories(data);
setFilteredCategories(data);
} catch (error) {
console.error("Error fetching categories:", error);
}
};
checkStreamStatus();
fetchCategories();
}, [username]);
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.category_name.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 = `/images/thumbnails/categories/${streamData.category_name
.toLowerCase()
.replace(/ /g, "_")}.webp`;
setThumbnailPreview({ url: defaultThumbnail, isCustom: false });
} else {
setThumbnailPreview({ url: "", isCustom: false });
}
};
const isFormValid = () => {
return (
streamData.title.trim() !== "" &&
streamData.category_name.trim() !== "" &&
categories.some(
(cat) =>
cat.category_name.toLowerCase() ===
streamData.category_name.toLowerCase()
) &&
streamDetected
);
};
const handleStartStream = async () => {
console.log("Starting stream with data:", streamData);
const formData = new FormData();
formData.append("key", streamData.stream_key);
try {
const response = await fetch("/api/publish_stream", {
method: "POST",
body: formData,
});
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);
};
const handleEndStream = async () => {
console.log("Ending stream...");
const formData = new FormData();
formData.append("key", streamData.stream_key);
try {
const response = await fetch("/api/end_stream", {
method: "POST",
body: formData,
});
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.category_id}
className="px-4 py-2 hover:bg-gray-600 cursor-pointer text-white"
onClick={() =>
handleCategorySelect(category.category_name)
}
>
{category.category_name}
</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"
>
<XIcon 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 : handleStartStream
}
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>
<ListItem
type="stream"
id={1}
title={streamData.title || "Stream Title"}
streamer={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;

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect } from "react";
import Navbar from "../components/Navigation/Navbar";
import AuthModal from "../components/Auth/AuthModal";
import { useAuthModal } from "../hooks/useAuthModal";
import { useAuth } from "../context/AuthContext";

View File

@@ -37,6 +37,7 @@ const VideoPage: React.FC<VideoPageProps> = ({ streamerId }) => {
const showReturn = window.location.search.includes("session_id");
const navigate = useNavigate();
const [isSubscribed, setIsSubscribed] = useState(false);
const [timeStarted, setTimeStarted] = useState("");
useEffect(() => {
// Prevent scrolling when checkout is open
@@ -60,7 +61,6 @@ const VideoPage: React.FC<VideoPageProps> = ({ streamerId }) => {
res
.json()
.then((data) => {
// Transform snake_case to camelCase
const transformedData: StreamDataProps = {
streamerName: data.username,
streamTitle: data.title,
@@ -69,6 +69,20 @@ const VideoPage: React.FC<VideoPageProps> = ({ streamerId }) => {
};
setStreamData(transformedData);
const time = Math.floor(
(Date.now() - new Date(data.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`
);
// Check if the logged-in user is following this streamer
if (isLoggedIn) checkFollowStatus(data.username);
})
@@ -100,14 +114,14 @@ const VideoPage: React.FC<VideoPageProps> = ({ streamerId }) => {
// Checks if user is subscribed
useEffect(() => {
fetch(`/api/user/subscription/${streamerName}/expiration`)
.then(response => response.json())
.then(data => {
console.log(data.remaining_time);
.then((response) => response.json())
.then((data) => {
console.log(streamData?.streamerName, data.remaining_time);
if (data.remaining_time > 0) {
setIsSubscribed(true);
}
})
.catch(error => console.error("Error fetching subscription:", error));
.catch((error) => console.error("Error fetching subscription:", error));
}, [streamerName]);
return (
@@ -214,12 +228,7 @@ const VideoPage: React.FC<VideoPageProps> = ({ streamerId }) => {
<div className="flex flex-col items-center">
<span className="text-gray-400 text-[0.75em]">Started</span>
<span className="text-[0.75em]">
{streamData
? `${Math.floor(
(Date.now() - new Date(streamData.startTime).getTime()) /
3600000
)} hours ago`
: "Loading..."}
{streamData ? timeStarted : "Loading..."}
</span>
</div>

28
package-lock.json generated
View File

@@ -1,28 +0,0 @@
{
"name": "cs3305-team11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"tailwind-scrollbar-hide": "^2.0.0"
}
},
"node_modules/tailwind-scrollbar-hide": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-2.0.0.tgz",
"integrity": "sha512-lqiIutHliEiODwBRHy4G2+Tcayo2U7+3+4frBmoMETD72qtah+XhOk5XcPzC1nJvXhXUdfl2ajlMhUc2qC6CIg==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >= 4.0.0 || >= 4.0.0-beta.8 || >= 4.0.0-alpha.20"
}
},
"node_modules/tailwindcss": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.3.tgz",
"integrity": "sha512-ImmZF0Lon5RrQpsEAKGxRvHwCvMgSC4XVlFRqmbzTEDb/3wvin9zfEZrMwgsa3yqBbPqahYcVI6lulM2S7IZAA==",
"license": "MIT",
"peer": true
}
}
}

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"tailwind-scrollbar-hide": "^2.0.0"
}
}

View File

@@ -14,10 +14,12 @@ stream_bp = Blueprint("stream", __name__)
# Constants
THUMBNAIL_GENERATION_INTERVAL = 10
## Path Manager
# Path Manager
path_manager = PathManager()
## Stream Routes
# Stream Routes
@stream_bp.route('/streams/popular/<int:no_streams>')
def popular_streams(no_streams) -> list[dict]:
"""
@@ -35,6 +37,7 @@ def popular_streams(no_streams) -> list[dict]:
streams = get_highest_view_streams(no_streams)
return jsonify(streams)
@stream_bp.route('/streams/popular/<string:category_name>')
@stream_bp.route('/streams/popular/<string:category_name>/<int:no_streams>/<int:offset>')
def popular_streams_by_category(category_name, no_streams=4, offset=0) -> list[dict]:
@@ -47,6 +50,7 @@ def popular_streams_by_category(category_name, no_streams=4, offset=0) -> list[d
streams = get_streams_based_on_category(category_id, no_streams, offset)
return jsonify(streams)
@login_required
@stream_bp.route('/streams/recommended')
def recommended_streams() -> list[dict]:
@@ -61,22 +65,34 @@ def recommended_streams() -> list[dict]:
streams = get_streams_based_on_category(category)
return streams
@stream_bp.route('/streams/<int:streamer_id>/data')
def stream_data(streamer_id):
def stream_data(streamer_id) -> dict:
"""
Returns a streamer's current stream data
"""
data = get_current_stream_data(streamer_id)
return jsonify(get_current_stream_data(streamer_id))
if session.get('user_id') == streamer_id:
with Database() as db:
stream_key = db.fetchone(
"""SELECT stream_key FROM users WHERE user_id = ?""", (streamer_id,))
if data:
data["stream_key"] = stream_key["stream_key"]
else:
data = {"stream_key": stream_key["stream_key"]}
return jsonify(data)
## Category Routes
# Category Routes
@stream_bp.route('/categories/popular/<int:no_categories>')
@stream_bp.route('/categories/popular/<int:no_categories>/<int:offset>')
def popular_categories(no_categories, offset=0) -> list[dict]:
def popular_categories(no_categories=4, offset=0) -> list[dict]:
"""
Returns a list of most popular categories
"""
print(no_categories, offset, flush=True)
# Limit the number of categories to 100
if no_categories < 1:
return jsonify([])
@@ -86,6 +102,7 @@ def popular_categories(no_categories, offset=0) -> list[dict]:
category_data = get_highest_view_categories(no_categories, offset)
return jsonify(category_data)
@login_required
@stream_bp.route('/categories/recommended')
def recommended_categories() -> list | list[dict]:
@@ -97,6 +114,7 @@ def recommended_categories() -> list | list[dict]:
categories = get_user_category_recommendations(user_id)
return jsonify(categories)
@login_required
@stream_bp.route('/categories/following')
def following_categories_streams():
@@ -108,7 +126,7 @@ def following_categories_streams():
return jsonify(streams)
## User Routes
# User Routes
@stream_bp.route('/user/<string:username>/status')
def user_live_status(username):
"""
@@ -133,7 +151,7 @@ def user_live_status(username):
})
## VOD Routes
# VOD Routes
@stream_bp.route('/vods/<string:username>')
def vods(username):
"""
@@ -144,7 +162,7 @@ def vods(username):
return jsonify(vods)
## RTMP Server Routes
# RTMP Server Routes
@stream_bp.route("/publish_stream", methods=["POST"])
def publish_stream():
"""
@@ -156,7 +174,7 @@ def publish_stream():
set user as streaming
periodically update thumbnail
"""
stream_key = request.form.get("name")
stream_key = request.form.get("key")
print("Stream request received")
# Open database connection
@@ -179,7 +197,8 @@ def publish_stream():
1))
# Set user as streaming
db.execute("""UPDATE users SET is_live = 1 WHERE user_id = ?""", (user_info["user_id"],))
db.execute("""UPDATE users SET is_live = 1 WHERE user_id = ?""",
(user_info["user_id"],))
username = user_info["username"]
user_id = user_info["user_id"]
@@ -195,6 +214,7 @@ def publish_stream():
return redirect(f"/{user_info['username']}/stream/")
@stream_bp.route("/end_stream", methods=["POST"])
def end_stream():
"""
@@ -209,7 +229,7 @@ def end_stream():
end thumbnail generation
"""
stream_key = request.form.get("name")
stream_key = request.form.get("key")
# Open database connection
with Database() as db:
@@ -222,7 +242,6 @@ def end_stream():
FROM streams
WHERE user_id = ?""", (user_info["user_id"],))
# If stream key is invalid, return unauthorized
if not user_info:
return "Unauthorized", 403
@@ -232,7 +251,8 @@ def end_stream():
WHERE user_id = ?""", (user_info["user_id"],))
# Move stream to vod table
stream_length = int((datetime.now() - parser.parse(stream_info["start_time"])).total_seconds())
stream_length = int(
(datetime.now() - parser.parse(stream_info["start_time"])).total_seconds())
db.execute("""INSERT INTO vods (user_id, title, datetime, category_id, length, views)
VALUES (?, ?, ?, ?, ?, ?)""", (user_info["user_id"],
@@ -252,6 +272,7 @@ def end_stream():
# Get username
username = user_info["username"]
combine_ts_stream.delay(path_manager.get_stream_path(username), path_manager.get_vods_path(username), vod_id)
combine_ts_stream.delay(path_manager.get_stream_path(
username), path_manager.get_vods_path(username), vod_id)
return "Stream ended", 200

View File

@@ -21,6 +21,16 @@ def user_data(username: str):
data = get_user(user_id)
return jsonify(data)
@user_bp.route('/user/<string:username>/stream_key')
def user_stream_key(username: str):
"""
Returns a stream key for a given user
"""
user_id = get_user_id(username)
with Database() as db:
data = db.fetchone("SELECT stream_key FROM users WHERE user_id = ?", (user_id,))
return jsonify({"stream_key": data["stream_key"]})
## Subscription Routes
@login_required
@user_bp.route('/user/subscription/<string:streamer_name>')

Binary file not shown.

View File

@@ -50,15 +50,39 @@ INSERT INTO categories (category_name) VALUES
('Music'),
('Art'),
('Education'),
('Sports');
('Sports'),
('League of Legends'),
('Fortnite'),
('Minecraft'),
('Call of Duty'),
('Counter-Strike 2'),
('Valorant'),
('Dota 2'),
('Apex Legends'),
('Grand Theft Auto V'),
('The Legend of Zelda: Breath of the Wild'),
('Elden Ring'),
('Red Dead Redemption 2'),
('Cyberpunk 2077'),
('Super Smash Bros. Ultimate'),
('Overwatch 2'),
('Genshin Impact'),
('World of Warcraft'),
('Rocket League'),
('FIFA 24'),
('The Sims 4'),
('Among Us'),
('Dead by Daylight'),
('Hogwarts Legacy');
-- Sample Data for streams
INSERT INTO streams (user_id, title, start_time, num_viewers, category_id) VALUES
(1, 'Game on!', '2025-02-16 17:00:00', 5, 1),
(2, 'Live Music Jam', '2025-01-25 20:00:00', 350, 2),
(3, 'Sketching Live', '2025-01-24 15:00:00', 80, 3),
(4, 'Math Made Easy', '2025-01-23 10:00:00', 400, 4),
(5, 'Sports Highlights', '2025-02-15 23:00:00', 210, 5);
INSERT INTO streams (user_id, title, start_time, category_id) VALUES
(1, 'Game on!', '2025-02-16 17:00:00', 1),
(2, 'Live Music Jam', '2025-01-25 20:00:00', 2),
(3, 'Sketching Live', '2025-01-24 15:00:00', 3),
(4, 'Math Made Easy', '2025-01-23 10:00:00', 4),
(5, 'Sports Highlights', '2025-02-15 23:00:00', 5);
-- Sample Data for vods
INSERT INTO vods (user_id, title, datetime, category_id, length, views) VALUES
@@ -118,22 +142,3 @@ SELECT * FROM streams;
SELECT * FROM chat;
SELECT * FROM tags;
SELECT * FROM stream_tags;
-- To see all tables in the database
SELECT name FROM sqlite_master WHERE type='table';
-- UPDATE users SET is_live = 0 WHERE user_id = 1;
SELECT users.user_id, streams.title, streams.num_viewers, users.username
FROM streams JOIN users
ON streams.user_id = users.user_id
WHERE users.user_id IN
(SELECT followed_id FROM follows WHERE user_id = 1)
AND users.is_live = 1;
SELECT categories.category_id, categories.category_name, SUM(streams.num_viewers) AS total_viewers
FROM streams
JOIN categories ON streams.category_id = categories.category_id
GROUP BY categories.category_name
ORDER BY SUM(streams.num_viewers) DESC
LIMIT 10;

View File

@@ -72,11 +72,11 @@ def get_highest_view_categories(no_categories: int = 4, offset: int = 0) -> Opti
"""
with Database() as db:
categories = db.fetchall("""
SELECT categories.category_id, categories.category_name, SUM(streams.num_viewers) AS num_viewers
FROM streams
JOIN categories ON streams.category_id = categories.category_id
GROUP BY categories.category_name
ORDER BY SUM(streams.num_viewers) DESC
SELECT categories.category_id, categories.category_name, COALESCE(SUM(streams.num_viewers), 0) AS num_viewers
FROM categories
LEFT JOIN streams ON streams.category_id = categories.category_id
GROUP BY categories.category_id, categories.category_name
ORDER BY num_viewers DESC
LIMIT ? OFFSET ?;
""", (no_categories, offset))
return categories