feat: stream notifications

This commit is contained in:
antebrl
2024-12-07 17:54:22 +00:00
parent b3a82b17b1
commit 30e6caa040
6 changed files with 245 additions and 67 deletions

View File

@@ -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>
);
}

View File

@@ -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 () => {

View 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;

View 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>
);
}

View File

@@ -3,5 +3,5 @@ import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<App />,
<App />
)

View File

@@ -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;
}