diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb5eac6..44c5c25 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index faa5e74..1b5cbca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/images/category_thumbnails/fortnite.webp b/frontend/public/images/category_thumbnails/fortnite.webp new file mode 100644 index 0000000..9fdc786 Binary files /dev/null and b/frontend/public/images/category_thumbnails/fortnite.webp differ diff --git a/frontend/public/images/category_thumbnails/league_of_legends.webp b/frontend/public/images/category_thumbnails/league_of_legends.webp new file mode 100644 index 0000000..9db56a6 Binary files /dev/null and b/frontend/public/images/category_thumbnails/league_of_legends.webp differ diff --git a/frontend/public/images/sample_game_video.mp4 b/frontend/public/images/sample_game_video.mp4 deleted file mode 100644 index 5869a6d..0000000 Binary files a/frontend/public/images/sample_game_video.mp4 and /dev/null differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c485ba2..4c1b16c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { ) } /> + : } /> } /> } /> { type?: "button" | "submit" | "reset"; extraClasses?: string; children?: React.ReactNode; - onClick?: () => void; } const Button: React.FC = ({ type = "button", children = "Submit", extraClasses = "", - onClick, + ...props }) => { return ( diff --git a/frontend/src/components/Input/Input.tsx b/frontend/src/components/Input/Input.tsx index 36cf9b2..cc6d9c7 100644 --- a/frontend/src/components/Input/Input.tsx +++ b/frontend/src/components/Input/Input.tsx @@ -27,7 +27,7 @@ const Input: React.FC = ({ 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`} /> diff --git a/frontend/src/components/Input/SearchBar.tsx b/frontend/src/components/Input/SearchBar.tsx index e88df5f..31f268e 100644 --- a/frontend/src/components/Input/SearchBar.tsx +++ b/frontend/src/components/Input/SearchBar.tsx @@ -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; diff --git a/frontend/src/components/Layout/ListItem.tsx b/frontend/src/components/Layout/ListItem.tsx index 0c15060..602e6bd 100644 --- a/frontend/src/components/Layout/ListItem.tsx +++ b/frontend/src/components/Layout/ListItem.tsx @@ -9,6 +9,7 @@ export interface ListItemProps { viewers: number; thumbnail?: string; onItemClick?: () => void; + extraClasses?: string; } const ListItem: React.FC = ({ @@ -19,12 +20,12 @@ const ListItem: React.FC = ({ viewers, thumbnail, onItemClick, + extraClasses = "", }) => { return (
diff --git a/frontend/src/components/Layout/ListRow.tsx b/frontend/src/components/Layout/ListRow.tsx index 0db3516..cbece49 100644 --- a/frontend/src/components/Layout/ListRow.tsx +++ b/frontend/src/components/Layout/ListRow.tsx @@ -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 = ({ wrap, onClick, extraClasses = "", + itemExtraClasses = "", children, }) => { const slider = useRef(null); @@ -52,7 +54,7 @@ const ListRow: React.FC = ({
- {!wrap && items.length > 3 && ( + {!wrap && items.length > 4 && ( <> = ({ ? onClick?.(item.streamer) : onClick?.(item.title) } + extraClasses={`${itemExtraClasses} min-w-[25vw]`} /> ))}
diff --git a/frontend/src/components/Navigation/Navbar.tsx b/frontend/src/components/Navigation/Navbar.tsx index 4555dfc..5ce42ca 100644 --- a/frontend/src/components/Navigation/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar.tsx @@ -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 = ({ 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 = ({ variant = "default" }) => { }`} > + + {/* Login / Logout Button */} + )} + {showAuthModal && setShowAuthModal(false)} />}
); diff --git a/frontend/src/components/Video/ChatPanel.tsx b/frontend/src/components/Video/ChatPanel.tsx index 9bc0314..f3caaf6 100644 --- a/frontend/src/components/Video/ChatPanel.tsx +++ b/frontend/src/components/Video/ChatPanel.tsx @@ -177,7 +177,7 @@ const ChatPanel: React.FC = ({
{/* Message content */} -
+
{msg.message}
diff --git a/frontend/src/components/Video/VideoPlayer.tsx b/frontend/src/components/Video/VideoPlayer.tsx index 1c6efae..de30a27 100644 --- a/frontend/src/components/Video/VideoPlayer.tsx +++ b/frontend/src/components/Video/VideoPlayer.tsx @@ -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 = ({ + streamer, + extraClasses, + onStreamDetected, +}) => { + const { streamerName: urlStreamerName } = useParams<{ + streamerName: string; + }>(); const videoRef = useRef(null); const playerRef = useRef(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 ( -
+
); diff --git a/frontend/src/pages/AllCategoriesPage.tsx b/frontend/src/pages/AllCategoriesPage.tsx index 1d3856b..45f2a42 100644 --- a/frontend/src/pages/AllCategoriesPage.tsx +++ b/frontend/src/pages/AllCategoriesPage.tsx @@ -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 (
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 20a91a8..156433a 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -38,7 +38,7 @@ const HomePage: React.FC = ({ 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 = ({ 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} diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx index acdd3fa..8a9599f 100644 --- a/frontend/src/pages/NotFoundPage.tsx +++ b/frontend/src/pages/NotFoundPage.tsx @@ -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 = () => {

404

Page Not Found

+ diff --git a/frontend/src/pages/StreamDashboardPage.tsx b/frontend/src/pages/StreamDashboardPage.tsx new file mode 100644 index 0000000..0aa4baf --- /dev/null +++ b/frontend/src/pages/StreamDashboardPage.tsx @@ -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({ + title: "", + category_name: "", + viewer_count: 0, + start_time: "", + stream_key: "", + }); + const [streamDetected, setStreamDetected] = useState(false); + const [timeStarted, setTimeStarted] = useState(""); + const [categories, setCategories] = useState([]); + const [isCategoryFocused, setIsCategoryFocused] = useState(false); + const [filteredCategories, setFilteredCategories] = useState([]); + const [thumbnail, setThumbnail] = useState(null); + const [thumbnailPreview, setThumbnailPreview] = useState<{ + url: string; + isCustom: boolean; + }>({ url: "", isCustom: false }); + const [debouncedCheck, setDebouncedCheck] = useState(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) => { + 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) => { + 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 ( + +
+

+ {isStreaming ? "Stream Dashboard" : "Start Streaming"} +

+ +
+ {/* Left side - Stream Settings */} +
+

+ Stream Settings +

+
+
+ + +
+ +
+ + 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 && ( +
+ {filteredCategories.map((category) => ( +
+ handleCategorySelect(category.category_name) + } + > + {category.category_name} +
+ ))} +
+ )} +
+ +
+ +
+ + + + {thumbnail ? thumbnail.name : "No file selected"} + + {thumbnailPreview.isCustom && ( + + )} +
+ {!thumbnailPreview.isCustom && ( +

+ No thumbnail selected - the default category image will be + used +

+ )} +
+ + {isStreaming && ( +
+

Stream Info

+

+ Viewers: {streamData.viewer_count} +

+

+ Started:{" "} + {new Date(streamData.start_time!).toLocaleTimeString()} + {` (${timeStarted})`} +

+
+ )} +
+ + + +
+ +
+
+ + {isStreaming && ( + + )} +
+ {!streamDetected && ( +

+ No stream input detected. Please start streaming using your + broadcast software. +

+ )} +
+
+
+ + {/* Right side - Preview */} +
+

+ Stream Preview +

+
+
+

Video

+ +
+
+

List Item

+ {}} + extraClasses="max-w-[20vw]" + /> +
+
+
+
+
+
+ ); +}; + +export default StreamDashboardPage; diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index f9a6d77..10fef04 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -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"; diff --git a/frontend/src/pages/VideoPage.tsx b/frontend/src/pages/VideoPage.tsx index 0172c8d..ba20c86 100644 --- a/frontend/src/pages/VideoPage.tsx +++ b/frontend/src/pages/VideoPage.tsx @@ -37,6 +37,7 @@ const VideoPage: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ streamerId }) => {
Started - {streamData - ? `${Math.floor( - (Date.now() - new Date(streamData.startTime).getTime()) / - 3600000 - )} hours ago` - : "Loading..."} + {streamData ? timeStarted : "Loading..."}
diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index b34b42b..0000000 --- a/package-lock.json +++ /dev/null @@ -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 - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index ac42183..0000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "tailwind-scrollbar-hide": "^2.0.0" - } -} diff --git a/web_server/blueprints/streams.py b/web_server/blueprints/streams.py index a0a13c1..9e96c25 100644 --- a/web_server/blueprints/streams.py +++ b/web_server/blueprints/streams.py @@ -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/') 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/') @stream_bp.route('/streams/popular///') def popular_streams_by_category(category_name, no_streams=4, offset=0) -> list[dict]: @@ -47,7 +50,8 @@ 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 + +@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//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/') @stream_bp.route('/categories/popular//') -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,7 +102,8 @@ def popular_categories(no_categories, offset=0) -> list[dict]: category_data = get_highest_view_categories(no_categories, offset) return jsonify(category_data) -@login_required + +@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//status') def user_live_status(username): """ @@ -133,7 +151,7 @@ def user_live_status(username): }) -## VOD Routes +# VOD Routes @stream_bp.route('/vods/') 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 @@ -169,32 +187,34 @@ def publish_stream(): # If stream key is invalid, return unauthorized if not user_info or user_info["is_live"]: return "Unauthorized", 403 - + # Insert stream into database db.execute("""INSERT INTO streams (user_id, title, start_time, num_viewers, category_id) - VALUES (?, ?, ?, ?, ?)""", (user_info["user_id"], - user_info["current_stream_title"], - datetime.now(), - 0, - 1)) - + VALUES (?, ?, ?, ?, ?)""", (user_info["user_id"], + user_info["current_stream_title"], + datetime.now(), + 0, + 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"] - + # Local file creation create_local_directories(username) # Update thumbnail periodically update_thumbnail.delay(user_id, - path_manager.get_stream_file_path(username), - path_manager.get_thumbnail_file_path(username), + path_manager.get_stream_file_path(username), + path_manager.get_thumbnail_file_path(username), THUMBNAIL_GENERATION_INTERVAL) return redirect(f"/{user_info['username']}/stream/") + @stream_bp.route("/end_stream", methods=["POST"]) def end_stream(): """ @@ -208,8 +228,8 @@ def end_stream(): clean up old ts files end thumbnail generation """ - - stream_key = request.form.get("name") + + stream_key = request.form.get("key") # Open database connection with Database() as db: @@ -217,41 +237,42 @@ def end_stream(): user_info = db.fetchone("""SELECT * FROM users WHERE stream_key = ?""", (stream_key,)) - + stream_info = db.fetchone("""SELECT * FROM streams WHERE user_id = ?""", (user_info["user_id"],)) - # If stream key is invalid, return unauthorized if not user_info: return "Unauthorized", 403 - + # Remove stream from database db.execute("""DELETE FROM streams 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"], - user_info["current_stream_title"], - stream_info["start_time"], - user_info["current_selected_category_id"], - stream_length, - 0)) - + VALUES (?, ?, ?, ?, ?, ?)""", (user_info["user_id"], + user_info["current_stream_title"], + stream_info["start_time"], + user_info["current_selected_category_id"], + stream_length, + 0)) + vod_id = db.get_last_insert_id() - + # Set user as not streaming db.execute("""UPDATE users SET is_live = 0 WHERE user_id = ?""", (user_info["user_id"],)) - + # Get username username = user_info["username"] - - combine_ts_stream.delay(path_manager.get_stream_path(username), path_manager.get_vods_path(username), vod_id) - return "Stream ended", 200 \ No newline at end of file + combine_ts_stream.delay(path_manager.get_stream_path( + username), path_manager.get_vods_path(username), vod_id) + + return "Stream ended", 200 diff --git a/web_server/blueprints/user.py b/web_server/blueprints/user.py index 9096838..146b735 100644 --- a/web_server/blueprints/user.py +++ b/web_server/blueprints/user.py @@ -21,6 +21,16 @@ def user_data(username: str): data = get_user(user_id) return jsonify(data) +@user_bp.route('/user//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/') diff --git a/web_server/database/app.db b/web_server/database/app.db index 2dca23a..3f252dd 100644 Binary files a/web_server/database/app.db and b/web_server/database/app.db differ diff --git a/web_server/database/testing_data.sql b/web_server/database/testing_data.sql index ed83017..93abead 100644 --- a/web_server/database/testing_data.sql +++ b/web_server/database/testing_data.sql @@ -45,20 +45,44 @@ INSERT INTO followed_categories (user_id, category_id) VALUES (5, 5); -- Sample Data for categories -INSERT INTO categories (category_name) VALUES -('Gaming'), -('Music'), -('Art'), -('Education'), -('Sports'); +INSERT INTO categories (category_name) VALUES +('Gaming'), +('Music'), +('Art'), +('Education'), +('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 @@ -117,23 +141,4 @@ SELECT * FROM categories; 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; +SELECT * FROM stream_tags; \ No newline at end of file diff --git a/web_server/utils/recommendation_utils.py b/web_server/utils/recommendation_utils.py index 347c6d6..63bf416 100644 --- a/web_server/utils/recommendation_utils.py +++ b/web_server/utils/recommendation_utils.py @@ -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