feat: stream notifications
This commit is contained in:
@@ -8,6 +8,8 @@ import { Channel } from './types';
|
||||
import socketService from './services/SocketService';
|
||||
import apiService from './services/ApiService';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import { ToastProvider } from './components/notifications/ToastContext';
|
||||
import ToastContainer from './components/notifications/ToastContainer';
|
||||
|
||||
function App() {
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
@@ -21,7 +23,6 @@ function App() {
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
apiService
|
||||
@@ -63,78 +64,82 @@ function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||
<div className="container mx-auto py-4">
|
||||
<header className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Radio className="w-8 h-8 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold">StreamHub</h1>
|
||||
</div>
|
||||
<div className="relative max-w-md w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search channels..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Users className="w-6 h-6 text-blue-500" />
|
||||
<button onClick={() => setIsSettingsOpen(true)} className="p-2 hover:bg-gray-800 rounded-lg transition-colors">
|
||||
<Settings className="w-6 h-6 text-blue-500" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen bg-gray-900 text-gray-100">
|
||||
<div className="container mx-auto py-4">
|
||||
<header className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Radio className="w-8 h-8 text-blue-500" />
|
||||
<h1 className="text-2xl font-bold">StreamHub</h1>
|
||||
</div>
|
||||
<div className="relative max-w-md w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search channels..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-gray-800 rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Users className="w-6 h-6 text-blue-500" />
|
||||
<button onClick={() => setIsSettingsOpen(true)} className="p-2 hover:bg-gray-800 rounded-lg transition-colors">
|
||||
<Settings className="w-6 h-6 text-blue-500" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-8 space-y-4">
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tv2 className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold">Live Channels</h2>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 lg:col-span-8 space-y-4">
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tv2 className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold">Live Channels</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<ChannelList
|
||||
channels={filteredChannels}
|
||||
selectedChannel={selectedChannel}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelList
|
||||
channels={filteredChannels}
|
||||
selectedChannel={selectedChannel}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
<VideoPlayer channel={selectedChannel} syncEnabled={syncEnabled}/>
|
||||
</div>
|
||||
|
||||
<VideoPlayer channel={selectedChannel} syncEnabled={syncEnabled}/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Chat />
|
||||
<div className="col-span-12 lg:col-span-4">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddChannelModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
syncEnabled={syncEnabled}
|
||||
onSyncChange={(enabled) => {
|
||||
setSyncEnabled(enabled);
|
||||
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
|
||||
}}
|
||||
/>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
|
||||
<AddChannelModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
syncEnabled={syncEnabled}
|
||||
onSyncChange={(enabled) => {
|
||||
setSyncEnabled(enabled);
|
||||
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { Channel } from '../types';
|
||||
import { ToastContext } from './notifications/ToastContext';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
channel: Channel | null;
|
||||
@@ -10,6 +11,7 @@ interface VideoPlayerProps {
|
||||
function VideoPlayer({ channel, syncEnabled }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const { addToast, removeToast } = useContext(ToastContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !channel?.url) return;
|
||||
@@ -20,6 +22,16 @@ function VideoPlayer({ channel, syncEnabled }: VideoPlayerProps) {
|
||||
hlsRef.current.destroy();
|
||||
}
|
||||
|
||||
let toastStartId = null;
|
||||
if (channel.restream) {
|
||||
toastStartId = addToast({
|
||||
type: 'loading',
|
||||
title: 'Starting Restream',
|
||||
message: 'This may take a few moments...',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
autoStartLoad: syncEnabled ? false : true,
|
||||
liveDurationInfinity: true,
|
||||
@@ -33,7 +45,7 @@ function VideoPlayer({ channel, syncEnabled }: VideoPlayerProps) {
|
||||
maxRetryDelayMs: 0,
|
||||
},
|
||||
errorRetry: {
|
||||
maxNumRetry: 20,
|
||||
maxNumRetry: 12,
|
||||
retryDelayMs: 1000,
|
||||
maxRetryDelayMs: 8000,
|
||||
backoff: 'linear',
|
||||
@@ -86,6 +98,9 @@ function VideoPlayer({ channel, syncEnabled }: VideoPlayerProps) {
|
||||
hls.startLoad();
|
||||
video.play();
|
||||
console.log("Starting stream");
|
||||
if (toastStartId) {
|
||||
removeToast(toastStartId);
|
||||
}
|
||||
} else {
|
||||
console.log("Waiting for stream to load: ", videoLength + timeDiff + timeTolerance, " < ", targetDelay);
|
||||
|
||||
@@ -130,6 +145,27 @@ function VideoPlayer({ channel, syncEnabled }: VideoPlayerProps) {
|
||||
video.playbackRate = 1.0;
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
|
||||
if (toastStartId) {
|
||||
removeToast(toastStartId);
|
||||
}
|
||||
|
||||
const is403 = data.response?.code === 403;
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Stream Error',
|
||||
message: is403 && !channel.restream
|
||||
? 'Access denied. Try with restream option for this channel.'
|
||||
: 'The stream is not working. Check the source.',
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
81
frontend/src/components/notifications/ToastContainer.tsx
Normal file
81
frontend/src/components/notifications/ToastContainer.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { AlertCircle, CheckCircle, Info, Loader, X } from 'lucide-react';
|
||||
import { useContext } from 'react';
|
||||
import { ToastContext } from './ToastContext';
|
||||
|
||||
function ToastContainer() {
|
||||
const { toasts, removeToast } = useContext(ToastContext);
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-4 min-w-[320px] max-w-[420px]">
|
||||
{toasts.map((toast) => {
|
||||
const icons = {
|
||||
info: <Info className="w-5 h-5 text-blue-400" />,
|
||||
success: <CheckCircle className="w-5 h-5 text-green-400" />,
|
||||
error: <AlertCircle className="w-5 h-5 text-red-400" />,
|
||||
loading: <Loader className="w-5 h-5 text-blue-400 animate-spin" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="bg-gray-800 rounded-lg shadow-lg overflow-hidden transform transition-all ease-in-out opacity-100"
|
||||
>
|
||||
{/* Toast Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between space-x-3">
|
||||
<div className="flex-shrink-0">{icons[toast.type]}</div>
|
||||
<div className="flex-1 pt-0.5">
|
||||
<h3 className="font-medium text-gray-100">{toast.title}</h3>
|
||||
{toast.message && (
|
||||
<p className="mt-1 text-sm text-gray-300">{toast.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
className="text-gray-400 hover:text-gray-100 focus:outline-none"
|
||||
onClick={() => removeToast(toast.id)}
|
||||
aria-label="Close toast"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{toast.type !== 'loading' && (
|
||||
<div className="h-1 bg-gray-700 relative">
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
toast.type === 'error'
|
||||
? 'rgb(239 68 68)' // Tailwind's `bg-red-500`
|
||||
: toast.type === 'success'
|
||||
? 'rgb(34 197 94)' // Tailwind's `bg-green-500`
|
||||
: 'rgb(59 130 246)', // Tailwind's `bg-blue-500`
|
||||
width: '100%',
|
||||
animation: `shrink ${toast.duration}ms linear`,
|
||||
}}
|
||||
onAnimationEnd={() => removeToast(toast.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Add the keyframes for the shrink animation */}
|
||||
<style>{`
|
||||
@keyframes shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToastContainer;
|
||||
46
frontend/src/components/notifications/ToastContext.tsx
Normal file
46
frontend/src/components/notifications/ToastContext.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { createContext, useCallback, useState } from 'react';
|
||||
import { ToastNotification } from '../../types';
|
||||
|
||||
interface ToastContextType {
|
||||
addToast: (toast: Omit<ToastNotification, 'id'>) => string;
|
||||
removeToast: (id: string) => void;
|
||||
toasts: ToastNotification[];
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastContextType>({
|
||||
addToast: () => '',
|
||||
removeToast: () => {},
|
||||
toasts: [],
|
||||
});
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback(
|
||||
({ type, title, message, duration = 5000 }: Omit<ToastNotification, 'id'>) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const newToast: ToastNotification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
duration,
|
||||
};
|
||||
|
||||
setToasts((prevToasts) => [...prevToasts, newToast]);
|
||||
|
||||
return id;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast, removeToast, toasts }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -3,5 +3,5 @@ import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<App />,
|
||||
<App />
|
||||
)
|
||||
|
||||
@@ -38,4 +38,14 @@ export interface ChatMessage {
|
||||
export interface CustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'error' | 'loading';
|
||||
|
||||
export interface ToastNotification {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
message?: string;
|
||||
duration: number;
|
||||
}
|
||||
Reference in New Issue
Block a user