Merge branch 'main' of https://github.com/john-david3/cs3305-team11
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" class="min-w-[650px]">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>Team Software Project</title>
|
<title>Team Software Project</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="min-h-screen h-full bg-gradient-to-tr from-[#2043ba] via-[#0026a6] to-[#63007a] overflow-x-hidden"></div>
|
<div id="root" class="min-h-screen h-full min-w-[650px] bg-gradient-to-tr from-[#2043ba] via-[#0026a6] to-[#63007a] overflow-x-hidden"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const LoginForm: React.FC<SubmitProps> = ({ onSubmit, onForgotPassword }) => {
|
|||||||
errors.password ? "border-red-500" : ""
|
errors.password ? "border-red-500" : ""
|
||||||
}`}
|
}`}
|
||||||
></Input>
|
></Input>
|
||||||
<div className="flex flex-row">
|
<div className="flex">
|
||||||
<label className="flex w-full items-center justify-start cursor-pointer w-10px">
|
<label className="flex w-full items-center justify-start cursor-pointer w-10px">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const ListItem: React.FC<ListItemProps> = ({
|
|||||||
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`}
|
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}
|
onClick={onItemClick}
|
||||||
>
|
>
|
||||||
<div className="relative w-full pt-[56.25%] overflow-hidden rounded-t-lg">
|
<div className="relative w-full aspect-video overflow-hidden rounded-t-lg">
|
||||||
{thumbnail ? (
|
{thumbnail ? (
|
||||||
<img
|
<img
|
||||||
src={thumbnail}
|
src={thumbnail}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const ListRow: React.FC<ListRowProps> = ({
|
|||||||
ref={slider}
|
ref={slider}
|
||||||
className={`flex ${
|
className={`flex ${
|
||||||
wrap ? "flex-wrap" : "overflow-x-scroll whitespace-nowrap"
|
wrap ? "flex-wrap" : "overflow-x-scroll whitespace-nowrap"
|
||||||
} items-center justify-between scroll-smooth scrollbar-hide gap-5 py-[10px] px=[30px] mx-[30px]`}
|
} max-w-[95%] items-center justify-between scroll-smooth scrollbar-hide gap-5 mx-auto`}
|
||||||
>
|
>
|
||||||
|
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -93,7 +93,7 @@ const ListRow: React.FC<ListRowProps> = ({
|
|||||||
? onClick?.(item.streamer)
|
? onClick?.(item.streamer)
|
||||||
: onClick?.(item.title)
|
: onClick?.(item.title)
|
||||||
}
|
}
|
||||||
extraClasses={`${itemExtraClasses} min-w-[25vw]`}
|
extraClasses={`${itemExtraClasses} min-w-[20vw]`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
117
frontend/src/components/Stream/VideoPlayer.tsx
Normal file
117
frontend/src/components/Stream/VideoPlayer.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import videojs from "video.js";
|
||||||
|
import type Player from "video.js/dist/types/player";
|
||||||
|
import "video.js/dist/video-js.css";
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
streamer?: string;
|
||||||
|
extraClasses?: string;
|
||||||
|
onStreamDetected?: (isStreamAvailable: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
|
streamer,
|
||||||
|
extraClasses,
|
||||||
|
onStreamDetected,
|
||||||
|
}) => {
|
||||||
|
const { streamerName: urlStreamerName } = useParams<{
|
||||||
|
streamerName: string;
|
||||||
|
}>();
|
||||||
|
const videoRef = useRef<HTMLDivElement>(null);
|
||||||
|
const playerRef = useRef<Player | null>(null);
|
||||||
|
|
||||||
|
// Use URL param if available, otherwise fall back to prop
|
||||||
|
const streamerName = urlStreamerName || streamer;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoRef.current || !streamerName) {
|
||||||
|
console.log("No video ref or streamer name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupPlayer = async () => {
|
||||||
|
const streamKey = await fetchStreamKey();
|
||||||
|
const streamUrl = `/stream/${streamKey}/index.m3u8`;
|
||||||
|
console.log("Player created with src:", streamUrl);
|
||||||
|
|
||||||
|
if (!playerRef.current) {
|
||||||
|
const videoElement = document.createElement("video");
|
||||||
|
videoElement.classList.add(
|
||||||
|
"video-js",
|
||||||
|
"vjs-big-play-centered",
|
||||||
|
"w-full",
|
||||||
|
"h-full"
|
||||||
|
);
|
||||||
|
videoElement.setAttribute("playsinline", "true");
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.appendChild(videoElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
playerRef.current = videojs(videoElement, {
|
||||||
|
controls: false,
|
||||||
|
autoplay: true,
|
||||||
|
muted: false,
|
||||||
|
fluid: true,
|
||||||
|
responsive: true,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
liveui: false,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
src: streamUrl,
|
||||||
|
type: "application/x-mpegURL",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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?.play();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
playerRef.current.src({
|
||||||
|
src: streamUrl,
|
||||||
|
type: "application/x-mpegURL",
|
||||||
|
});
|
||||||
|
playerRef.current.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStreamKey = async () => {
|
||||||
|
const response = await fetch(`/api/user/${streamerName}/stream_key`);
|
||||||
|
const keyData = await response.json();
|
||||||
|
return keyData.stream_key;
|
||||||
|
};
|
||||||
|
|
||||||
|
setupPlayer();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playerRef.current) {
|
||||||
|
playerRef.current.dispose();
|
||||||
|
playerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [streamerName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoPlayer;
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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<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`;
|
|
||||||
|
|
||||||
if (!playerRef.current) {
|
|
||||||
const videoElement = document.createElement("video");
|
|
||||||
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: false,
|
|
||||||
autoplay: true,
|
|
||||||
muted: false,
|
|
||||||
fluid: true,
|
|
||||||
responsive: true,
|
|
||||||
aspectRatio: "16:9",
|
|
||||||
liveui: false,
|
|
||||||
sources: [
|
|
||||||
{
|
|
||||||
src: streamUrl,
|
|
||||||
type: "application/x-mpegURL",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
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?.play();
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
playerRef.current.src({ src: streamUrl, type: "application/x-mpegURL" });
|
|
||||||
playerRef.current.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.dispose();
|
|
||||||
playerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [streamerName]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default VideoPlayer;
|
|
||||||
@@ -44,10 +44,9 @@ const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => {
|
|||||||
wrap={false}
|
wrap={false}
|
||||||
onClick={handleStreamClick}
|
onClick={handleStreamClick}
|
||||||
extraClasses="bg-[var(--liveNow)]"
|
extraClasses="bg-[var(--liveNow)]"
|
||||||
|
|
||||||
>
|
>
|
||||||
{/* <Button extraClasses="absolute right-10" onClick={() => navigate("/")}>
|
{/* <Button extraClasses="absolute right-10" onClick={() => navigate("/")}>
|
||||||
Show More . . .
|
Show More
|
||||||
</Button> */}
|
</Button> */}
|
||||||
</ListRow>
|
</ListRow>
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ const HomePage: React.FC<HomePageProps> = ({ variant = "default" }) => {
|
|||||||
extraClasses="absolute right-10"
|
extraClasses="absolute right-10"
|
||||||
onClick={() => navigate("/categories")}
|
onClick={() => navigate("/categories")}
|
||||||
>
|
>
|
||||||
Show More . . .
|
Show More
|
||||||
</Button>
|
</Button>
|
||||||
</ListRow>
|
</ListRow>
|
||||||
</DynamicPageContent>
|
</DynamicPageContent>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ListItem from "../components/Layout/ListItem";
|
|||||||
import { X as XIcon, Eye as ShowIcon, EyeOff as HideIcon } from "lucide-react";
|
import { X as XIcon, Eye as ShowIcon, EyeOff as HideIcon } from "lucide-react";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import VideoPlayer from "../components/Video/VideoPlayer";
|
import VideoPlayer from "../components/Stream/VideoPlayer";
|
||||||
|
|
||||||
interface StreamData {
|
interface StreamData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -66,71 +66,70 @@ const StreamDashboardPage: React.FC = () => {
|
|||||||
}, [categories, thumbnailPreview.isCustom]);
|
}, [categories, thumbnailPreview.isCustom]);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
checkStreamStatus();
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Just need the stream key if not streaming
|
||||||
|
const response = await fetch(`/api/user/${username}/stream_key`);
|
||||||
|
const keyData = await response.json();
|
||||||
|
setStreamData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
stream_key: keyData.stream_key,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking stream status:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setStreamData((prev) => ({ ...prev, [name]: value }));
|
setStreamData((prev) => ({ ...prev, [name]: value }));
|
||||||
@@ -201,11 +200,11 @@ const StreamDashboardPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartStream = async () => {
|
const handlePublishStream = async () => {
|
||||||
console.log("Starting stream with data:", streamData);
|
console.log("Starting stream with data:", streamData);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("key", streamData.stream_key);
|
formData.append("data", JSON.stringify(streamData));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/publish_stream", {
|
const response = await fetch("/api/publish_stream", {
|
||||||
@@ -228,6 +227,30 @@ const StreamDashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleUpdateStream = async () => {
|
const handleUpdateStream = async () => {
|
||||||
console.log("Updating stream with data:", streamData);
|
console.log("Updating stream with data:", streamData);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("key", streamData.stream_key);
|
||||||
|
formData.append("title", streamData.title);
|
||||||
|
formData.append("category_name", streamData.category_name);
|
||||||
|
if (thumbnail) {
|
||||||
|
formData.append("thumbnail", thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/update_stream", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log("Stream updated successfully");
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.error("Failed to update stream");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating stream:", error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndStream = async () => {
|
const handleEndStream = async () => {
|
||||||
@@ -392,7 +415,7 @@ const StreamDashboardPage: React.FC = () => {
|
|||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Button
|
<Button
|
||||||
onClick={
|
onClick={
|
||||||
isStreaming ? handleUpdateStream : handleStartStream
|
isStreaming ? handleUpdateStream : handlePublishStream
|
||||||
}
|
}
|
||||||
disabled={!isFormValid()}
|
disabled={!isFormValid()}
|
||||||
extraClasses="text-2xl px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
extraClasses="text-2xl px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ToggleButton } from "../components/Input/Button";
|
import { ToggleButton } from "../components/Input/Button";
|
||||||
import ChatPanel from "../components/Video/ChatPanel";
|
import ChatPanel from "../components/Stream/ChatPanel";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useAuthModal } from "../hooks/useAuthModal";
|
import { useAuthModal } from "../hooks/useAuthModal";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useFollow } from "../hooks/useFollow";
|
import { useFollow } from "../hooks/useFollow";
|
||||||
import VideoPlayer from "../components/Video/VideoPlayer";
|
import VideoPlayer from "../components/Stream/VideoPlayer";
|
||||||
import { SocketProvider } from "../context/SocketContext";
|
import { SocketProvider } from "../context/SocketContext";
|
||||||
import AuthModal from "../components/Auth/AuthModal";
|
import AuthModal from "../components/Auth/AuthModal";
|
||||||
import CheckoutForm, { Return } from "../components/Checkout/CheckoutForm";
|
import CheckoutForm, { Return } from "../components/Checkout/CheckoutForm";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
worker_processes 1;
|
worker_processes 1;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 1024;
|
worker_connections 1024;
|
||||||
@@ -15,8 +16,8 @@ rtmp {
|
|||||||
deny play all;
|
deny play all;
|
||||||
push rtmp://127.0.0.1:1935/hls-live;
|
push rtmp://127.0.0.1:1935/hls-live;
|
||||||
|
|
||||||
on_publish http://web_server:5000/publish_stream;
|
on_publish http://web_server:5000/init_stream; # if the stream is detected from OBS
|
||||||
on_publish_done http://web_server:5000/end_stream;
|
on_publish_done http://web_server:5000/end_stream; # if the stream is ended on OBS
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ rtmp {
|
|||||||
live on;
|
live on;
|
||||||
|
|
||||||
hls on;
|
hls on;
|
||||||
hls_path /stream_data/;
|
hls_path /stream_data/stream/;
|
||||||
|
|
||||||
allow publish 127.0.0.1;
|
allow publish 127.0.0.1;
|
||||||
deny publish all;
|
deny publish all;
|
||||||
@@ -78,7 +79,7 @@ http {
|
|||||||
|
|
||||||
# The MPEG-TS video chunks are stored in /tmp/hls
|
# The MPEG-TS video chunks are stored in /tmp/hls
|
||||||
location ~ ^/stream/(.+)/(.+\.ts)$ {
|
location ~ ^/stream/(.+)/(.+\.ts)$ {
|
||||||
alias /stream_data/$1/stream/$2;
|
alias /stream_data/stream/$1/$2;
|
||||||
|
|
||||||
# Let the MPEG-TS video chunks be cacheable
|
# Let the MPEG-TS video chunks be cacheable
|
||||||
expires max;
|
expires max;
|
||||||
@@ -86,35 +87,36 @@ http {
|
|||||||
|
|
||||||
# The M3U8 playlists location
|
# The M3U8 playlists location
|
||||||
location ~ ^/stream/(.+)/(.+\.m3u8)$ {
|
location ~ ^/stream/(.+)/(.+\.m3u8)$ {
|
||||||
alias /stream_data/$1/stream/$2;
|
alias /stream_data/stream/$1/$2;
|
||||||
|
|
||||||
# The M3U8 playlists should not be cacheable
|
# The M3U8 playlists should not be cacheable
|
||||||
expires -1d;
|
expires -1d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#! Unused right now so the following are inaccurate locations
|
||||||
# The thumbnails location
|
# The thumbnails location
|
||||||
location ~ ^/stream/(.+)/thumbnails/(.+\.jpg)$ {
|
# location ~ ^/stream/(.+)/thumbnails/(.+\.jpg)$ {
|
||||||
alias /stream_data/$1/thumbnails/$2;
|
# alias /stream_data/$1/thumbnails/$2;
|
||||||
|
|
||||||
# The thumbnails should not be cacheable
|
# # The thumbnails should not be cacheable
|
||||||
expires -1d;
|
# expires -1d;
|
||||||
}
|
# }
|
||||||
|
|
||||||
# The vods location
|
# # The vods location
|
||||||
location ~ ^/stream/(.+)/vods/(.+\.mp4)$ {
|
# location ~ ^/stream/(.+)/vods/(.+\.mp4)$ {
|
||||||
alias /stream_data/$1/vods/$2;
|
# alias /stream_data/$1/vods/$2;
|
||||||
|
|
||||||
# The vods should not be cacheable
|
# # The vods should not be cacheable
|
||||||
expires -1d;
|
# expires -1d;
|
||||||
}
|
# }
|
||||||
|
|
||||||
location ~ ^/\?token=.*$ {
|
# location ~ ^/\?token=.*$ {
|
||||||
proxy_pass http://frontend:5173;
|
# proxy_pass http://frontend:5173;
|
||||||
proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
# proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
}
|
# }
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend:5173;
|
proxy_pass http://frontend:5173;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ def stream_data(streamer_id) -> dict:
|
|||||||
Returns a streamer's current stream data
|
Returns a streamer's current stream data
|
||||||
"""
|
"""
|
||||||
data = get_current_stream_data(streamer_id)
|
data = get_current_stream_data(streamer_id)
|
||||||
|
|
||||||
if session.get('user_id') == streamer_id:
|
if session.get('user_id') == streamer_id:
|
||||||
with Database() as db:
|
with Database() as db:
|
||||||
stream_key = db.fetchone(
|
stream_key = db.fetchone(
|
||||||
@@ -163,10 +163,39 @@ def vods(username):
|
|||||||
|
|
||||||
|
|
||||||
# RTMP Server Routes
|
# RTMP Server Routes
|
||||||
|
|
||||||
|
@stream_bp.route("/init_stream", methods=["POST"])
|
||||||
|
def init_stream():
|
||||||
|
"""
|
||||||
|
Called by NGINX when OBS starts streaming.
|
||||||
|
Creates necessary directories and validates stream key.
|
||||||
|
"""
|
||||||
|
stream_key = request.form.get("name")
|
||||||
|
|
||||||
|
print(f"Stream initialization requested in nginx with key: {stream_key}")
|
||||||
|
|
||||||
|
with Database() as db:
|
||||||
|
# Check if valid stream key and user is allowed to stream
|
||||||
|
user_info = db.fetchone("""SELECT user_id, username, is_live
|
||||||
|
FROM users
|
||||||
|
WHERE stream_key = ?""", (stream_key,))
|
||||||
|
|
||||||
|
if not user_info:
|
||||||
|
print("Unauthorized - Invalid stream key", flush=True)
|
||||||
|
return "Unauthorized - Invalid stream key", 403
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
username = user_info["username"]
|
||||||
|
create_local_directories(username)
|
||||||
|
|
||||||
|
return "OK", 200
|
||||||
|
|
||||||
|
|
||||||
@stream_bp.route("/publish_stream", methods=["POST"])
|
@stream_bp.route("/publish_stream", methods=["POST"])
|
||||||
def publish_stream():
|
def publish_stream():
|
||||||
"""
|
"""
|
||||||
Authenticates stream from streamer and publishes it to the site
|
Called when user clicks Start Stream in dashboard.
|
||||||
|
Sets up stream in database and starts thumbnail generation.
|
||||||
|
|
||||||
step-by-step:
|
step-by-step:
|
||||||
fetch user info from stream key
|
fetch user info from stream key
|
||||||
@@ -174,27 +203,26 @@ def publish_stream():
|
|||||||
set user as streaming
|
set user as streaming
|
||||||
periodically update thumbnail
|
periodically update thumbnail
|
||||||
"""
|
"""
|
||||||
stream_key = request.form.get("key")
|
data = request.form.get("data")
|
||||||
print("Stream request received")
|
|
||||||
|
|
||||||
# Open database connection
|
|
||||||
with Database() as db:
|
with Database() as db:
|
||||||
# Get user info from stream key
|
user_info = db.fetchone("""SELECT user_id, username, current_stream_title,
|
||||||
user_info = db.fetchone("""SELECT user_id, username, current_stream_title, current_selected_category_id, is_live
|
current_selected_category_id, is_live
|
||||||
FROM users
|
FROM users
|
||||||
WHERE stream_key = ?""", (stream_key,))
|
WHERE stream_key = ?""", (data['stream_key'],))
|
||||||
|
|
||||||
# If stream key is invalid, return unauthorized
|
|
||||||
if not user_info or user_info["is_live"]:
|
if not user_info or user_info["is_live"]:
|
||||||
|
print(
|
||||||
|
"Unauthorized. No user found from Stream key or user is already streaming.", flush=True)
|
||||||
return "Unauthorized", 403
|
return "Unauthorized", 403
|
||||||
|
|
||||||
# Insert stream into database
|
# Insert stream into database
|
||||||
db.execute("""INSERT INTO streams (user_id, title, start_time, num_viewers, category_id)
|
db.execute("""INSERT INTO streams (user_id, title, start_time, num_viewers, category_id)
|
||||||
VALUES (?, ?, ?, ?, ?)""", (user_info["user_id"],
|
VALUES (?, ?, ?, ?, ?)""", (user_info["user_id"],
|
||||||
user_info["current_stream_title"],
|
data["title"],
|
||||||
datetime.now(),
|
datetime.now(),
|
||||||
0,
|
0,
|
||||||
1))
|
get_category_id(data['category_name'])))
|
||||||
|
|
||||||
# Set user as streaming
|
# Set user as streaming
|
||||||
db.execute("""UPDATE users SET is_live = 1 WHERE user_id = ?""",
|
db.execute("""UPDATE users SET is_live = 1 WHERE user_id = ?""",
|
||||||
@@ -203,16 +231,41 @@ def publish_stream():
|
|||||||
username = user_info["username"]
|
username = user_info["username"]
|
||||||
user_id = user_info["user_id"]
|
user_id = user_info["user_id"]
|
||||||
|
|
||||||
# Local file creation
|
|
||||||
create_local_directories(username)
|
|
||||||
|
|
||||||
# Update thumbnail periodically
|
# Update thumbnail periodically
|
||||||
update_thumbnail.delay(user_id,
|
update_thumbnail.delay(user_id,
|
||||||
path_manager.get_stream_file_path(username),
|
path_manager.get_stream_file_path(username),
|
||||||
path_manager.get_thumbnail_file_path(username),
|
path_manager.get_thumbnail_file_path(username),
|
||||||
THUMBNAIL_GENERATION_INTERVAL)
|
THUMBNAIL_GENERATION_INTERVAL)
|
||||||
|
|
||||||
return redirect(f"/{user_info['username']}/stream/")
|
return "OK", 200
|
||||||
|
|
||||||
|
|
||||||
|
@stream_bp.route("/update_stream", methods=["POST"])
|
||||||
|
def update_stream():
|
||||||
|
"""
|
||||||
|
Called by StreamDashboard to update stream info
|
||||||
|
"""
|
||||||
|
# TODO: Add thumbnails (paths) to table, allow user to update thumbnail
|
||||||
|
|
||||||
|
stream_key = request.form.get("key")
|
||||||
|
title = request.form.get("title")
|
||||||
|
category_name = request.form.get("category_name")
|
||||||
|
|
||||||
|
with Database() as db:
|
||||||
|
user_info = db.fetchone("""SELECT user_id, username, is_live
|
||||||
|
FROM users
|
||||||
|
WHERE stream_key = ?""", (stream_key,))
|
||||||
|
|
||||||
|
if not user_info or not user_info["is_live"]:
|
||||||
|
print(
|
||||||
|
"Unauthorized - No user found from stream key or user is not streaming", flush=True)
|
||||||
|
return "Unauthorized", 403
|
||||||
|
|
||||||
|
db.execute("""UPDATE streams
|
||||||
|
SET title = ?, category_id = ?
|
||||||
|
WHERE user_id = ?""", (title, get_category_id(category_name), user_info["user_id"]))
|
||||||
|
|
||||||
|
return "Stream updated", 200
|
||||||
|
|
||||||
|
|
||||||
@stream_bp.route("/end_stream", methods=["POST"])
|
@stream_bp.route("/end_stream", methods=["POST"])
|
||||||
@@ -230,6 +283,12 @@ def end_stream():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
stream_key = request.form.get("key")
|
stream_key = request.form.get("key")
|
||||||
|
if stream_key is None:
|
||||||
|
stream_key = request.form.get("name")
|
||||||
|
|
||||||
|
if stream_key is None:
|
||||||
|
print("Unauthorized - No stream key provided", flush=True)
|
||||||
|
return "Unauthorized", 403
|
||||||
|
|
||||||
# Open database connection
|
# Open database connection
|
||||||
with Database() as db:
|
with Database() as db:
|
||||||
@@ -244,6 +303,7 @@ def end_stream():
|
|||||||
|
|
||||||
# If stream key is invalid, return unauthorized
|
# If stream key is invalid, return unauthorized
|
||||||
if not user_info:
|
if not user_info:
|
||||||
|
print("Unauthorized - No user found from stream key", flush=True)
|
||||||
return "Unauthorized", 403
|
return "Unauthorized", 403
|
||||||
|
|
||||||
# Remove stream from database
|
# Remove stream from database
|
||||||
@@ -256,9 +316,9 @@ def end_stream():
|
|||||||
|
|
||||||
db.execute("""INSERT INTO vods (user_id, title, datetime, category_id, length, views)
|
db.execute("""INSERT INTO vods (user_id, title, datetime, category_id, length, views)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""", (user_info["user_id"],
|
VALUES (?, ?, ?, ?, ?, ?)""", (user_info["user_id"],
|
||||||
user_info["current_stream_title"],
|
stream_info["title"],
|
||||||
stream_info["start_time"],
|
stream_info["start_time"],
|
||||||
user_info["current_selected_category_id"],
|
stream_info["category_id"],
|
||||||
stream_length,
|
stream_length,
|
||||||
0))
|
0))
|
||||||
|
|
||||||
|
|||||||
@@ -142,41 +142,14 @@ def get_vod_tags(vod_id: int):
|
|||||||
""", (vod_id,))
|
""", (vod_id,))
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
def transfer_stream_to_vod(user_id: int):
|
|
||||||
"""
|
|
||||||
Deletes stream from stream table and moves it to VoD table
|
|
||||||
TODO: Add functionaliy to save stream permanently
|
|
||||||
"""
|
|
||||||
|
|
||||||
with Database() as db:
|
|
||||||
stream = db.fetchone("""
|
|
||||||
SELECT * FROM streams WHERE user_id = ?;
|
|
||||||
""", (user_id,))
|
|
||||||
|
|
||||||
if not stream:
|
|
||||||
return False
|
|
||||||
|
|
||||||
## TODO: calculate length in seconds, currently using temp value
|
|
||||||
|
|
||||||
db.execute("""
|
|
||||||
INSERT INTO vods (user_id, title, datetime, category_id, length, views)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?);
|
|
||||||
""", (stream["user_id"], stream["title"], stream["datetime"], stream["category_id"], 10, stream["num_viewers"]))
|
|
||||||
|
|
||||||
db.execute("""
|
|
||||||
DELETE FROM streams WHERE user_id = ?;
|
|
||||||
""", (user_id,))
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def create_local_directories(username: str):
|
def create_local_directories(username: str):
|
||||||
"""
|
"""
|
||||||
Create directories for user stream data if they do not exist
|
Create directories for user stream data if they do not exist
|
||||||
"""
|
"""
|
||||||
|
|
||||||
vods_path = f"stream_data/{username}/vods"
|
vods_path = f"stream_data/vods/{username}"
|
||||||
stream_path = f"stream_data/{username}/stream"
|
stream_path = f"stream_data/stream"
|
||||||
thumbnail_path = f"stream_data/{username}/thumbnails"
|
thumbnail_path = f"stream_data/thumbnails/{username}"
|
||||||
|
|
||||||
if not os.path.exists(vods_path):
|
if not os.path.exists(vods_path):
|
||||||
os.makedirs(vods_path)
|
os.makedirs(vods_path)
|
||||||
@@ -188,7 +161,6 @@ def create_local_directories(username: str):
|
|||||||
os.makedirs(thumbnail_path)
|
os.makedirs(thumbnail_path)
|
||||||
|
|
||||||
# Fix permissions
|
# Fix permissions
|
||||||
os.chmod(f"stream_data/{username}", 0o777)
|
|
||||||
os.chmod(vods_path, 0o777)
|
os.chmod(vods_path, 0o777)
|
||||||
os.chmod(stream_path, 0o777)
|
os.chmod(stream_path, 0o777)
|
||||||
os.chmod(thumbnail_path, 0o777)
|
os.chmod(thumbnail_path, 0o777)
|
||||||
|
|||||||
Reference in New Issue
Block a user