diff --git a/backend/README.md b/backend/README.md index 589f5b5..0ac8277 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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` diff --git a/backend/controllers/ChannelController.js b/backend/controllers/ChannelController.js index 2fc6921..6fa1b5e 100644 --- a/backend/controllers/ChannelController.js +++ b/backend/controllers/ChannelController.js @@ -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()); + }, +}; \ No newline at end of file diff --git a/backend/controllers/StreamController.js b/backend/controllers/StreamController.js index d9a0436..2f2fc61 100644 --- a/backend/controllers/StreamController.js +++ b/backend/controllers/StreamController.js @@ -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()) { diff --git a/backend/index.js b/backend/index.js deleted file mode 100644 index eb0fc4c..0000000 --- a/backend/index.js +++ /dev/null @@ -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(); -}); diff --git a/backend/models/Channel.js b/backend/models/Channel.js index 2aed4d8..d0bd091 100644 --- a/backend/models/Channel.js +++ b/backend/models/Channel.js @@ -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; } } diff --git a/backend/models/ChatMessage.js b/backend/models/ChatMessage.js new file mode 100644 index 0000000..e7744c0 --- /dev/null +++ b/backend/models/ChatMessage.js @@ -0,0 +1,9 @@ +class ChatMessage { + constructor(userId, message, timestamp) { + this.userId = userId; + this.message = message; + this.timestamp = timestamp; + } +} + +module.exports = ChatMessage; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 0daf0a5..060a751 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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 + } + } } } } diff --git a/backend/package.json b/backend/package.json index 9e908a5..e7bc6bd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..6366cbe --- /dev/null +++ b/backend/server.js @@ -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); + +}) diff --git a/backend/services/ChannelService.js b/backend/services/ChannelService.js new file mode 100644 index 0000000..62da72f --- /dev/null +++ b/backend/services/ChannelService.js @@ -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(); diff --git a/backend/services/ChatService.js b/backend/services/ChatService.js new file mode 100644 index 0000000..06076b9 --- /dev/null +++ b/backend/services/ChatService.js @@ -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(); \ No newline at end of file diff --git a/backend/services/SocketService.js b/backend/services/SocketService.js new file mode 100644 index 0000000..0382f7e --- /dev/null +++ b/backend/services/SocketService.js @@ -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 }; \ No newline at end of file diff --git a/backend/services/FFmpegService.js b/backend/services/streaming/FFmpegService.js similarity index 100% rename from backend/services/FFmpegService.js rename to backend/services/streaming/FFmpegService.js diff --git a/backend/services/StorageService.js b/backend/services/streaming/StorageService.js similarity index 100% rename from backend/services/StorageService.js rename to backend/services/streaming/StorageService.js diff --git a/backend/socket/ChannelSocketHandler.js b/backend/socket/ChannelSocketHandler.js new file mode 100644 index 0000000..b66afd2 --- /dev/null +++ b/backend/socket/ChannelSocketHandler.js @@ -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 }); + } + }); +}; diff --git a/backend/socket/ChatSocketHandler.js b/backend/socket/ChatSocketHandler.js new file mode 100644 index 0000000..d9faf2c --- /dev/null +++ b/backend/socket/ChatSocketHandler.js @@ -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 + }); +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e238a9a..7d33eb3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index fc15087..e4809a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64c84c4..cb40d07 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([ { - 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(channels[0]); + const [isModalOpen, setIsModalOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const addChannel = (channel: Omit) => { - 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() { @@ -153,7 +126,6 @@ function App() { setIsModalOpen(false)} - onAdd={addChannel} /> ); diff --git a/frontend/src/components/AddChannelModal.tsx b/frontend/src/components/AddChannelModal.tsx index 802a784..f1e215b 100644 --- a/frontend/src/components/AddChannelModal.tsx +++ b/frontend/src/components/AddChannelModal.tsx @@ -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) => 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(''); diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx index 9db4e34..73c196d 100644 --- a/frontend/src/components/ChannelList.tsx +++ b/frontend/src/components/ChannelList.tsx @@ -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 (
@@ -21,21 +25,18 @@ function ChannelList({ channels, selectedChannel, onSelectChannel }: ChannelList : 'hover:bg-gray-700' }`} > -
+
{channel.name} -
-

- {channel.name} -

+

{channel.name}

))}
); } -export default ChannelList; \ No newline at end of file +export default ChannelList; diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx deleted file mode 100644 index 4531209..0000000 --- a/frontend/src/components/Chat.tsx +++ /dev/null @@ -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([ - { - 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) => { - 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 ( -
-
- -

Live Chat

-
- -
- {messages.map((msg) => ( -
- {msg.user.avatar ? ( - {msg.user.name} - ) : ( -
// Spacer for system messages - )} -
-
- - {msg.user.name} - - - {new Date(msg.timestamp).toLocaleTimeString()} - -
-

{msg.message}

-
-
- ))} -
- -
-
- 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" - /> - -
-
-
- ); -} - -export default Chat; \ No newline at end of file diff --git a/frontend/src/components/chat/Chat.tsx b/frontend/src/components/chat/Chat.tsx new file mode 100644 index 0000000..87d5cab --- /dev/null +++ b/frontend/src/components/chat/Chat.tsx @@ -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([]); + 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 ( +
+
+ +

Live Chat

+
+ +
+ {messages.map((msg) => { + if(msg.userId === userName) { + return ; + } else if(msg.userId === 'System') { + return ; + } else { + return ; + } + })} +
+ +
+
+ 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" + /> + +
+
+
+ ); +} + +export default Chat; diff --git a/frontend/src/components/chat/ReceivedMessage.tsx b/frontend/src/components/chat/ReceivedMessage.tsx new file mode 100644 index 0000000..a1ba104 --- /dev/null +++ b/frontend/src/components/chat/ReceivedMessage.tsx @@ -0,0 +1,21 @@ +import { ChatMessage } from "../../types"; + +export function ReceivedMessage({ msg }: { + msg: ChatMessage +}) { + return ( +
+ {/* TODO: fetch random images */} + {msg.userId} +
+
+ {msg.userId} + + {new Date(msg.timestamp).toLocaleTimeString()} + +
+

{msg.message}

+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/chat/SendMessage.tsx b/frontend/src/components/chat/SendMessage.tsx new file mode 100644 index 0000000..62be7bf --- /dev/null +++ b/frontend/src/components/chat/SendMessage.tsx @@ -0,0 +1,21 @@ +import { ChatMessage } from "../../types"; + +export function SendMessage({ msg }: { + msg: ChatMessage +}) { + return ( +
+ {/* TODO: fetch random images */} + {msg.userId} +
+
+ {msg.userId} + + {new Date(msg.timestamp).toLocaleTimeString()} + +
+

{msg.message}

+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/chat/SystemMessage.tsx b/frontend/src/components/chat/SystemMessage.tsx new file mode 100644 index 0000000..efdb13c --- /dev/null +++ b/frontend/src/components/chat/SystemMessage.tsx @@ -0,0 +1,18 @@ +import { ChatMessage } from "../../types"; + +export function SystemMessage({ msg }: { + msg: ChatMessage +}) { + return ( +
+
+
+ + {new Date(msg.timestamp).toLocaleTimeString()} + +
+

{msg.message}

+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6f4ac9b..0b31db0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,7 +4,5 @@ import App from './App.tsx' import './index.css' createRoot(document.getElementById('root')!).render( - - - , + , ) diff --git a/frontend/src/services/SocketService.ts b/frontend/src/services/SocketService.ts new file mode 100644 index 0000000..2f4065c --- /dev/null +++ b/frontend/src/services/SocketService.ts @@ -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 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(event: string, listener: (data: T) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)?.push(listener); + } + + // Event abbestellen + unsubscribeFromEvent(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; \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 68e83d8..d0704f7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; } \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 70ecc15..5e5d7b5 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,4 +7,7 @@ export default defineConfig({ optimizeDeps: { exclude: ['lucide-react'], }, + server: { + port: 8080, + }, })