Compare commits
2 Commits
acbd3b6349
...
96b099d892
| Author | SHA1 | Date | |
|---|---|---|---|
| 96b099d892 | |||
| e9a9e336da |
@@ -26,7 +26,13 @@
|
|||||||
"Bash(ls /c/project/SRT_stream/dist-electron/*.exe)",
|
"Bash(ls /c/project/SRT_stream/dist-electron/*.exe)",
|
||||||
"Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{:*)",
|
"Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{:*)",
|
||||||
"Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{\"tag_name\":\"v1.0.0\",\"name\":\"SRT Streamer v1.0.0\",\"body\":\"Portable Windows EXE\",\"draft\":false,\"prerelease\":false}')",
|
"Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{\"tag_name\":\"v1.0.0\",\"name\":\"SRT Streamer v1.0.0\",\"body\":\"Portable Windows EXE\",\"draft\":false,\"prerelease\":false}')",
|
||||||
"Bash(npx vite *)"
|
"Bash(npx vite *)",
|
||||||
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(dir dist-electron\\\\*.exe)",
|
||||||
|
"Bash(curl -s -X POST https://git.queo.ru/api/v1/repos/admin/srt-streamer/releases -H 'Authorization: token c4a37376c2c3375987f1a6eb3fd11e1305468e35' -H 'Content-Type: application/json' -d '{ *)",
|
||||||
|
"Bash(python -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('Release ID:', r.get\\('id'\\), '| URL:', r.get\\('html_url'\\)\\)\")",
|
||||||
|
"Bash(python -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('Asset:', r.get\\('name'\\), '| Size:', r.get\\('size'\\), 'bytes'\\)\")",
|
||||||
|
"Bash(git checkout *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-3
@@ -1,8 +1,9 @@
|
|||||||
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog, shell } = require('electron')
|
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog, shell } = require('electron')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const ffmpegManager = require('./ffmpeg')
|
const ffmpegManager = require('./ffmpeg')
|
||||||
const deviceManager = require('./devices')
|
const deviceManager = require('./devices')
|
||||||
|
const remoteClient = require('./remote')
|
||||||
|
|
||||||
// ─── Icon helpers ──────────────────────────────────────────────────────────────
|
// ─── Icon helpers ──────────────────────────────────────────────────────────────
|
||||||
// Draw a pixel into an RGBA buffer
|
// Draw a pixel into an RGBA buffer
|
||||||
@@ -124,7 +125,12 @@ function loadConfig() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load config:', e)
|
console.error('Failed to load config:', e)
|
||||||
}
|
}
|
||||||
return { streams: [], tolbek: { enabled: false, port: 5000, latency: 200 }, recording: { folder: '', segmentMinutes: 60 } }
|
return {
|
||||||
|
streams: [],
|
||||||
|
tolbek: { enabled: false, port: 5000, latency: 200 },
|
||||||
|
recording: { folder: '', segmentMinutes: 60 },
|
||||||
|
remote: { enabled: false, server: '', port: '', token: require('./remote').RemoteClient.generateToken(), machineName: '' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveConfig(config) {
|
function saveConfig(config) {
|
||||||
@@ -336,6 +342,29 @@ ipcMain.handle('open-folder', async (_, folderPath) => {
|
|||||||
try { await shell.openPath(folderPath) } catch {}
|
try { await shell.openPath(folderPath) } catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Remote control IPC ───────────────────────────────────────────────────────
|
||||||
|
ipcMain.handle('remote-connect', async (_, { server, port, token, machineName }) => {
|
||||||
|
try {
|
||||||
|
remoteClient.connect({ server, port, token, machineName })
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('remote-disconnect', () => {
|
||||||
|
remoteClient.disconnect()
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('remote-get-url', (_, { server, port }) => {
|
||||||
|
return require('./remote').buildWsUrl(server, port)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('generate-token', () => {
|
||||||
|
return require('./remote').RemoteClient.generateToken()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('minimize-window', () => {
|
ipcMain.handle('minimize-window', () => {
|
||||||
mainWindow && mainWindow.minimize()
|
mainWindow && mainWindow.minimize()
|
||||||
})
|
})
|
||||||
@@ -367,6 +396,53 @@ ffmpegManager.on('reconnecting', (data) => {
|
|||||||
mainWindow && mainWindow.webContents.send('stream-reconnecting', data)
|
mainWindow && mainWindow.webContents.send('stream-reconnecting', data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Remote client events ─────────────────────────────────────────────────────
|
||||||
|
remoteClient.on('status', (data) => {
|
||||||
|
mainWindow && mainWindow.webContents.send('remote-status', data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server requested device list → fetch and send back
|
||||||
|
remoteClient.on('request_devices', async () => {
|
||||||
|
try {
|
||||||
|
const devices = await deviceManager.getDevices()
|
||||||
|
remoteClient.sendDevices(devices)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server sent a command → execute locally
|
||||||
|
remoteClient.on('command', async ({ action, config, id, changes }) => {
|
||||||
|
try {
|
||||||
|
if (action === 'start_stream') {
|
||||||
|
// Update UI first
|
||||||
|
mainWindow && mainWindow.webContents.send('remote-command', { action: 'start_stream', config })
|
||||||
|
const result = await ffmpegManager.startStream(config)
|
||||||
|
updateTrayMenu(ffmpegManager.getActiveCount())
|
||||||
|
remoteClient.sendStreamStatus(config.id, 'running')
|
||||||
|
} else if (action === 'stop_stream') {
|
||||||
|
ffmpegManager.stopStream(id)
|
||||||
|
updateTrayMenu(ffmpegManager.getActiveCount())
|
||||||
|
remoteClient.sendStreamStatus(id, 'idle')
|
||||||
|
mainWindow && mainWindow.webContents.send('remote-command', { action: 'stop_stream', id })
|
||||||
|
} else if (action === 'update_stream') {
|
||||||
|
mainWindow && mainWindow.webContents.send('remote-command', { action: 'update_stream', id, changes })
|
||||||
|
} else if (action === 'stop_all') {
|
||||||
|
ffmpegManager.stopAllStreams()
|
||||||
|
updateTrayMenu(0)
|
||||||
|
mainWindow && mainWindow.webContents.send('all-streams-stopped')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Remote] Command error:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Forward stream events to remote server
|
||||||
|
ffmpegManager.on('ended', (data) => {
|
||||||
|
remoteClient.sendStreamStatus(data.id, 'idle')
|
||||||
|
})
|
||||||
|
ffmpegManager.on('error', (data) => {
|
||||||
|
remoteClient.sendStreamStatus(data.id, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow()
|
createWindow()
|
||||||
createTray()
|
createTray()
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
||||||
openFolder: (p) => ipcRenderer.invoke('open-folder', p),
|
openFolder: (p) => ipcRenderer.invoke('open-folder', p),
|
||||||
|
|
||||||
|
// Remote control
|
||||||
|
remoteConnect: (cfg) => ipcRenderer.invoke('remote-connect', cfg),
|
||||||
|
remoteDisconnect: () => ipcRenderer.invoke('remote-disconnect'),
|
||||||
|
remoteGetUrl: (cfg) => ipcRenderer.invoke('remote-get-url', cfg),
|
||||||
|
generateToken: () => ipcRenderer.invoke('generate-token'),
|
||||||
|
|
||||||
// Window controls
|
// Window controls
|
||||||
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
|
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
|
||||||
hideWindow: () => ipcRenderer.invoke('hide-window'),
|
hideWindow: () => ipcRenderer.invoke('hide-window'),
|
||||||
@@ -34,6 +40,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onStreamEnded: (cb) => ipcRenderer.on('stream-ended', (_, d) => cb(d)),
|
onStreamEnded: (cb) => ipcRenderer.on('stream-ended', (_, d) => cb(d)),
|
||||||
onStreamReconnecting: (cb) => ipcRenderer.on('stream-reconnecting', (_, d) => cb(d)),
|
onStreamReconnecting: (cb) => ipcRenderer.on('stream-reconnecting', (_, d) => cb(d)),
|
||||||
onAllStreamsStopped: (cb) => ipcRenderer.on('all-streams-stopped', () => cb()),
|
onAllStreamsStopped: (cb) => ipcRenderer.on('all-streams-stopped', () => cb()),
|
||||||
|
onRemoteStatus: (cb) => ipcRenderer.on('remote-status', (_, d) => cb(d)),
|
||||||
|
onRemoteCommand: (cb) => ipcRenderer.on('remote-command', (_, d) => cb(d)),
|
||||||
|
|
||||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
|
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
'use strict'
|
||||||
|
const { EventEmitter } = require('events')
|
||||||
|
const { randomUUID } = require('crypto')
|
||||||
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
// ─── URL builder ──────────────────────────────────────────────────────────────
|
||||||
|
// Accepts any of:
|
||||||
|
// srt.queo.ru → wss://srt.queo.ru:443
|
||||||
|
// srt.queo.ru:8080 → ws://srt.queo.ru:8080 (non-443 = assume no SSL)
|
||||||
|
// 192.168.1.5 → ws://192.168.1.5:443
|
||||||
|
// 192.168.1.5:3000 → ws://192.168.1.5:3000
|
||||||
|
// https://srt.queo.ru → wss://srt.queo.ru:443
|
||||||
|
// http://pi.local:3000 → ws://pi.local:3000
|
||||||
|
function buildWsUrl(server, port) {
|
||||||
|
let s = (server || '').trim()
|
||||||
|
|
||||||
|
// Already has explicit ws:// or wss:// — use as-is (port ignored)
|
||||||
|
if (/^wss?:\/\//i.test(s)) return s
|
||||||
|
|
||||||
|
// Strip http(s):// prefix, remember the scheme
|
||||||
|
let forceSecure = null
|
||||||
|
if (/^https:\/\//i.test(s)) { s = s.replace(/^https:\/\//i, ''); forceSecure = true }
|
||||||
|
else if (/^http:\/\//i.test(s)) { s = s.replace(/^http:\/\//i, ''); forceSecure = false }
|
||||||
|
|
||||||
|
// Parse host and optional inline port
|
||||||
|
let host = s
|
||||||
|
let resolvedPort = parseInt(port) || 443
|
||||||
|
|
||||||
|
const colonIdx = s.lastIndexOf(':')
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const maybePort = parseInt(s.slice(colonIdx + 1))
|
||||||
|
if (maybePort > 0 && maybePort < 65536) {
|
||||||
|
host = s.slice(0, colonIdx)
|
||||||
|
resolvedPort = maybePort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose protocol: wss for port 443 or explicit https, ws otherwise
|
||||||
|
const secure = forceSecure !== null ? forceSecure : resolvedPort === 443
|
||||||
|
const proto = secure ? 'wss' : 'ws'
|
||||||
|
|
||||||
|
return `${proto}://${host}:${resolvedPort}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RemoteClient ─────────────────────────────────────────────────────────────
|
||||||
|
class RemoteClient extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.ws = null
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this._manualClose = false
|
||||||
|
this._retryTimer = null
|
||||||
|
this._retryCount = 0
|
||||||
|
this._pingInterval = null
|
||||||
|
|
||||||
|
this.serverUrl = null
|
||||||
|
this.token = null
|
||||||
|
this.machineName = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connect ────────────────────────────────────────────────────────────────
|
||||||
|
connect({ server, port, token, machineName }) {
|
||||||
|
this._manualClose = false
|
||||||
|
this.token = token
|
||||||
|
this.machineName = machineName || require('os').hostname()
|
||||||
|
this.serverUrl = buildWsUrl(server, port)
|
||||||
|
|
||||||
|
console.log('[Remote] Connecting to', this.serverUrl)
|
||||||
|
this._doConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
_doConnect() {
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.terminate() } catch {}
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connecting = true
|
||||||
|
this.emit('status', { status: 'connecting', url: this.serverUrl })
|
||||||
|
|
||||||
|
const ws = new WebSocket(this.serverUrl, {
|
||||||
|
handshakeTimeout: 10000,
|
||||||
|
// Allow self-signed certs on local network
|
||||||
|
rejectUnauthorized: !this.serverUrl.includes('192.168.') &&
|
||||||
|
!this.serverUrl.includes('10.') &&
|
||||||
|
!this.serverUrl.includes('172.') &&
|
||||||
|
!this.serverUrl.includes('localhost') &&
|
||||||
|
!this.serverUrl.includes('.local')
|
||||||
|
})
|
||||||
|
this.ws = ws
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
this.connected = false // becomes true after auth_ok
|
||||||
|
this.connecting = false
|
||||||
|
this._retryCount = 0
|
||||||
|
|
||||||
|
// Send auth immediately
|
||||||
|
this._send({ type: 'auth', token: this.token, machineName: this.machineName, version: '2.0.0' })
|
||||||
|
|
||||||
|
// Keepalive ping every 25s
|
||||||
|
this._pingInterval = setInterval(() => this._send({ type: 'ping' }), 25000)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw)
|
||||||
|
this._handleMessage(msg)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Remote] Invalid message:', raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', (code, reason) => {
|
||||||
|
this._cleanup()
|
||||||
|
if (!this._manualClose) {
|
||||||
|
const delay = Math.min(3000 * Math.pow(1.5, this._retryCount), 60000)
|
||||||
|
this._retryCount++
|
||||||
|
console.log(`[Remote] Disconnected (${code}), retrying in ${(delay/1000).toFixed(1)}s`)
|
||||||
|
this.emit('status', { status: 'disconnected', retryIn: delay })
|
||||||
|
this._retryTimer = setTimeout(() => this._doConnect(), delay)
|
||||||
|
} else {
|
||||||
|
this.emit('status', { status: 'disconnected' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
console.error('[Remote] WS error:', err.message)
|
||||||
|
this.emit('status', { status: 'error', error: err.message })
|
||||||
|
// 'close' fires after 'error', so reconnect is handled there
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this._manualClose = true
|
||||||
|
this._cleanup()
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.terminate() } catch {}
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
this.emit('status', { status: 'disconnected' })
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
clearInterval(this._pingInterval)
|
||||||
|
clearTimeout(this._retryTimer)
|
||||||
|
this._pingInterval = null
|
||||||
|
this._retryTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Incoming messages ──────────────────────────────────────────────────────
|
||||||
|
_handleMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'auth_ok':
|
||||||
|
this.connected = true
|
||||||
|
console.log('[Remote] Authenticated, server name:', msg.serverName)
|
||||||
|
this.emit('status', { status: 'connected', serverName: msg.serverName })
|
||||||
|
// Server may request initial state right away
|
||||||
|
this.emit('request_devices')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'auth_fail':
|
||||||
|
console.error('[Remote] Auth failed:', msg.reason)
|
||||||
|
this.emit('status', { status: 'auth_failed', reason: msg.reason })
|
||||||
|
this.disconnect()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'get_devices':
|
||||||
|
this.emit('request_devices')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'start_stream':
|
||||||
|
this.emit('command', { action: 'start_stream', config: msg.config })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stop_stream':
|
||||||
|
this.emit('command', { action: 'stop_stream', id: msg.id })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'update_stream':
|
||||||
|
this.emit('command', { action: 'update_stream', id: msg.id, changes: msg.changes })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stop_all':
|
||||||
|
this.emit('command', { action: 'stop_all' })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
break // keepalive response
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('[Remote] Unknown message type:', msg.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outgoing messages ──────────────────────────────────────────────────────
|
||||||
|
_send(obj) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
try { this.ws.send(JSON.stringify(obj)) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDevices(devices) {
|
||||||
|
this._send({ type: 'devices', devices })
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStreamStatus(id, status) {
|
||||||
|
this._send({ type: 'stream_status', id, status })
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAllStatus(streams) {
|
||||||
|
this._send({ type: 'all_status', streams })
|
||||||
|
}
|
||||||
|
|
||||||
|
sendLog(id, text) {
|
||||||
|
// Throttle logs — send only every ~500ms or on errors
|
||||||
|
this._send({ type: 'log', id, text })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Static helper ──────────────────────────────────────────────────────────
|
||||||
|
static generateToken() {
|
||||||
|
return randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
static buildWsUrl(server, port) {
|
||||||
|
return buildWsUrl(server, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new RemoteClient()
|
||||||
|
module.exports.RemoteClient = RemoteClient
|
||||||
|
module.exports.buildWsUrl = buildWsUrl
|
||||||
Generated
+25
-3
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "srt-streamer",
|
"name": "srt-streamer",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "srt-streamer",
|
"name": "srt-streamer",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fluent-ffmpeg": "^2.1.3"
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -6026,6 +6027,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"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/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "srt-streamer",
|
"name": "srt-streamer",
|
||||||
"version": "1.2.0",
|
"version": "2.0.0",
|
||||||
"description": "Cross-platform SRT multi-stream sender with system tray",
|
"description": "Cross-platform SRT multi-stream sender with system tray",
|
||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fluent-ffmpeg": "^2.1.3"
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
+12
-1
@@ -3,6 +3,7 @@ import StreamCard from './components/StreamCard.jsx'
|
|||||||
import TolbekSettings from './components/TolbekSettings.jsx'
|
import TolbekSettings from './components/TolbekSettings.jsx'
|
||||||
import LogPanel from './components/LogPanel.jsx'
|
import LogPanel from './components/LogPanel.jsx'
|
||||||
import RecordingBar from './components/RecordingBar.jsx'
|
import RecordingBar from './components/RecordingBar.jsx'
|
||||||
|
import RemoteSettings from './components/RemoteSettings.jsx'
|
||||||
|
|
||||||
const ipc = window.electronAPI || null
|
const ipc = window.electronAPI || null
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export default function App() {
|
|||||||
const [devicesLoaded, setDevicesLoaded] = useState(false)
|
const [devicesLoaded, setDevicesLoaded] = useState(false)
|
||||||
// Global recording settings
|
// Global recording settings
|
||||||
const [recording, setRecording] = useState({ folder: '', segmentMinutes: 60 })
|
const [recording, setRecording] = useState({ folder: '', segmentMinutes: 60 })
|
||||||
|
const [remote, setRemote] = useState({ enabled: false, server: '', port: '', token: '', machineName: '' })
|
||||||
|
|
||||||
// Load config and devices on mount
|
// Load config and devices on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +47,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (config.tolbek) setTolbek(config.tolbek)
|
if (config.tolbek) setTolbek(config.tolbek)
|
||||||
if (config.recording) setRecording(config.recording)
|
if (config.recording) setRecording(config.recording)
|
||||||
|
if (config.remote) setRemote(config.remote)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipc.getDevices().then(devs => { setDevices(devs); setDevicesLoaded(true) })
|
ipc.getDevices().then(devs => { setDevices(devs); setDevicesLoaded(true) })
|
||||||
@@ -104,7 +107,8 @@ export default function App() {
|
|||||||
const saveData = {
|
const saveData = {
|
||||||
streams: streams.map(s => { const { status, ...rest } = s; return rest }),
|
streams: streams.map(s => { const { status, ...rest } = s; return rest }),
|
||||||
tolbek,
|
tolbek,
|
||||||
recording
|
recording,
|
||||||
|
remote
|
||||||
}
|
}
|
||||||
ipc.saveConfig(saveData)
|
ipc.saveConfig(saveData)
|
||||||
ipc.updateTrayCount(streams.filter(s => s.status === 'running').length)
|
ipc.updateTrayCount(streams.filter(s => s.status === 'running').length)
|
||||||
@@ -190,6 +194,9 @@ export default function App() {
|
|||||||
<button className={`tab ${activeTab === 'tolbek' ? 'active' : ''}`} onClick={() => setActiveTab('tolbek')}>
|
<button className={`tab ${activeTab === 'tolbek' ? 'active' : ''}`} onClick={() => setActiveTab('tolbek')}>
|
||||||
Tolbek (Receiver) {tolbek.running && <span className="tab-badge tab-badge--green">●</span>}
|
Tolbek (Receiver) {tolbek.running && <span className="tab-badge tab-badge--green">●</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<button className={`tab ${activeTab === 'remote' ? 'active' : ''}`} onClick={() => setActiveTab('remote')}>
|
||||||
|
Remote {remote.enabled && <span className={`tab-badge ${remote.enabled ? 'tab-badge--green' : ''}`}>●</span>}
|
||||||
|
</button>
|
||||||
<button className={`tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>
|
<button className={`tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>
|
||||||
Logs {hasErrors && <span className="tab-badge tab-badge--red">!</span>}
|
Logs {hasErrors && <span className="tab-badge tab-badge--red">!</span>}
|
||||||
</button>
|
</button>
|
||||||
@@ -243,6 +250,10 @@ export default function App() {
|
|||||||
<TolbekSettings config={tolbek} onChange={setTolbek} ipc={ipc} />
|
<TolbekSettings config={tolbek} onChange={setTolbek} ipc={ipc} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'remote' && (
|
||||||
|
<RemoteSettings config={remote} onChange={setRemote} ipc={ipc} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
<LogPanel logs={logs} streams={streams} onClear={() => setLogs([])} />
|
<LogPanel logs={logs} streams={streams} onClear={() => setLogs([])} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
disconnected: 'Не подключено',
|
||||||
|
connecting: 'Подключение…',
|
||||||
|
connected: 'Подключено',
|
||||||
|
error: 'Ошибка',
|
||||||
|
auth_failed: 'Ошибка авторизации',
|
||||||
|
}
|
||||||
|
const STATUS_CLASS = {
|
||||||
|
disconnected: 'remote-status--off',
|
||||||
|
connecting: 'remote-status--connecting',
|
||||||
|
connected: 'remote-status--on',
|
||||||
|
error: 'remote-status--error',
|
||||||
|
auth_failed: 'remote-status--error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RemoteSettings({ config, onChange, ipc }) {
|
||||||
|
const [status, setStatus] = useState('disconnected')
|
||||||
|
const [statusInfo, setStatusInfo] = useState(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const debounceRef = useRef(null)
|
||||||
|
|
||||||
|
const { server = '', port = '', token = '', machineName = '', enabled = false } = config
|
||||||
|
|
||||||
|
// Subscribe to remote status events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ipc) return
|
||||||
|
ipc.onRemoteStatus(({ status: s, ...rest }) => {
|
||||||
|
setStatus(s)
|
||||||
|
setStatusInfo(rest)
|
||||||
|
})
|
||||||
|
return () => ipc.removeAllListeners('remote-status')
|
||||||
|
}, [ipc])
|
||||||
|
|
||||||
|
// Build URL preview when server/port changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ipc || !server) { setPreviewUrl(''); return }
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
const url = await ipc.remoteGetUrl({ server, port })
|
||||||
|
setPreviewUrl(url)
|
||||||
|
}, 400)
|
||||||
|
}, [server, port, ipc])
|
||||||
|
|
||||||
|
// Auto-connect on mount if enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ipc || !enabled || !server || !token) return
|
||||||
|
handleConnect()
|
||||||
|
}, []) // eslint-disable-line
|
||||||
|
|
||||||
|
async function handleConnect() {
|
||||||
|
if (!ipc || !server || !token) return
|
||||||
|
await ipc.remoteConnect({ server, port, token, machineName })
|
||||||
|
onChange({ ...config, enabled: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisconnect() {
|
||||||
|
if (!ipc) return
|
||||||
|
await ipc.remoteDisconnect()
|
||||||
|
onChange({ ...config, enabled: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateToken() {
|
||||||
|
if (!ipc) return
|
||||||
|
const newToken = await ipc.generateToken()
|
||||||
|
onChange({ ...config, token: newToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyToken() {
|
||||||
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConnected = status === 'connected'
|
||||||
|
const isConnecting = status === 'connecting'
|
||||||
|
const canConnect = server && token && !isConnected && !isConnecting
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="remote-settings">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2 className="section-title">🌐 Удалённое управление</h2>
|
||||||
|
<p className="section-desc">
|
||||||
|
Подключите приложение к центральному серверу для управления через веб-интерфейс.
|
||||||
|
Приложение само устанавливает соединение — NAT и проброс портов не нужны.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection status */}
|
||||||
|
<div className={`remote-status-bar ${STATUS_CLASS[status] || 'remote-status--off'}`}>
|
||||||
|
<span className="remote-status-dot" />
|
||||||
|
<span className="remote-status-label">{STATUS_LABEL[status] || status}</span>
|
||||||
|
{isConnected && statusInfo?.serverName && (
|
||||||
|
<span className="remote-status-server">→ {statusInfo.serverName}</span>
|
||||||
|
)}
|
||||||
|
{status === 'disconnected' && statusInfo?.retryIn && (
|
||||||
|
<span className="remote-status-retry">
|
||||||
|
повтор через {(statusInfo.retryIn / 1000).toFixed(0)}с
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status === 'auth_failed' && statusInfo?.reason && (
|
||||||
|
<span className="remote-status-retry">{statusInfo.reason}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
{/* Server */}
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="group-title">Сервер</div>
|
||||||
|
|
||||||
|
<div className="fields-row">
|
||||||
|
<div className="field field--grow">
|
||||||
|
<label className="field-label">Адрес сервера</label>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
type="text"
|
||||||
|
value={server}
|
||||||
|
onChange={e => onChange({ ...config, server: e.target.value })}
|
||||||
|
placeholder="srt.queo.ru или 192.168.1.100"
|
||||||
|
disabled={isConnected || isConnecting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ width: 90 }}>
|
||||||
|
<label className="field-label">Порт</label>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
type="number"
|
||||||
|
min="1" max="65535"
|
||||||
|
value={port}
|
||||||
|
onChange={e => onChange({ ...config, port: e.target.value })}
|
||||||
|
placeholder="443"
|
||||||
|
disabled={isConnected || isConnecting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewUrl && (
|
||||||
|
<div className="srt-preview">
|
||||||
|
<span className="srt-preview-label">WebSocket URL:</span>
|
||||||
|
<code className="srt-url">{previewUrl}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="group-title">Токен авторизации</div>
|
||||||
|
<p className="info-text">
|
||||||
|
Введите этот токен в веб-интерфейсе при добавлении нового агента.
|
||||||
|
</p>
|
||||||
|
<div className="remote-token-row">
|
||||||
|
<code className="remote-token">{token || '—'}</code>
|
||||||
|
<button
|
||||||
|
className="btn btn--small"
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
disabled={!token}
|
||||||
|
title="Скопировать токен"
|
||||||
|
>
|
||||||
|
{copied ? '✓ Скопировано' : '📋 Копировать'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn--small btn--danger-outline"
|
||||||
|
onClick={handleGenerateToken}
|
||||||
|
disabled={isConnected}
|
||||||
|
title="Сгенерировать новый токен (потребуется повторная авторизация на сервере)"
|
||||||
|
>
|
||||||
|
↺ Новый
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Machine name */}
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="group-title">Имя машины</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Отображается в веб-интерфейсе</label>
|
||||||
|
<input
|
||||||
|
className="field-input"
|
||||||
|
type="text"
|
||||||
|
value={machineName}
|
||||||
|
onChange={e => onChange({ ...config, machineName: e.target.value })}
|
||||||
|
placeholder="Студия / Офис / Выездной"
|
||||||
|
disabled={isConnected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="remote-actions">
|
||||||
|
{!isConnected ? (
|
||||||
|
<button
|
||||||
|
className="btn btn--start"
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={!canConnect}
|
||||||
|
>
|
||||||
|
🔗 Подключиться
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn--stop" onClick={handleDisconnect}>
|
||||||
|
✕ Отключиться
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="group-title">Как это работает</div>
|
||||||
|
<div className="tolbek-modes-info">
|
||||||
|
<div className="mode-row">
|
||||||
|
<span className="mode-tag">Агент</span>
|
||||||
|
<span className="mode-desc">
|
||||||
|
SRT Streamer подключается к серверу и остаётся доступным для управления.
|
||||||
|
Работает через NAT без проброса портов.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mode-row">
|
||||||
|
<span className="mode-tag">Сервер</span>
|
||||||
|
<span className="mode-desc">
|
||||||
|
Веб-приложение на <em>{server || 'вашем домене'}</em> — показывает все подключённые
|
||||||
|
агенты, их устройства, позволяет запускать и останавливать потоки.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mode-row">
|
||||||
|
<span className="mode-tag">Локально</span>
|
||||||
|
<span className="mode-desc">
|
||||||
|
Без подключения приложение работает в обычном режиме —
|
||||||
|
удалённое управление опционально.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -921,3 +921,92 @@ option:disabled { color: var(--text-muted); }
|
|||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== REMOTE SETTINGS ========== */
|
||||||
|
.remote-settings {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status bar */
|
||||||
|
.remote-status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-section);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-status--off .remote-status-dot { background: var(--text-muted); }
|
||||||
|
.remote-status--on .remote-status-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.remote-status--connecting .remote-status-dot {
|
||||||
|
background: var(--orange);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
.remote-status--error .remote-status-dot { background: var(--red); }
|
||||||
|
|
||||||
|
.remote-status--on { border-color: var(--green); background: var(--green-dim); }
|
||||||
|
.remote-status--error { border-color: var(--red); background: var(--red-dim); }
|
||||||
|
.remote-status--connecting { border-color: var(--orange); }
|
||||||
|
|
||||||
|
.remote-status-label { font-weight: 600; }
|
||||||
|
.remote-status-server { color: var(--text-muted); font-size: 11.5px; }
|
||||||
|
.remote-status-retry { color: var(--text-muted); font-size: 11px; margin-left: auto; }
|
||||||
|
|
||||||
|
/* Token row */
|
||||||
|
.remote-token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-token {
|
||||||
|
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-code);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.btn--danger-outline:hover:not(:disabled) {
|
||||||
|
background: var(--red-dim);
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remote actions */
|
||||||
|
.remote-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user