From 7484c927f14b9b053357a647f8df9a5acd2107e6 Mon Sep 17 00:00:00 2001 From: Chris-1010 <122332721@umail.ucc.ie> Date: Wed, 19 Feb 2025 23:25:34 +0000 Subject: [PATCH] FEAT: Add `StreamDashboardPage`; UPDATE: Add route to `StreamDashboardPage`; UPDATE: `VideoPlayer` for improved stream handling; UPDATE: `Navbar` to include navigation to `StreamDashboardPage`; UPDATE: Expand `stream_data` method to include `stream_key` in `streams.py`; REFACTOR: Format `streams.py`; --- frontend/src/App.tsx | 2 + frontend/src/components/Navigation/Navbar.tsx | 34 +- frontend/src/components/Video/VideoPlayer.tsx | 49 +- frontend/src/pages/StreamDashboardPage.tsx | 444 ++++++++++++++++++ web_server/blueprints/streams.py | 100 ++-- 5 files changed, 571 insertions(+), 58 deletions(-) create mode 100644 frontend/src/pages/StreamDashboardPage.tsx 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() { ) } /> + : } /> } /> } /> = ({ 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/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/StreamDashboardPage.tsx b/frontend/src/pages/StreamDashboardPage.tsx new file mode 100644 index 0000000..c751695 --- /dev/null +++ b/frontend/src/pages/StreamDashboardPage.tsx @@ -0,0 +1,444 @@ +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-[100%]" + 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})`} +

+
+ )} +
+ + + +
+ +
+ + {!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/web_server/blueprints/streams.py b/web_server/blueprints/streams.py index defffd5..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,16 +65,27 @@ 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=4, offset=0) -> list[dict]: @@ -87,7 +102,8 @@ def popular_categories(no_categories=4, 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]: """ @@ -98,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(): @@ -109,7 +126,7 @@ def following_categories_streams(): return jsonify(streams) -## User Routes +# User Routes @stream_bp.route('/user//status') def user_live_status(username): """ @@ -134,7 +151,7 @@ def user_live_status(username): }) -## VOD Routes +# VOD Routes @stream_bp.route('/vods/') def vods(username): """ @@ -145,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(): """ @@ -157,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 @@ -170,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(): """ @@ -209,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: @@ -218,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