diff --git a/Dockerfile b/Dockerfile index 7ac8368..b4aa83d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,7 @@ -FROM nginx:alpine - -# Copy the static web application files -COPY index.html /usr/share/nginx/html/index.html -COPY streams.txt /usr/share/nginx/html/streams.txt - -# Nginx default port +FROM node:18-alpine +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --only=production +COPY . . EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 238963e..46d3375 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,5 +5,5 @@ services: ports: - "8080:80" volumes: - - ./streams.txt:/usr/share/nginx/html/streams.txt:ro + - ./streams.txt:/app/streams.txt restart: unless-stopped diff --git a/index.html b/index.html index 1423b57..9116bb2 100644 --- a/index.html +++ b/index.html @@ -177,16 +177,49 @@ margin-bottom: 24px; } - /* Stream Selector Title */ + /* Channels Section Header Layout */ + .channels-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + .section-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-secondary); - margin-bottom: 12px; font-weight: 600; } + .add-channel-btn { + background: rgba(99, 102, 241, 0.12); + border: 1px solid rgba(99, 102, 241, 0.25); + color: #818cf8; + font-size: 12px; + font-weight: 600; + padding: 6px 12px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; + } + + .add-channel-btn:hover { + background: rgba(99, 102, 241, 0.25); + border-color: var(--accent); + color: white; + } + + .add-channel-btn svg { + width: 14px; + height: 14px; + fill: currentColor; + } + /* Stream list scroll area */ .controls-list-wrapper { flex-grow: 1; @@ -380,6 +413,168 @@ text-align: center; } + /* Modal styling */ + .modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(9, 9, 11, 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + z-index: 100; + } + + .modal-backdrop.active { + opacity: 1; + pointer-events: auto; + } + + .modal-card { + background: rgba(15, 23, 42, 0.9); + border: 1px solid var(--panel-border); + border-radius: 16px; + width: 90%; + max-width: 480px; + padding: 28px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6); + transform: scale(0.9); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + } + + .modal-backdrop.active .modal-card { + transform: scale(1); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + .modal-header h2 { + font-size: 20px; + font-weight: 700; + background: linear-gradient(to right, #ffffff, #cbd5e1); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + + .close-modal-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 24px; + cursor: pointer; + line-height: 1; + transition: color 0.2s; + } + + .close-modal-btn:hover { + color: var(--text-primary); + } + + .form-group { + margin-bottom: 18px; + display: flex; + flex-direction: column; + gap: 6px; + } + + .form-group label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + } + + .form-group input, .form-group textarea { + background: rgba(30, 41, 59, 0.4); + border: 1px solid var(--btn-border); + border-radius: 8px; + padding: 12px; + color: var(--text-primary); + font-family: inherit; + font-size: 14px; + transition: all 0.2s; + } + + .form-group input:focus, .form-group textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 10px rgba(99, 102, 241, 0.2); + background: rgba(30, 41, 59, 0.6); + } + + .form-group textarea { + height: 100px; + resize: none; + } + + .modal-error-message { + color: #fca5a5; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; + padding: 10px; + font-size: 13px; + margin-bottom: 18px; + display: none; + line-height: 1.4; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + } + + .modal-btn-primary { + background: var(--accent); + color: white; + border: none; + padding: 10px 20px; + font-weight: 600; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; + } + + .modal-btn-primary:hover { + background: var(--accent-hover); + } + + .modal-btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .modal-btn-secondary { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--btn-border); + padding: 10px 20px; + font-weight: 600; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + } + + .modal-btn-secondary:hover { + background: var(--btn-hover-bg); + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.15); + } + @keyframes spin { to { transform: rotate(360deg); } } @@ -422,7 +617,15 @@
-
Available Channels
+
+
Available Channels
+ +
@@ -434,7 +637,32 @@
+
+ + + @@ -501,13 +729,6 @@ // Parse streams.txt and construct lists async function loadStreams() { const list = document.getElementById('controlsList'); - list.innerHTML = ` -
-
- Parsing streams... -
- `; - try { const response = await fetch('streams.txt'); if (!response.ok) { @@ -538,10 +759,6 @@ } }); - if (streams.length === 0) { - throw new Error('streams.txt contains no valid streams. Add stream entries using format: "Name, URL"'); - } - activeStreams = streams; buildControls(); @@ -556,7 +773,16 @@ const list = document.getElementById('controlsList'); list.innerHTML = ''; - activeStreams.forEach((stream, index) => { + if (activeStreams.length === 0) { + list.innerHTML = ` +
+ No active channels. Click "+ Add Channel" to start. +
+ `; + return; + } + + activeStreams.forEach((stream) => { const badgeHtml = getStreamBadge(stream.url); const button = document.createElement('button'); button.className = 'stream-btn'; @@ -567,12 +793,6 @@ button.onclick = () => switchStream(button, stream.url); list.appendChild(button); }); - - // Automatically switch and load the first stream in the text file - if (activeStreams.length > 0) { - const firstButton = list.querySelector('.stream-btn'); - switchStream(firstButton, activeStreams[0].url); - } } // Handle loader removal when stream frame finishes loading @@ -580,8 +800,80 @@ document.getElementById('loader').classList.remove('active'); }); + // Modal Action Handlers + function openModal() { + const modal = document.getElementById('addStreamModal'); + const err = document.getElementById('modalError'); + err.style.display = 'none'; + document.getElementById('addStreamForm').reset(); + modal.classList.add('active'); + } + + function closeModal() { + document.getElementById('addStreamModal').classList.remove('active'); + } + + async function submitNewStream(event) { + event.preventDefault(); + const nameInput = document.getElementById('newStreamName'); + const codeInput = document.getElementById('newStreamCode'); + const errorDiv = document.getElementById('modalError'); + const submitBtn = document.getElementById('submitBtn'); + + const name = nameInput.value.trim(); + const embedCode = codeInput.value.trim(); + + errorDiv.style.display = 'none'; + submitBtn.disabled = true; + submitBtn.textContent = 'Adding...'; + + try { + const response = await fetch('/api/streams', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, embedCode }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to save stream.'); + } + + // Close and update list + closeModal(); + await loadStreams(); + + // Automatically switch to the newly added stream + const buttons = document.querySelectorAll('.stream-btn'); + for (let btn of buttons) { + const nameEl = btn.querySelector('.stream-name'); + if (nameEl && nameEl.textContent === data.name) { + switchStream(btn, data.url); + break; + } + } + + } catch (err) { + errorDiv.textContent = err.message; + errorDiv.style.display = 'block'; + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Add Channel'; + } + } + // Initialize loading on start - window.addEventListener('DOMContentLoaded', loadStreams); + window.addEventListener('DOMContentLoaded', async () => { + await loadStreams(); + // Automatically switch and load the first stream in the list on startup + if (activeStreams.length > 0) { + const firstButton = document.querySelector('.stream-btn'); + switchStream(firstButton, activeStreams[0].url); + } + }); diff --git a/package.json b/package.json new file mode 100644 index 0000000..09295df --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "stream-hub", + "version": "1.0.0", + "description": "StreamHub Live Player Server", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.19.2" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..5e66e5f --- /dev/null +++ b/server.js @@ -0,0 +1,63 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 80; + +app.use(express.json()); +app.use(express.static(__dirname)); + +// Endpoint to add a stream from the frontend +app.post('/api/streams', (req, res) => { + const { name, embedCode } = req.body; + if (!name || !embedCode) { + return res.status(400).json({ error: 'Name and embed code are required.' }); + } + + // Extract URL from embedCode + // Supports iframe elements (searches for src attribute) and raw URLs + let url = ''; + const srcMatch = embedCode.match(/src=["']([^"']+)["']/i); + if (srcMatch) { + url = srcMatch[1]; + } else { + const trimmed = embedCode.trim(); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + url = trimmed; + } else { + return res.status(400).json({ error: 'Invalid input. Please provide a valid iframe embed code or raw URL.' }); + } + } + + // Clean name and url + const cleanName = name.trim(); + const cleanUrl = url.trim(); + + if (!cleanName || !cleanUrl) { + return res.status(400).json({ error: 'Name and URL cannot be empty.' }); + } + + // Read the file to see if a newline is needed at the end + const filePath = path.join(__dirname, 'streams.txt'); + fs.readFile(filePath, 'utf8', (err, data) => { + let prefix = ''; + if (!err && data.length > 0 && !data.endsWith('\n')) { + prefix = '\n'; + } + + const newline = `${prefix}${cleanName}, ${cleanUrl}\n`; + + fs.appendFile(filePath, newline, 'utf8', (writeErr) => { + if (writeErr) { + console.error('File write error:', writeErr); + return res.status(500).json({ error: 'Failed to write to streams.txt' }); + } + res.json({ success: true, name: cleanName, url: cleanUrl }); + }); + }); +}); + +app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); +}); diff --git a/streams.txt b/streams.txt index b0fdde4..4f45f80 100644 --- a/streams.txt +++ b/streams.txt @@ -5,3 +5,4 @@ Twitch Stream, https://player.twitch.tv/?channel=twitch&parent=localhost Alt YouTube, https://www.youtube.com/embed/jNQXAC9IVRw FOX wc final, https://embed.st/embed/admin/ppv-spain-vs-argentina/3 FOX timstreams, https://logic.icelanders.st/embed/fox-usa +TSN wc, https://embed.st/embed/admin/ppv-spain-vs-argentina/1