main funcions fixes
This commit is contained in:
156
desktop-operator/src/config.js
Normal file
156
desktop-operator/src/config.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { app } = require('electron');
|
||||
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.configPath = path.join(app.getPath('userData'), 'config.json');
|
||||
this.config = this.loadConfig();
|
||||
}
|
||||
|
||||
loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
const data = fs.readFileSync(this.configPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки конфигурации:', error);
|
||||
}
|
||||
|
||||
// Конфигурация по умолчанию
|
||||
return {
|
||||
server: {
|
||||
url: 'http://localhost:3001',
|
||||
autoConnect: false,
|
||||
reconnectAttempts: 3,
|
||||
reconnectInterval: 5000
|
||||
},
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
language: 'ru',
|
||||
windowBounds: {
|
||||
width: 1400,
|
||||
height: 900,
|
||||
x: null,
|
||||
y: null
|
||||
},
|
||||
videoFilters: {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
monochrome: false,
|
||||
edgeDetection: false
|
||||
}
|
||||
},
|
||||
recording: {
|
||||
quality: 'high',
|
||||
format: 'webm',
|
||||
maxDuration: 3600000, // 1 час в миллисекундах
|
||||
autoSave: true,
|
||||
saveLocation: path.join(app.getPath('videos'), 'GodEye')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
saveConfig() {
|
||||
try {
|
||||
// Создаем директорию если её нет
|
||||
const dir = path.dirname(this.configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error('Ошибка сохранения конфигурации:', error);
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
if (!key || typeof key !== 'string') {
|
||||
return this.config; // Возвращаем всю конфигурацию если ключ пустой
|
||||
}
|
||||
|
||||
// Если передана пустая строка, возвращаем всю конфигурацию
|
||||
if (key.trim() === '') {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
const keys = key.split('.');
|
||||
let value = this.config;
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const keys = key.split('.');
|
||||
let obj = this.config;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const k = keys[i];
|
||||
if (!(k in obj) || typeof obj[k] !== 'object') {
|
||||
obj[k] = {};
|
||||
}
|
||||
obj = obj[k];
|
||||
}
|
||||
|
||||
obj[keys[keys.length - 1]] = value;
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
// Быстрые методы для часто используемых настроек
|
||||
getServerUrl() {
|
||||
return this.get('server.url');
|
||||
}
|
||||
|
||||
setServerUrl(url) {
|
||||
this.set('server.url', url);
|
||||
}
|
||||
|
||||
getAutoConnect() {
|
||||
return this.get('server.autoConnect');
|
||||
}
|
||||
|
||||
setAutoConnect(autoConnect) {
|
||||
this.set('server.autoConnect', autoConnect);
|
||||
}
|
||||
|
||||
getWindowBounds() {
|
||||
return this.get('ui.windowBounds');
|
||||
}
|
||||
|
||||
setWindowBounds(bounds) {
|
||||
this.set('ui.windowBounds', bounds);
|
||||
}
|
||||
|
||||
getVideoFilters() {
|
||||
return this.get('ui.videoFilters');
|
||||
}
|
||||
|
||||
setVideoFilters(filters) {
|
||||
this.set('ui.videoFilters', filters);
|
||||
}
|
||||
|
||||
getRecordingSettings() {
|
||||
return this.get('recording');
|
||||
}
|
||||
|
||||
ensureRecordingDirectory() {
|
||||
const saveLocation = this.get('recording.saveLocation');
|
||||
if (!fs.existsSync(saveLocation)) {
|
||||
fs.mkdirSync(saveLocation, { recursive: true });
|
||||
}
|
||||
return saveLocation;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConfigManager;
|
||||
110
desktop-operator/src/main.js
Normal file
110
desktop-operator/src/main.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const ConfigManager = require('./config');
|
||||
|
||||
let mainWindow;
|
||||
let config;
|
||||
|
||||
function createWindow() {
|
||||
// Инициализируем конфигурацию
|
||||
config = new ConfigManager();
|
||||
|
||||
// Получаем сохраненные размеры окна
|
||||
const bounds = config.getWindowBounds();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
webSecurity: false // Needed for WebRTC
|
||||
},
|
||||
icon: path.join(__dirname, '../assets/icon.png'),
|
||||
title: 'GodEye Operator Console',
|
||||
show: false,
|
||||
minWidth: 1200,
|
||||
minHeight: 800
|
||||
});
|
||||
|
||||
mainWindow.loadFile('src/renderer/index.html');
|
||||
|
||||
// Show window when ready to prevent visual flash
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
|
||||
// Передаем конфигурацию в renderer процесс
|
||||
mainWindow.webContents.send('config-loaded', {
|
||||
serverUrl: config.getServerUrl(),
|
||||
autoConnect: config.getAutoConnect(),
|
||||
videoFilters: config.getVideoFilters()
|
||||
});
|
||||
});
|
||||
|
||||
// Сохраняем размеры окна при изменении
|
||||
mainWindow.on('resize', saveWindowBounds);
|
||||
mainWindow.on('move', saveWindowBounds);
|
||||
|
||||
// Open DevTools in development
|
||||
if (process.argv.includes('--dev')) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Обработка внешних ссылок
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
function saveWindowBounds() {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const bounds = mainWindow.getBounds();
|
||||
config.setWindowBounds(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
// IPC обработчики
|
||||
ipcMain.handle('get-config', (event, key) => {
|
||||
return config.get(key);
|
||||
});
|
||||
|
||||
ipcMain.handle('set-config', (event, key, value) => {
|
||||
config.set(key, value);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-save-dialog', async (event, options) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-message-box', async (event, options) => {
|
||||
const result = await dialog.showMessageBox(mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// IPC handlers for main process communication
|
||||
ipcMain.handle('app-version', () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
902
desktop-operator/src/renderer/app.js
Normal file
902
desktop-operator/src/renderer/app.js
Normal file
@@ -0,0 +1,902 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('socket.io-client');
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
class GodEyeOperator {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.operatorId = uuidv4();
|
||||
this.currentSession = null;
|
||||
this.localConnection = null;
|
||||
this.remoteStream = null;
|
||||
this.mediaRecorder = null;
|
||||
this.recordedChunks = [];
|
||||
this.isRecording = false;
|
||||
this.recordingStartTime = null;
|
||||
this.recordingInterval = null;
|
||||
this.pingInterval = null;
|
||||
this.isFullscreen = false;
|
||||
this.isZoomed = false;
|
||||
this.config = null;
|
||||
|
||||
// UI Elements
|
||||
this.elements = {
|
||||
connectBtn: document.getElementById('connect-btn'),
|
||||
disconnectBtn: document.getElementById('disconnect-btn'),
|
||||
serverUrl: document.getElementById('server-url'),
|
||||
autoConnect: document.getElementById('auto-connect'),
|
||||
connectionIndicator: document.getElementById('connection-indicator'),
|
||||
connectionStatusText: document.getElementById('connection-status-text'),
|
||||
pingIndicator: document.getElementById('ping-indicator'),
|
||||
sessionInfo: document.getElementById('session-info'),
|
||||
remoteVideo: document.getElementById('remoteVideo'),
|
||||
videoContainer: document.querySelector('.video-container'),
|
||||
videoOverlay: document.getElementById('video-overlay'),
|
||||
devicesList: document.getElementById('devices-list'),
|
||||
sessionsList: document.getElementById('sessions-list'),
|
||||
logsContainer: document.getElementById('logs-container'),
|
||||
clearLogs: document.getElementById('clear-logs'),
|
||||
|
||||
// Camera controls
|
||||
cameraButtons: document.querySelectorAll('.camera-btn'),
|
||||
|
||||
// Video actions
|
||||
screenshotBtn: document.getElementById('screenshot-btn'),
|
||||
fullscreenBtn: document.getElementById('fullscreen-btn'),
|
||||
zoomBtn: document.getElementById('zoom-btn'),
|
||||
pipBtn: document.getElementById('pip-btn'),
|
||||
|
||||
// Image processing
|
||||
brightness: document.getElementById('brightness'),
|
||||
contrast: document.getElementById('contrast'),
|
||||
saturation: document.getElementById('saturation'),
|
||||
monochrome: document.getElementById('monochrome'),
|
||||
edgeDetection: document.getElementById('edge-detection'),
|
||||
resetFilters: document.getElementById('reset-filters'),
|
||||
|
||||
// Recording controls
|
||||
startRecording: document.getElementById('start-recording'),
|
||||
stopRecording: document.getElementById('stop-recording'),
|
||||
playRecording: document.getElementById('play-recording'),
|
||||
recordingStatus: document.getElementById('recording-status'),
|
||||
recordingDuration: document.getElementById('recording-duration')
|
||||
};
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.setupEventListeners();
|
||||
this.updateImageFilters();
|
||||
this.log('Приложение инициализировано', 'info');
|
||||
|
||||
// Загружаем конфигурацию
|
||||
await this.loadConfig();
|
||||
|
||||
// Автоподключение если включено
|
||||
if (this.config && this.config.autoConnect) {
|
||||
setTimeout(() => this.connect(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
try {
|
||||
// Получаем конфигурацию из main процесса
|
||||
ipcRenderer.on('config-loaded', (event, config) => {
|
||||
this.config = config;
|
||||
this.applyConfig();
|
||||
});
|
||||
|
||||
// Запрашиваем полную конфигурацию
|
||||
const fullConfig = await ipcRenderer.invoke('get-config', '');
|
||||
if (fullConfig) {
|
||||
this.config = {
|
||||
serverUrl: await ipcRenderer.invoke('get-config', 'server.url'),
|
||||
autoConnect: await ipcRenderer.invoke('get-config', 'server.autoConnect'),
|
||||
videoFilters: await ipcRenderer.invoke('get-config', 'ui.videoFilters')
|
||||
};
|
||||
this.applyConfig();
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('Ошибка загрузки конфигурации: ' + error.message, 'error');
|
||||
// Применяем настройки по умолчанию
|
||||
this.config = {
|
||||
serverUrl: 'http://localhost:3001',
|
||||
autoConnect: false,
|
||||
videoFilters: {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
monochrome: false,
|
||||
edgeDetection: false
|
||||
}
|
||||
};
|
||||
this.applyConfig();
|
||||
}
|
||||
}
|
||||
|
||||
applyConfig() {
|
||||
if (!this.config) return;
|
||||
|
||||
// Применяем настройки сервера
|
||||
if (this.config.serverUrl) {
|
||||
this.elements.serverUrl.value = this.config.serverUrl;
|
||||
}
|
||||
|
||||
if (this.config.autoConnect !== undefined) {
|
||||
this.elements.autoConnect.checked = this.config.autoConnect;
|
||||
}
|
||||
|
||||
// Применяем видео фильтры
|
||||
if (this.config.videoFilters) {
|
||||
const filters = this.config.videoFilters;
|
||||
this.elements.brightness.value = filters.brightness || 0;
|
||||
this.elements.contrast.value = filters.contrast || 0;
|
||||
this.elements.saturation.value = filters.saturation || 0;
|
||||
this.elements.monochrome.checked = filters.monochrome || false;
|
||||
this.elements.edgeDetection.checked = filters.edgeDetection || false;
|
||||
this.updateImageFilters();
|
||||
}
|
||||
}
|
||||
|
||||
async saveConfig(key, value) {
|
||||
try {
|
||||
await ipcRenderer.invoke('set-config', key, value);
|
||||
} catch (error) {
|
||||
this.log('Ошибка сохранения настройки: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Connection controls
|
||||
this.elements.connectBtn.addEventListener('click', () => this.connect());
|
||||
this.elements.disconnectBtn.addEventListener('click', () => this.disconnect());
|
||||
this.elements.clearLogs.addEventListener('click', () => this.clearLogs());
|
||||
// Refresh devices
|
||||
const refreshBtn = document.getElementById('refresh-devices');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.log('Запрос обновления списка устройств...', 'info');
|
||||
this.requestDevicesList();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-connect setting
|
||||
this.elements.autoConnect.addEventListener('change', (e) => {
|
||||
this.saveConfig('server.autoConnect', e.target.checked);
|
||||
});
|
||||
|
||||
// Server URL changes
|
||||
this.elements.serverUrl.addEventListener('change', (e) => {
|
||||
this.saveConfig('server.url', e.target.value);
|
||||
});
|
||||
|
||||
// Camera controls
|
||||
this.elements.cameraButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const cameraType = btn.dataset.camera;
|
||||
this.switchCamera(cameraType);
|
||||
});
|
||||
});
|
||||
|
||||
// Video action controls
|
||||
this.elements.screenshotBtn.addEventListener('click', () => this.takeScreenshot());
|
||||
this.elements.fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
|
||||
this.elements.zoomBtn.addEventListener('click', () => this.toggleZoom());
|
||||
this.elements.pipBtn.addEventListener('click', () => this.togglePictureInPicture());
|
||||
|
||||
// Image processing controls
|
||||
this.elements.brightness.addEventListener('input', () => this.updateImageFilters());
|
||||
this.elements.contrast.addEventListener('input', () => this.updateImageFilters());
|
||||
this.elements.saturation.addEventListener('input', () => this.updateImageFilters());
|
||||
this.elements.monochrome.addEventListener('change', () => this.updateImageFilters());
|
||||
this.elements.edgeDetection.addEventListener('change', () => this.updateImageFilters());
|
||||
this.elements.resetFilters.addEventListener('click', () => this.resetImageFilters());
|
||||
|
||||
// Recording controls
|
||||
this.elements.startRecording.addEventListener('click', () => this.startRecording());
|
||||
this.elements.stopRecording.addEventListener('click', () => this.stopRecording());
|
||||
this.elements.playRecording.addEventListener('click', () => this.playLastRecording());
|
||||
|
||||
// Update slider values display
|
||||
this.elements.brightness.addEventListener('input', (e) => {
|
||||
document.getElementById('brightness-value').textContent = e.target.value;
|
||||
this.saveVideoFilters();
|
||||
});
|
||||
this.elements.contrast.addEventListener('input', (e) => {
|
||||
document.getElementById('contrast-value').textContent = e.target.value;
|
||||
this.saveVideoFilters();
|
||||
});
|
||||
this.elements.saturation.addEventListener('input', (e) => {
|
||||
document.getElementById('saturation-value').textContent = e.target.value;
|
||||
this.saveVideoFilters();
|
||||
});
|
||||
|
||||
// Save filter changes
|
||||
this.elements.monochrome.addEventListener('change', () => this.saveVideoFilters());
|
||||
this.elements.edgeDetection.addEventListener('change', () => this.saveVideoFilters());
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
this.takeScreenshot();
|
||||
break;
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
this.toggleFullscreen();
|
||||
break;
|
||||
case 'r':
|
||||
e.preventDefault();
|
||||
if (this.isRecording) {
|
||||
this.stopRecording();
|
||||
} else {
|
||||
this.startRecording();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ESC для выхода из полноэкранного режима
|
||||
if (e.key === 'Escape' && this.isFullscreen) {
|
||||
this.toggleFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
// Video drag for zoom mode
|
||||
let isDragging = false;
|
||||
let startX, startY, translateX = 0, translateY = 0;
|
||||
|
||||
this.elements.remoteVideo.addEventListener('mousedown', (e) => {
|
||||
if (this.isZoomed) {
|
||||
isDragging = true;
|
||||
startX = e.clientX - translateX;
|
||||
startY = e.clientY - translateY;
|
||||
this.elements.remoteVideo.style.cursor = 'grabbing';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (isDragging && this.isZoomed) {
|
||||
translateX = e.clientX - startX;
|
||||
translateY = e.clientY - startY;
|
||||
this.elements.remoteVideo.style.transform = `scale(1.5) translate(${translateX/1.5}px, ${translateY/1.5}px)`;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
if (this.isZoomed) {
|
||||
this.elements.remoteVideo.style.cursor = 'move';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveVideoFilters() {
|
||||
const filters = {
|
||||
brightness: parseInt(this.elements.brightness.value),
|
||||
contrast: parseInt(this.elements.contrast.value),
|
||||
saturation: parseInt(this.elements.saturation.value),
|
||||
monochrome: this.elements.monochrome.checked,
|
||||
edgeDetection: this.elements.edgeDetection.checked
|
||||
};
|
||||
this.saveConfig('ui.videoFilters', filters);
|
||||
}
|
||||
|
||||
connect() {
|
||||
const serverUrl = this.elements.serverUrl.value.trim();
|
||||
if (!serverUrl) {
|
||||
this.log('Введите URL сервера', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Подключение к серверу: ${serverUrl}`, 'info');
|
||||
|
||||
try {
|
||||
this.socket = io(serverUrl, {
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
this.setupSocketListeners();
|
||||
|
||||
} catch (error) {
|
||||
this.log(`Ошибка подключения: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
setupSocketListeners() {
|
||||
// Connection events
|
||||
this.socket.on('connect', () => {
|
||||
this.log('Подключен к серверу', 'success');
|
||||
this.updateConnectionStatus(true);
|
||||
this.registerOperator();
|
||||
this.startPingMonitoring();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.log('Отключен от сервера', 'warning');
|
||||
this.updateConnectionStatus(false);
|
||||
this.clearDevicesList();
|
||||
this.clearSessionsList();
|
||||
this.stopPingMonitoring();
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
this.log(`Ошибка соединения: ${error.message}`, 'error');
|
||||
this.updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
// Registration events
|
||||
this.socket.on('register:success', (data) => {
|
||||
this.log(`Регистрация успешна: ${data.operatorId}`, 'success');
|
||||
if (data.availableDevices) {
|
||||
this.updateDevicesList(data.availableDevices);
|
||||
} else {
|
||||
this.requestDevicesList();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('register:error', (error) => {
|
||||
this.log(`Ошибка регистрации: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
// Devices events
|
||||
this.socket.on('devices:list', (data) => {
|
||||
this.updateDevicesList(data.devices);
|
||||
});
|
||||
|
||||
// Camera events
|
||||
this.socket.on('camera:response', (data) => {
|
||||
this.handleCameraResponse(data);
|
||||
});
|
||||
|
||||
this.socket.on('camera:error', (error) => {
|
||||
this.log(`Ошибка камеры: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
// WebRTC events
|
||||
this.socket.on('webrtc:offer', (data) => {
|
||||
this.handleWebRTCOffer(data);
|
||||
});
|
||||
|
||||
this.socket.on('webrtc:answer', (data) => {
|
||||
this.handleWebRTCAnswer(data);
|
||||
});
|
||||
|
||||
this.socket.on('webrtc:ice-candidate', (data) => {
|
||||
this.handleICECandidate(data);
|
||||
});
|
||||
|
||||
this.socket.on('webrtc:error', (error) => {
|
||||
this.log(`Ошибка WebRTC: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
// Ping events
|
||||
this.socket.on('pong', (startTime) => {
|
||||
const ping = Date.now() - startTime;
|
||||
this.elements.pingIndicator.textContent = `Ping: ${ping}ms`;
|
||||
this.elements.pingIndicator.style.color = ping < 100 ? '#4CAF50' : ping < 300 ? '#FF9800' : '#f44336';
|
||||
});
|
||||
}
|
||||
|
||||
registerOperator() {
|
||||
this.socket.emit('register:operator', {
|
||||
operatorId: this.operatorId,
|
||||
capabilities: ['video-stream', 'recording', 'image-processing']
|
||||
});
|
||||
}
|
||||
|
||||
requestDevicesList() {
|
||||
this.socket.emit('devices:list');
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
this.updateConnectionStatus(false);
|
||||
this.log('Отключен от сервера', 'info');
|
||||
}
|
||||
|
||||
updateConnectionStatus(connected) {
|
||||
this.elements.connectBtn.disabled = connected;
|
||||
this.elements.disconnectBtn.disabled = !connected;
|
||||
|
||||
if (connected) {
|
||||
this.elements.connectionIndicator.textContent = '● Подключен';
|
||||
this.elements.connectionIndicator.className = 'status connected';
|
||||
this.elements.connectionStatusText.textContent = 'Подключен к серверу';
|
||||
} else {
|
||||
this.elements.connectionIndicator.textContent = '● Отключен';
|
||||
this.elements.connectionIndicator.className = 'status disconnected';
|
||||
this.elements.connectionStatusText.textContent = 'Не подключен';
|
||||
this.elements.sessionInfo.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateDevicesList(devices) {
|
||||
const container = this.elements.devicesList;
|
||||
this.log('Обновление списка устройств в UI...', 'debug');
|
||||
if (!devices || devices.length === 0) {
|
||||
container.innerHTML = '<div class="no-devices">Нет подключенных устройств</div>';
|
||||
this.log('Нет подключенных устройств', 'warning');
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
devices.forEach(device => {
|
||||
const deviceElement = document.createElement('div');
|
||||
deviceElement.className = 'device-item';
|
||||
deviceElement.innerHTML = `
|
||||
<div class="device-info">
|
||||
<strong>ID:</strong> ${device.deviceId}<br>
|
||||
<strong>Статус:</strong> ${device.isConnected ? 'Онлайн' : 'Офлайн'}
|
||||
</div>
|
||||
<div class="device-capabilities">
|
||||
Камеры: ${(Array.isArray(device.capabilities) ? device.capabilities.join(', ') : (device.capabilities?.cameras?.join(', ') || ''))}
|
||||
</div>
|
||||
<div class="device-actions">
|
||||
<button class="btn-device" onclick="operator.requestCamera('${device.deviceId}', 'back')">
|
||||
Подключиться
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(deviceElement);
|
||||
});
|
||||
this.log(`Список устройств отображён: ${devices.length} устройств`, 'success');
|
||||
}
|
||||
|
||||
clearDevicesList() {
|
||||
this.elements.devicesList.innerHTML = '<div class="no-devices">Нет подключенных устройств</div>';
|
||||
}
|
||||
|
||||
clearSessionsList() {
|
||||
this.elements.sessionsList.innerHTML = '<div class="no-sessions">Нет активных сессий</div>';
|
||||
}
|
||||
|
||||
requestCamera(deviceId, cameraType = 'back') {
|
||||
if (!this.socket || !this.socket.connected) {
|
||||
this.log('Нет подключения к серверу', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Запрос доступа к камере ${cameraType} устройства ${deviceId}`, 'info');
|
||||
|
||||
this.socket.emit('camera:request', {
|
||||
deviceId: deviceId,
|
||||
operatorId: this.operatorId,
|
||||
cameraType: cameraType
|
||||
});
|
||||
}
|
||||
|
||||
handleCameraResponse(data) {
|
||||
if (data.success) {
|
||||
this.currentSession = data.session;
|
||||
this.elements.sessionInfo.textContent = `Сессия: ${data.session.id}`;
|
||||
this.log(`Доступ к камере получен. Сессия: ${data.session.id}`, 'success');
|
||||
this.updateCameraButtons(data.session.cameraType);
|
||||
this.initWebRTC();
|
||||
} else {
|
||||
this.log(`Отказ в доступе к камере: ${data.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
updateCameraButtons(activeCameraType) {
|
||||
this.elements.cameraButtons.forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.camera === activeCameraType) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
switchCamera(cameraType) {
|
||||
if (!this.currentSession) {
|
||||
this.log('Нет активной сессии для переключения камеры', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Переключение на камеру: ${cameraType}`, 'info');
|
||||
|
||||
this.socket.emit('camera:switch', {
|
||||
sessionId: this.currentSession.id,
|
||||
cameraType: cameraType
|
||||
});
|
||||
|
||||
this.updateCameraButtons(cameraType);
|
||||
}
|
||||
|
||||
async initWebRTC() {
|
||||
try {
|
||||
this.localConnection = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' }
|
||||
]
|
||||
});
|
||||
|
||||
// Handle incoming stream
|
||||
this.localConnection.ontrack = (event) => {
|
||||
this.log('Получен видеопоток', 'success');
|
||||
this.remoteStream = event.streams[0];
|
||||
this.elements.remoteVideo.srcObject = this.remoteStream;
|
||||
this.elements.videoOverlay.classList.add('hidden');
|
||||
|
||||
// Enable recording controls when stream is available
|
||||
this.elements.startRecording.disabled = false;
|
||||
};
|
||||
|
||||
// Handle ICE candidates
|
||||
this.localConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.socket.emit('webrtc:ice-candidate', {
|
||||
sessionId: this.currentSession.id,
|
||||
candidate: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Create and send offer
|
||||
const offer = await this.localConnection.createOffer();
|
||||
await this.localConnection.setLocalDescription(offer);
|
||||
|
||||
this.socket.emit('webrtc:offer', {
|
||||
sessionId: this.currentSession.id,
|
||||
offer: offer
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.log(`Ошибка инициализации WebRTC: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebRTCOffer(data) {
|
||||
try {
|
||||
await this.localConnection.setRemoteDescription(data.offer);
|
||||
const answer = await this.localConnection.createAnswer();
|
||||
await this.localConnection.setLocalDescription(answer);
|
||||
|
||||
this.socket.emit('webrtc:answer', {
|
||||
sessionId: data.sessionId,
|
||||
answer: answer
|
||||
});
|
||||
} catch (error) {
|
||||
this.log(`Ошибка обработки WebRTC offer: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebRTCAnswer(data) {
|
||||
try {
|
||||
await this.localConnection.setRemoteDescription(data.answer);
|
||||
} catch (error) {
|
||||
this.log(`Ошибка обработки WebRTC answer: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleICECandidate(data) {
|
||||
try {
|
||||
await this.localConnection.addIceCandidate(data.candidate);
|
||||
} catch (error) {
|
||||
this.log(`Ошибка добавления ICE candidate: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Image Processing Methods
|
||||
updateImageFilters() {
|
||||
const brightness = this.elements.brightness.value;
|
||||
const contrast = this.elements.contrast.value;
|
||||
const saturation = this.elements.saturation.value;
|
||||
const monochrome = this.elements.monochrome.checked;
|
||||
const edgeDetection = this.elements.edgeDetection.checked;
|
||||
|
||||
let filters = [];
|
||||
|
||||
if (brightness !== '0') {
|
||||
filters.push(`brightness(${(100 + parseInt(brightness))}%)`);
|
||||
}
|
||||
|
||||
if (contrast !== '0') {
|
||||
filters.push(`contrast(${(100 + parseInt(contrast))}%)`);
|
||||
}
|
||||
|
||||
if (saturation !== '0') {
|
||||
filters.push(`saturate(${(100 + parseInt(saturation))}%)`);
|
||||
}
|
||||
|
||||
if (monochrome) {
|
||||
filters.push('grayscale(100%)');
|
||||
}
|
||||
|
||||
if (edgeDetection) {
|
||||
// Простая имитация выделения краев через высокий контраст
|
||||
filters.push('contrast(200%) brightness(50%)');
|
||||
}
|
||||
|
||||
this.elements.remoteVideo.style.filter = filters.join(' ');
|
||||
}
|
||||
|
||||
resetImageFilters() {
|
||||
this.elements.brightness.value = 0;
|
||||
this.elements.contrast.value = 0;
|
||||
this.elements.saturation.value = 0;
|
||||
this.elements.monochrome.checked = false;
|
||||
this.elements.edgeDetection.checked = false;
|
||||
|
||||
// Update value displays
|
||||
document.getElementById('brightness-value').textContent = '0';
|
||||
document.getElementById('contrast-value').textContent = '0';
|
||||
document.getElementById('saturation-value').textContent = '0';
|
||||
|
||||
this.updateImageFilters();
|
||||
this.log('Фильтры изображения сброшены', 'info');
|
||||
}
|
||||
|
||||
// Recording Methods
|
||||
startRecording() {
|
||||
if (!this.remoteStream) {
|
||||
this.log('Нет видеопотока для записи', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.recordedChunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(this.remoteStream, {
|
||||
mimeType: 'video/webm;codecs=vp9'
|
||||
});
|
||||
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.recordedChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.onstop = () => {
|
||||
this.log('Запись остановлена', 'info');
|
||||
this.elements.playRecording.disabled = false;
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(1000); // Collect data every second
|
||||
this.isRecording = true;
|
||||
this.recordingStartTime = Date.now();
|
||||
|
||||
// Update UI
|
||||
this.elements.startRecording.disabled = true;
|
||||
this.elements.stopRecording.disabled = false;
|
||||
this.elements.recordingStatus.textContent = 'Запись...';
|
||||
|
||||
// Start duration counter
|
||||
this.recordingInterval = setInterval(() => {
|
||||
if (this.isRecording) {
|
||||
const duration = Date.now() - this.recordingStartTime;
|
||||
this.elements.recordingDuration.textContent = this.formatDuration(duration);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.log('Запись начата', 'success');
|
||||
|
||||
} catch (error) {
|
||||
this.log(`Ошибка начала записи: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
stopRecording() {
|
||||
if (this.mediaRecorder && this.isRecording) {
|
||||
this.mediaRecorder.stop();
|
||||
this.isRecording = false;
|
||||
|
||||
// Clear interval
|
||||
if (this.recordingInterval) {
|
||||
clearInterval(this.recordingInterval);
|
||||
this.recordingInterval = null;
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.elements.startRecording.disabled = false;
|
||||
this.elements.stopRecording.disabled = true;
|
||||
this.elements.recordingStatus.textContent = 'Готов к записи';
|
||||
|
||||
this.log('Запись завершена', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
playLastRecording() {
|
||||
if (this.recordedChunks.length === 0) {
|
||||
this.log('Нет записанного видео для воспроизведения', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Create a new window to play the recording
|
||||
const recordingWindow = window.open('', '_blank', 'width=800,height=600');
|
||||
recordingWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>GodEye - Воспроизведение записи</title>
|
||||
<style>
|
||||
body { background: #000; margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
video { max-width: 100%; max-height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<video controls autoplay>
|
||||
<source src="${url}" type="video/webm">
|
||||
Ваш браузер не поддерживает воспроизведение видео.
|
||||
</video>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
this.log('Воспроизведение записи в новом окне', 'info');
|
||||
}
|
||||
|
||||
formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Logging
|
||||
log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry ${type}`;
|
||||
logEntry.innerHTML = `<span class="log-timestamp">[${timestamp}]</span>${message}`;
|
||||
|
||||
this.elements.logsContainer.appendChild(logEntry);
|
||||
this.elements.logsContainer.scrollTop = this.elements.logsContainer.scrollHeight;
|
||||
|
||||
// Keep only last 100 entries
|
||||
const entries = this.elements.logsContainer.children;
|
||||
if (entries.length > 100) {
|
||||
entries[0].remove();
|
||||
}
|
||||
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
this.elements.logsContainer.innerHTML = '';
|
||||
this.log('Журнал событий очищен', 'info');
|
||||
}
|
||||
|
||||
// New video action methods
|
||||
async takeScreenshot() {
|
||||
if (!this.remoteStream) {
|
||||
this.log('Нет активного видео потока для скриншота', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const video = this.elements.remoteVideo;
|
||||
|
||||
canvas.width = video.videoWidth || video.offsetWidth;
|
||||
canvas.height = video.videoHeight || video.offsetHeight;
|
||||
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Save screenshot
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `godeye-screenshot-${timestamp}.png`;
|
||||
|
||||
const saveResult = await ipcRenderer.invoke('show-save-dialog', {
|
||||
title: 'Сохранить скриншот',
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{ name: 'PNG Images', extensions: ['png'] },
|
||||
{ name: 'JPEG Images', extensions: ['jpg', 'jpeg'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!saveResult.canceled) {
|
||||
// Convert dataURL to blob and save
|
||||
const base64Data = dataURL.replace(/^data:image\/png;base64,/, '');
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync(saveResult.filePath, base64Data, 'base64');
|
||||
this.log(`Скриншот сохранен: ${saveResult.filePath}`, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.log(`Ошибка создания скриншота: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
this.isFullscreen = !this.isFullscreen;
|
||||
|
||||
if (this.isFullscreen) {
|
||||
this.elements.videoContainer.classList.add('fullscreen');
|
||||
this.elements.fullscreenBtn.textContent = '⊞ Выйти из полного экрана';
|
||||
this.log('Включен полноэкранный режим', 'info');
|
||||
} else {
|
||||
this.elements.videoContainer.classList.remove('fullscreen');
|
||||
this.elements.fullscreenBtn.textContent = '⛶ Полный экран';
|
||||
this.log('Отключен полноэкранный режим', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
toggleZoom() {
|
||||
this.isZoomed = !this.isZoomed;
|
||||
|
||||
if (this.isZoomed) {
|
||||
this.elements.videoContainer.classList.add('zoomed');
|
||||
this.elements.zoomBtn.textContent = '🔍 Уменьшить';
|
||||
this.elements.remoteVideo.style.cursor = 'move';
|
||||
this.log('Включено увеличение видео', 'info');
|
||||
} else {
|
||||
this.elements.videoContainer.classList.remove('zoomed');
|
||||
this.elements.zoomBtn.textContent = '🔍 Увеличить';
|
||||
this.elements.remoteVideo.style.cursor = 'default';
|
||||
this.elements.remoteVideo.style.transform = 'scale(1)';
|
||||
this.log('Отключено увеличение видео', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
async togglePictureInPicture() {
|
||||
if (!this.remoteStream || !this.elements.remoteVideo) {
|
||||
this.log('Нет активного видео потока для PiP', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture();
|
||||
this.elements.pipBtn.textContent = '📺 PiP';
|
||||
this.log('Выход из режима "Картинка в картинке"', 'info');
|
||||
} else {
|
||||
await this.elements.remoteVideo.requestPictureInPicture();
|
||||
this.elements.pipBtn.textContent = '📺 Выйти из PiP';
|
||||
this.log('Включен режим "Картинка в картинке"', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Ошибка PiP: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
startPingMonitoring() {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.socket && this.socket.connected) {
|
||||
const startTime = Date.now();
|
||||
this.socket.emit('ping', startTime);
|
||||
|
||||
// Timeout после 5 секунд
|
||||
setTimeout(() => {
|
||||
if (this.elements.pingIndicator.textContent === 'Ping: ...') {
|
||||
this.elements.pingIndicator.textContent = 'Ping: Timeout';
|
||||
this.elements.pingIndicator.style.color = '#f44336';
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}, 10000); // Каждые 10 секунд
|
||||
}
|
||||
|
||||
stopPingMonitoring() {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
this.elements.pingIndicator.textContent = 'Ping: --';
|
||||
this.elements.pingIndicator.style.color = '#ccc';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.operator = new GodEyeOperator();
|
||||
});
|
||||
|
||||
// Expose for global access
|
||||
window.GodEyeOperator = GodEyeOperator;
|
||||
158
desktop-operator/src/renderer/index.html
Normal file
158
desktop-operator/src/renderer/index.html
Normal file
@@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GodEye Operator Console</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="logo">
|
||||
<h1>🔍 GodEye Operator</h1>
|
||||
</div>
|
||||
<div class="connection-status">
|
||||
<span id="connection-indicator" class="status disconnected">● Отключен</span>
|
||||
<span id="session-info"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- Left Panel - Video Stream -->
|
||||
<div class="video-panel">
|
||||
<div class="video-container">
|
||||
<video id="remoteVideo" autoplay playsinline muted></video>
|
||||
<div id="video-overlay" class="video-overlay">
|
||||
<div id="no-stream" class="no-stream-message">
|
||||
<h3>Нет видеопотока</h3>
|
||||
<p>Выберите устройство и запросите доступ к камере</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Controls -->
|
||||
<div class="video-controls">
|
||||
<div class="control-group">
|
||||
<label>Камера:</label>
|
||||
<div class="camera-buttons">
|
||||
<button id="btn-back" class="camera-btn" data-camera="back">Основная</button>
|
||||
<button id="btn-front" class="camera-btn" data-camera="front">Фронтальная</button>
|
||||
<button id="btn-wide" class="camera-btn" data-camera="wide">Широкоугольная</button>
|
||||
<button id="btn-telephoto" class="camera-btn" data-camera="telephoto">Телеобъектив</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Действия с видео:</label>
|
||||
<div class="video-actions">
|
||||
<button id="screenshot-btn" class="btn-action" title="Сделать скриншот">📷 Скриншот</button>
|
||||
<button id="fullscreen-btn" class="btn-action" title="Полноэкранный режим">⛶ Полный экран</button>
|
||||
<button id="zoom-btn" class="btn-action" title="Увеличить">🔍 Увеличить</button>
|
||||
<button id="pip-btn" class="btn-action" title="Картинка в картинке">📺 PiP</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Processing Controls -->
|
||||
<div class="image-controls">
|
||||
<h4>Обработка изображения</h4>
|
||||
<div class="slider-group">
|
||||
<label for="brightness">Яркость: <span id="brightness-value">0</span></label>
|
||||
<input type="range" id="brightness" min="-100" max="100" value="0">
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<label for="contrast">Контрастность: <span id="contrast-value">0</span></label>
|
||||
<input type="range" id="contrast" min="-100" max="100" value="0">
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<label for="saturation">Насыщенность: <span id="saturation-value">0</span></label>
|
||||
<input type="range" id="saturation" min="-100" max="100" value="0">
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="monochrome"> Монохром
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="edge-detection"> Выделение краев
|
||||
</label>
|
||||
</div>
|
||||
<button id="reset-filters" class="btn-secondary">Сбросить фильтры</button>
|
||||
</div>
|
||||
|
||||
<!-- Recording Controls -->
|
||||
<div class="recording-controls">
|
||||
<h4>Запись</h4>
|
||||
<div class="recording-buttons">
|
||||
<button id="start-recording" class="btn-record">🔴 Начать запись</button>
|
||||
<button id="stop-recording" class="btn-stop" disabled>⏹ Остановить</button>
|
||||
<button id="play-recording" class="btn-play" disabled>▶ Воспроизвести</button>
|
||||
</div>
|
||||
<div class="recording-info">
|
||||
<span id="recording-status">Готов к записи</span>
|
||||
<span id="recording-duration">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Devices & Sessions -->
|
||||
<div class="control-panel">
|
||||
<!-- Connection Settings -->
|
||||
<div class="connection-panel">
|
||||
<h3>Подключение к серверу</h3>
|
||||
<div class="input-group">
|
||||
<label for="server-url">URL сервера:</label>
|
||||
<input type="text" id="server-url" value="http://localhost:3001" placeholder="http://localhost:3001">
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="auto-connect"> Автоматически подключаться при запуске
|
||||
</label>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button id="connect-btn" class="btn-primary">Подключиться</button>
|
||||
<button id="disconnect-btn" class="btn-secondary" disabled>Отключиться</button>
|
||||
</div>
|
||||
<div id="connection-info" class="connection-info">
|
||||
<span id="connection-status-text">Не подключен</span>
|
||||
<span id="ping-indicator">Ping: --</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Devices -->
|
||||
<div class="devices-panel">
|
||||
<h3>Доступные устройства</h3>
|
||||
<div class="devices-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span></span>
|
||||
<button id="refresh-devices" class="btn-secondary btn-small" title="Обновить список устройств">🔄 Обновить</button>
|
||||
</div>
|
||||
<div id="devices-list" class="devices-list">
|
||||
<div class="no-devices">Нет подключенных устройств</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="sessions-panel">
|
||||
<h3>Активные сессии</h3>
|
||||
<div id="sessions-list" class="sessions-list">
|
||||
<div class="no-sessions">Нет активных сессий</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="logs-panel">
|
||||
<h3>Журнал событий</h3>
|
||||
<div id="logs-container" class="logs-container" style="max-height: 180px; overflow-y: auto;"></div>
|
||||
<button id="clear-logs" class="btn-secondary btn-small">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
698
desktop-operator/src/renderer/styles.css
Normal file
698
desktop-operator/src/renderer/styles.css
Normal file
@@ -0,0 +1,698 @@
|
||||
/* Global Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
background: #2d2d2d;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 20px;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#session-info {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Video Panel */
|
||||
.video-panel {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e;
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#remoteVideo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-stream-message {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.no-stream-message h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Video Controls */
|
||||
.video-controls {
|
||||
background: #2d2d2d;
|
||||
padding: 15px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.camera-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.camera-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #555;
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.camera-btn:hover {
|
||||
background: #4a4a4a;
|
||||
border-color: #777;
|
||||
}
|
||||
|
||||
.camera-btn.active {
|
||||
background: #4CAF50;
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
.camera-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Image Controls */
|
||||
.image-controls {
|
||||
background: #2a2a2a;
|
||||
padding: 15px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.image-controls h4 {
|
||||
margin-bottom: 15px;
|
||||
color: #4CAF50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slider-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.slider-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"] {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #4CAF50;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Recording Controls */
|
||||
.recording-controls {
|
||||
background: #2a2a2a;
|
||||
padding: 15px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.recording-controls h4 {
|
||||
margin-bottom: 15px;
|
||||
color: #ff5722;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.recording-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-record {
|
||||
background: #f44336;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-record:hover:not(:disabled) {
|
||||
background: #d32f2f;
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background: #757575;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-stop:hover:not(:disabled) {
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.btn-play {
|
||||
background: #2196F3;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-play:hover:not(:disabled) {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.recording-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Control Panel */
|
||||
.control-panel {
|
||||
flex: 1;
|
||||
background: #252525;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.control-panel > div {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
/* Connection Panel */
|
||||
.connection-panel h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #4CAF50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #555;
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: #4CAF50;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #757575;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Devices Panel */
|
||||
.devices-panel h3,
|
||||
.sessions-panel h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #2196F3;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.devices-list,
|
||||
.sessions-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-item,
|
||||
.session-item {
|
||||
background: #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #4CAF50;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.device-info strong {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.device-capabilities {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.device-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-device {
|
||||
background: #2196F3;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.btn-device:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.no-devices,
|
||||
.no-sessions {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Logs Panel */
|
||||
.logs-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-panel h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #FF9800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 2px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
.log-entry.success {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.log-entry.warning {
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
/* Enhanced Button Styles */
|
||||
.video-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 8px 12px;
|
||||
background: #2196F3;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: #1976D2;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
.btn-action:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
background: #555;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Connection Panel Enhancements */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
#connection-status-text {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
#ping-indicator {
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Fullscreen Video Mode */
|
||||
.video-container.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-container.fullscreen #remoteVideo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
background: #181818;
|
||||
border: 1px solid #333;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #eee;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
/* Video Zoom */
|
||||
.video-container.zoomed #remoteVideo {
|
||||
transform: scale(1.5);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
/* Tooltip Styles */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 125%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.tooltip:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Status Indicators */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.status-indicator.online {
|
||||
background: #4CAF50;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.status-indicator.connecting {
|
||||
background: #FF9800;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
max-width: none;
|
||||
max-height: 300px;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.control-panel > div {
|
||||
min-width: 250px;
|
||||
border-right: 1px solid #333;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user