Merge pull request #7 from antebrl/5-live-chat-implementation

5 live chat implementation
This commit is contained in:
Ante Brähler
2024-11-20 15:26:55 +01:00
committed by GitHub
30 changed files with 867 additions and 297 deletions

View File

@@ -18,3 +18,15 @@ Before running, make sure you have `ffmpeg` installed on your system.
node index.js
```
Be aware, that this application is designed for Linux systems!
## Architecture
### API
- Endpoints to add a channel, get all channels, get selected channel and set selected channel
### WebSockets
- `channel-added` and `channel-selected` events will be send to all connected clients
- chat messages: `send-chat-message` and `chat-message`
- users: `user-connected` and `user-disconnected`

View File

@@ -1,66 +1,11 @@
const ffmpegService = require('../services/FFmpegService');
const storageService = require('../services/StorageService');
const Channel = require('../models/Channel');
let channels = [new Channel('DEFAULT_CHANNEL', process.env.DEFAULT_CHANNEL_URL)];
let currentChannel = channels[0];
function setCurrent(req, res) {
const { channelName } = req.body || {};
if (!channelName) {
return res.status(400).json({ status: 'error', message: 'channelName is required' });
}
const nextChannel = channels.find(channel => channel.name === channelName);
if (!nextChannel) {
res.status(400).json({ status: 'error', message: 'Channel does not exist' });
}
if (currentChannel !== nextChannel) {
const segmentNumber = storageService.getNextSegmentNumber();
storageService.clearStorage();
currentChannel = nextChannel;
ffmpegService.startFFmpeg(nextChannel.url, segmentNumber);
}
res.status(200).json({ status: 'success', channelUrl: currentChannel.url });
}
function getCurrent(_, res) {
if (currentChannel) {
res.status(200).json({ channelName: currentChannel.name, channelUrl: currentChannel.url });
} else {
res.status(404).json({ status: 'error', message: 'No channel active' });
}
}
function getChannels(_, res) {
res.status(200).json({ channels });
}
function addChannel(req, res) {
const { name, url } = req.body;
if (!name || !url) {
return res.status(400).json({ status: 'error', message: 'Channel name and URL are required' });
}
const channelExists = channels.some(channel => channel.url === url);
if (channelExists) {
return res.status(409).json({ status: 'error', message: 'Channel already exists' });
}
const newChannel = new Channel(name, url);
channels.push(newChannel);
res.status(201).json({ status: 'success', message: 'Channel added', channel: newChannel });
}
const ChannelService = require('../services/ChannelService');
module.exports = {
setCurrent,
getCurrent,
getChannels,
addChannel
};
getChannels(req, res) {
res.json(ChannelService.getChannels());
},
getCurrentChannel(req, res) {
res.json(ChannelService.getCurrentChannel());
},
};

View File

@@ -1,5 +1,5 @@
const ffmpegService = require('../services/FFmpegService');
const storageService = require('../services/StorageService');
const ffmpegService = require('../services/streaming/FFmpegService');
const storageService = require('../services/streaming/StorageService');
function start() {
if (!ffmpegService.isFFmpegRunning()) {

View File

@@ -1,27 +0,0 @@
const express = require('express');
const dotenv = require('dotenv');
const channelController = require('./controllers/ChannelController');
const streamController = require('./controllers/StreamController');
dotenv.config();
const app = express();
app.use(express.json());
const apiRouter = express.Router();
apiRouter.post('/current', channelController.setCurrent);
apiRouter.get('/current', channelController.getCurrent);
apiRouter.get('/', channelController.getChannels);
apiRouter.post('/add', channelController.addChannel);
app.use('/channels', apiRouter);
const PORT = 5000;
app.listen(PORT, () => {
console.log(`Server listening on Port ${PORT}`);
streamController.start();
});

View File

@@ -1,7 +1,10 @@
class Channel {
constructor(name, url) {
static nextId = 0;
constructor(name, url, avatar) {
this.id = Channel.nextId++;
this.name = name;
this.url = url;
this.avatar = avatar;
}
}

View File

@@ -0,0 +1,9 @@
class ChatMessage {
constructor(userId, message, timestamp) {
this.userId = userId;
this.message = message;
this.timestamp = timestamp;
}
}
module.exports = ChatMessage;

View File

@@ -11,7 +11,34 @@
"dependencies": {
"child_process": "^1.0.2",
"dotenv": "^16.4.5",
"express": "^4.21.1"
"express": "^4.21.1",
"socket.io": "^4.8.1"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@types/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="
},
"node_modules/@types/cors": {
"version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
"integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dependencies": {
"undici-types": "~6.19.8"
}
},
"node_modules/accepts": {
@@ -31,6 +58,14 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -117,6 +152,18 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -182,6 +229,63 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz",
"integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==",
"dependencies": {
"@types/cookie": "^0.4.1",
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
@@ -475,6 +579,14 @@
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
@@ -670,6 +782,107 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -698,6 +911,11 @@
"node": ">= 0.6"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -721,6 +939,26 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -2,8 +2,9 @@
"name": "iptv-restream",
"version": "1.0.0",
"description": "",
"main": "index.js",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
@@ -20,6 +21,7 @@
"dependencies": {
"child_process": "^1.0.2",
"dotenv": "^16.4.5",
"express": "^4.21.1"
"express": "^4.21.1",
"socket.io": "^4.8.1"
}
}

52
backend/server.js Normal file
View File

@@ -0,0 +1,52 @@
const express = require('express');
const dotenv = require('dotenv');
const { Server } = require('socket.io');
const ChatSocketHandler = require('./socket/ChatSocketHandler');
const ChannelSocketHandler = require('./socket/ChannelSocketHandler');
const channelController = require('./controllers/ChannelController');
const streamController = require('./controllers/StreamController');
dotenv.config();
const app = express();
app.use(express.json());
const apiRouter = express.Router();
apiRouter.get('/', channelController.getChannels);
apiRouter.get('/current', channelController.getCurrentChannel);
app.use('/channels', apiRouter);
const PORT = 5000;
const server = app.listen(PORT, () => {
console.log(`Server listening on Port ${PORT}`);
//streamController.start();
});
// Web Sockets
const io = new Server(server);
const connectedUsers = {};
io.on('connection', socket => {
console.log('New client connected');
socket.on('new-user', userId => {
connectedUsers[socket.id] = userId;
socket.broadcast.emit('user-connected', userId);
})
socket.on('disconnect', () => {
socket.broadcast.emit('user-disconnected', connectedUsers[socket.id]);
delete connectedUsers[socket.id];
})
ChannelSocketHandler(io, socket);
ChatSocketHandler(io, socket);
})

View File

@@ -0,0 +1,45 @@
const ffmpegService = require('./streaming/FFmpegService');
const storageService = require('./streaming/StorageService');
const Channel = require('../models/Channel');
class ChannelService {
constructor() {
this.channels = [new Channel('DEFAULT_CHANNEL', process.env.DEFAULT_CHANNEL_URL, "https://images.unsplash.com/photo-1534308143481-c55f00be8bd7?w=64&h=64&fit=crop&crop=faces")];
this.currentChannel = this.channels[0];
}
getChannels() {
return this.channels;
}
addChannel(name, url, avatar) {
const existing = this.channels.some(channel => channel.url === url);
if (existing) {
throw new Error('Channel already exists');
}
const newChannel = new Channel(name, url, avatar);
this.channels.push(newChannel);
return newChannel;
}
setCurrentChannel(id) {
const nextChannel = this.channels.find(channel => channel.id === id);
if (!nextChannel) {
throw new Error('Channel does not exist');
}
if (this.currentChannel !== nextChannel) {
const segmentNumber = storageService.getNextSegmentNumber();
storageService.clearStorage();
this.currentChannel = nextChannel;
ffmpegService.startFFmpeg(nextChannel.url, segmentNumber);
}
return nextChannel;
}
getCurrentChannel() {
return this.currentChannel;
}
}
module.exports = new ChannelService();

View File

@@ -0,0 +1,20 @@
const ChatMessage = require('../models/ChatMessage');
// At the moment, this service is not used! It is only a placeholder for future development for a persistent chat.
class ChatService {
constructor() {
this.messages = [];
}
addMessage(userId, message, timestamp) {
const newChatMessage = new ChatMessage(userId, message, timestamp);
this.messages.push(newChatMessage);
return newChatMessage;
}
getMessages() {
return this.messages;
}
}
module.exports = new ChatService();

View File

@@ -0,0 +1,14 @@
let io;
function setSocketIO(socketIOInstance) {
io = socketIOInstance;
}
function getSocketIO() {
if (!io) {
throw new Error('Socket.IO instance not initialized');
}
return io;
}
module.exports = { setSocketIO, getSocketIO };

View File

@@ -0,0 +1,24 @@
const ChannelService = require('../services/ChannelService');
module.exports = (io, socket) => {
socket.on('add-channel', ({ name, url, avatar }) => {
try {
const newChannel = ChannelService.addChannel(name, url, avatar);
io.emit('channel-added', newChannel); // Broadcast to all clients
} catch (err) {
socket.emit('app-error', { message: err.message });
}
});
socket.on('set-current-channel', (id) => {
try {
const nextChannel = ChannelService.setCurrentChannel(id);
io.emit('channel-selected', nextChannel); // Broadcast to all clients
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
}
});
};

View File

@@ -0,0 +1,10 @@
const ChatService = require('../services/ChatService');
const ChatMessage = require('../models/ChatMessage');
module.exports = (io, socket) => {
socket.on('send-message', ({ userId, message, timestamp }) => {
const chatMessage = ChatService.addMessage(userId, message, timestamp);
socket.broadcast.emit('chat-message', chatMessage) // Broadcast to all clients except sender
});
};

View File

@@ -11,7 +11,8 @@
"hls.js": "^1.4.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
@@ -1209,6 +1210,11 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1894,7 +1900,6 @@
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1943,6 +1948,26 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
"node_modules/engine.io-client": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
"integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -2856,8 +2881,7 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/mz": {
"version": "2.7.0",
@@ -3473,6 +3497,32 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4036,6 +4086,34 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -4772,6 +4850,11 @@
"dev": true,
"optional": true
},
"@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -5234,7 +5317,6 @@
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"requires": {
"ms": "^2.1.3"
}
@@ -5275,6 +5357,23 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
"engine.io-client": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
"integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="
},
"esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -5938,8 +6037,7 @@
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"mz": {
"version": "2.7.0",
@@ -6327,6 +6425,26 @@
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true
},
"socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
}
},
"socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
}
},
"source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6682,6 +6800,17 @@
}
}
},
"ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"requires": {}
},
"xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,10 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"hls.js": "^1.4.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"hls.js": "^1.4.0"
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
@@ -31,4 +32,4 @@
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}
}

View File

@@ -1,94 +1,68 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Search, Plus, Settings, Users, Radio, MessageSquare, Tv2 } from 'lucide-react';
import VideoPlayer from './components/VideoPlayer';
import ChannelList from './components/ChannelList';
import Chat from './components/Chat';
import Chat from './components/chat/Chat';
import AddChannelModal from './components/AddChannelModal';
import { Channel } from './types';
import socketService from './services/SocketService';
function App() {
const [channels, setChannels] = useState<Channel[]>([
{
id: 1,
id: 100,
name: 'Das Erste',
url: 'https://mcdn.daserste.de/daserste/de/master1080p5000.m3u8',
isLive: true,
avatar: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Das_Erste-Logo_klein.svg/768px-Das_Erste-Logo_klein.svg.png'
},
{
id: 2,
id: 200,
name: 'ZDF',
url: 'https://mcdn.daserste.de/daserste/de/master1080p5000.m3u8',
isLive: true,
avatar: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/ZDF_logo.svg/2560px-ZDF_logo.svg.png'
},
{
id: 3,
id: 300,
name: 'Creative Studio',
url: 'https://mcdn.daserste.de/daserste/de/master1080p5000.m3u8',
isLive: false,
avatar: 'https://images.unsplash.com/photo-1534308143481-c55f00be8bd7?w=64&h=64&fit=crop&crop=faces'
},
{
id: 4,
id: 400,
name: 'Creative Studio',
url: 'https://mcdn.daserste.de/daserste/de/master1080p5000.m3u8',
isLive: false,
avatar: 'https://images.unsplash.com/photo-1534308143481-c55f00be8bd7?w=64&h=64&fit=crop&crop=faces'
},
{
id: 5,
name: 'Creative Studio',
url: 'https://mcdn.daserste.de/daserste/de/master1080p5000.m3u8',
isLive: false,
avatar: 'https://images.unsplash.com/photo-1534308143481-c55f00be8bd7?w=64&h=64&fit=crop&crop=faces'
},
{
id: 6,
name: 'Creative Studio',
url: 'https://mcdn.daserste.de/daserste/de/master1080p5000.m3u8',
isLive: false,
avatar: 'https://images.unsplash.com/photo-1534308143481-c55f00be8bd7?w=64&h=64&fit=crop&crop=faces'
},
{
id: 7,
name: 'Creative Studio',
url: 'https://mcdn.daserste.de/daserste/de/master1080p5000.m3u8',
isLive: false,
avatar: 'https://images.unsplash.com/photo-1534308143481-c55f00be8bd7?w=64&h=64&fit=crop&crop=faces'
},
]);
const [selectedChannel, setSelectedChannel] = useState<Channel>(channels[0]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const addChannel = (channel: Omit<Channel, 'id' | 'avatar'>) => {
const newChannel = {
...channel,
id: channels.length + 1,
avatar: `https://images.unsplash.com/photo-${Math.floor(Math.random() * 1000)}?w=64&h=64&fit=crop&crop=faces`,
};
setChannels([...channels, newChannel]);
};
const handleChannelChange = (channel: Channel) => {
setSelectedChannel(channel);
};
useEffect(() => {
const systemMessage = {
id: Date.now(),
user: {
name: 'System',
avatar: ''
},
message: `Switched to ${selectedChannel.name}'s stream`,
timestamp: new Date().toISOString()
socketService.connect();
console.log('Subscribing to events');
const channelAddedListener = (channel: Channel) => {
setChannels((prevChannels) => [...prevChannels, channel]);
};
window.dispatchEvent(new CustomEvent('newChatMessage', { detail: systemMessage }));
}, [selectedChannel]);
const channelSelectedListener = (nextChannel: Channel) => {
setSelectedChannel(nextChannel);
};
socketService.subscribeToEvent('channel-added', channelAddedListener);
socketService.subscribeToEvent('channel-selected', channelSelectedListener);
return () => {
socketService.unsubscribeFromEvent('channel-added', channelAddedListener);
socketService.unsubscribeFromEvent('channel-selected', channelSelectedListener);
socketService.disconnect();
console.log('WebSocket connection closed');
};
}, []);
const filteredChannels = channels.filter(channel =>
channel.name.toLowerCase().includes(searchQuery.toLowerCase())
@@ -137,7 +111,6 @@ function App() {
<ChannelList
channels={filteredChannels}
selectedChannel={selectedChannel}
onSelectChannel={handleChannelChange}
/>
</div>
@@ -153,7 +126,6 @@ function App() {
<AddChannelModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onAdd={addChannel}
/>
</div>
);

View File

@@ -1,14 +1,13 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { Channel } from '../types';
import socketService from '../services/SocketService';
interface AddChannelModalProps {
isOpen: boolean;
onClose: () => void;
onAdd: (channel: Omit<Channel, 'id'>) => void;
}
function AddChannelModal({ isOpen, onClose, onAdd }: AddChannelModalProps) {
function AddChannelModal({ isOpen, onClose}: AddChannelModalProps) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
@@ -16,11 +15,7 @@ function AddChannelModal({ isOpen, onClose, onAdd }: AddChannelModalProps) {
e.preventDefault();
if (!name.trim() || !url.trim()) return;
onAdd({
name: name.trim(),
url: url.trim(),
isLive: true,
});
socketService.addChannel(name.trim(), url.trim(), `https://images.unsplash.com/photo-${Math.floor(Math.random() * 1000)}?w=64&h=64&fit=crop&crop=faces`);
setName('');
setUrl('');

View File

@@ -1,13 +1,17 @@
import React from 'react';
import { Channel } from '../types';
import socketService from '../services/SocketService';
interface ChannelListProps {
channels: Channel[];
selectedChannel: Channel;
onSelectChannel: (channel: Channel) => void;
}
function ChannelList({ channels, selectedChannel, onSelectChannel }: ChannelListProps) {
function ChannelList({ channels, selectedChannel}: ChannelListProps) {
const onSelectChannel = (channel: Channel) => {
socketService.setCurrentChannel(channel.id);
};
return (
<div className="flex space-x-3 hover:overflow-x-auto overflow-hidden pb-2 px-1 pt-1 scroll-container">
@@ -21,21 +25,18 @@ function ChannelList({ channels, selectedChannel, onSelectChannel }: ChannelList
: 'hover:bg-gray-700'
}`}
>
<div className="h-20 w-20 mb-2 flex items-center justify-center rounded-lg overflow-hidden mx-auto">
<div className="h-20 w-20 mb-2 flex items-center justify-center rounded-lg overflow-hidden mx-auto">
<img
src={channel.avatar}
alt={channel.name}
className="w-full h-full object-contain rounded-lg transition-transform group-hover:scale-105"
/>
</div>
<p className="text-sm font-medium truncate text-center">
{channel.name}
</p>
<p className="text-sm font-medium truncate text-center">{channel.name}</p>
</button>
))}
</div>
);
}
export default ChannelList;
export default ChannelList;

View File

@@ -1,112 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Send, MessageSquare } from 'lucide-react';
import { ChatMessage } from '../types';
function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: 1,
user: {
name: 'Alex Thompson',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=64&h=64&fit=crop&crop=faces',
},
message: 'Amazing stream today! 🔥',
timestamp: new Date().toISOString(),
},
{
id: 2,
user: {
name: 'Sarah Chen',
avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=64&h=64&fit=crop&crop=faces',
},
message: 'The quality is incredible!',
timestamp: new Date().toISOString(),
},
]);
const [newMessage, setNewMessage] = useState('');
useEffect(() => {
const handleNewMessage = (event: CustomEvent<ChatMessage>) => {
setMessages(prev => [...prev, event.detail]);
};
window.addEventListener('newChatMessage', handleNewMessage as EventListener);
return () => {
window.removeEventListener('newChatMessage', handleNewMessage as EventListener);
};
}, []);
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
const message: ChatMessage = {
id: messages.length + 1,
user: {
name: 'You',
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=64&h=64&fit=crop&crop=faces',
},
message: newMessage,
timestamp: new Date().toISOString(),
};
setMessages([...messages, message]);
setNewMessage('');
};
return (
<div className="bg-gray-800 rounded-lg h-full">
<div className="flex items-center space-x-2 p-4 border-b border-gray-700">
<MessageSquare className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold">Live Chat</h2>
</div>
<div className="h-[calc(100vh-13rem)] overflow-y-auto p-4 space-y-4 scroll-container vertical-scroll-container">
{messages.map((msg) => (
<div key={msg.id} className="flex items-start space-x-3">
{msg.user.avatar ? (
<img
src={msg.user.avatar}
alt={msg.user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8" /> // Spacer for system messages
)}
<div>
<div className="flex items-center space-x-2">
<span className={`font-medium ${msg.user.name === 'System' ? 'text-blue-400' : ''}`}>
{msg.user.name}
</span>
<span className="text-xs text-gray-400">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-gray-300">{msg.message}</p>
</div>
</div>
))}
</div>
<form onSubmit={handleSendMessage} className="p-4 border-t border-gray-700">
<div className="relative">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type a message..."
className="w-full bg-gray-700 rounded-lg pl-4 pr-12 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-500 hover:bg-blue-600 p-1.5 rounded-lg transition-colors"
>
<Send className="w-4 h-4" />
</button>
</div>
</form>
</div>
);
}
export default Chat;

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react';
import { Send, MessageSquare } from 'lucide-react';
import socketService from '../../services/SocketService';
import { Channel, ChatMessage } from '../../types';
import { SendMessage } from './SendMessage';
import { SystemMessage } from './SystemMessage';
import { ReceivedMessage } from './ReceivedMessage';
function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [userName] = useState('You');
useEffect(() => {
const messageListener = (message: ChatMessage) => {
setMessages((prevMessages) => [...prevMessages, message]);
};
socketService.subscribeToEvent('chat-message', messageListener);
const channelSelectedListener = (selectedChannel: Channel) => {
const systemMessage = {
message: `Switched to ${selectedChannel.name}'s stream`,
timestamp: new Date().toISOString(),
userId: 'System',
};
setMessages((prevMessages) => [...prevMessages, systemMessage]);
}
socketService.subscribeToEvent('channel-selected', channelSelectedListener);
return () => {
socketService.unsubscribeFromEvent('chat-message', messageListener);
socketService.unsubscribeFromEvent('channel-selected', channelSelectedListener);
};
}, []);
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
socketService.sendMessage(userName, newMessage, new Date().toISOString());
setMessages((prev) => [
...prev,
{
userId: userName,
message: newMessage,
timestamp: new Date().toISOString(),
},
]);
setNewMessage('');
};
return (
<div className="bg-gray-800 rounded-lg h-full">
<div className="flex items-center space-x-2 p-4 border-b border-gray-700">
<MessageSquare className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold">Live Chat</h2>
</div>
<div className="h-[calc(100vh-13rem)] overflow-y-auto p-4 space-y-4 scroll-container vertical-scroll-container">
{messages.map((msg) => {
if(msg.userId === userName) {
return <SendMessage msg={msg}></SendMessage>;
} else if(msg.userId === 'System') {
return <SystemMessage msg={msg}></SystemMessage>;
} else {
return <ReceivedMessage msg={msg}></ReceivedMessage>;
}
})}
</div>
<form onSubmit={handleSendMessage} className="p-4 border-t border-gray-700">
<div className="relative">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type a message..."
className="w-full bg-gray-700 rounded-lg pl-4 pr-12 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button type="submit" className="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-500 hover:bg-blue-600 p-1.5 rounded-lg transition-colors">
<Send className="w-4 h-4" />
</button>
</div>
</form>
</div>
);
}
export default Chat;

View File

@@ -0,0 +1,21 @@
import { ChatMessage } from "../../types";
export function ReceivedMessage({ msg }: {
msg: ChatMessage
}) {
return (
<div className="flex items-start space-x-3 pr-10">
{/* TODO: fetch random images */}
<img src={`https://images.unsplash.com/photo-${Math.floor(Math.random() * 1000)}?w=64&h=64&fit=crop&crop=faces`} alt={msg.userId} className="w-8 h-8 rounded-full" />
<div>
<div className="flex items-center space-x-2">
<span className="font-medium">{msg.userId}</span>
<span className="text-xs text-gray-400">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-gray-300">{msg.message}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { ChatMessage } from "../../types";
export function SendMessage({ msg }: {
msg: ChatMessage
}) {
return (
<div className="flex items-start justify-end space-x-3 pl-10">
{/* TODO: fetch random images */}
<img src={`https://images.unsplash.com/photo-${Math.floor(Math.random() * 1000)}?w=64&h=64&fit=crop&crop=faces`} alt={msg.userId} className="w-8 h-8 rounded-full" />
<div>
<div className="flex items-center space-x-2">
<span className="font-medium">{msg.userId}</span>
<span className="text-xs text-gray-400">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-gray-300">{msg.message}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { ChatMessage } from "../../types";
export function SystemMessage({ msg }: {
msg: ChatMessage
}) {
return (
<div className="flex items-start space-x-3">
<div className="ml-11">
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-400">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-blue-400">{msg.message}</p>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,87 @@
import { io, Socket } from 'socket.io-client';
import { Channel, ChatMessage } from '../types';
class SocketService {
private socket: Socket | null = null;
private listeners: Map<string, ((data: any) => void)[]> = new Map();
// Initialize
connect() {
if (this.socket?.connected) return;
console.log('Connecting to WebSocket server');
this.socket = io(import.meta.env.BACKEND_WS_URL);
this.socket.on('connect', () => {
console.log('Connected to WebSocket server');
});
this.socket.on('disconnect', () => {
console.log('Disconnected from WebSocket server');
});
this.socket.on('app-error', (error) => {
console.error('Failed:', error);
});
// Listen for incoming custom events
this.socket.onAny((event: string, data: any) => {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
eventListeners.forEach((listener) => listener(data));
}
});
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
subscribeToEvent<T>(event: string, listener: (data: T) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)?.push(listener);
}
// Event abbestellen
unsubscribeFromEvent<T>(event: string, listener: (data: T) => void) {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
this.listeners.set(
event,
eventListeners.filter((existingListener) => existingListener !== listener)
);
}
}
// Nachricht senden
sendMessage(userId, message, timestamp) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('send-message', { userId, message, timestamp });
}
// Channel hinzufügen
addChannel(name, url, avatar) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('add-channel', { name, url, avatar });
}
// Aktuellen Channel setzen
setCurrentChannel(id) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('set-current-channel', id);
}
}
const socketService = new SocketService();
export default socketService;

View File

@@ -1,3 +1,4 @@
// Not used
export interface User {
name: string;
avatar: string;
@@ -7,13 +8,11 @@ export interface Channel {
id: number;
name: string;
url: string;
isLive: boolean;
avatar: string;
}
export interface ChatMessage {
id: number;
user: User;
userId: string;
message: string;
timestamp: string;
}

View File

@@ -7,4 +7,7 @@ export default defineConfig({
optimizeDeps: {
exclude: ['lucide-react'],
},
server: {
port: 8080,
},
})