Skip to content

Commit f77748f

Browse files
committed
ci: Add GitHub Actions workflow for build and release
1 parent d890324 commit f77748f

File tree

8 files changed

+527
-159
lines changed

8 files changed

+527
-159
lines changed

index.html

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,19 @@
5151
border: 1px solid var(--border-color);
5252
cursor: pointer;
5353
text-align: center;
54-
transition: background-color 0.2s, border-color 0.2s;
54+
transition: background-color 0.2s, border-color 0.2s, transform 0.1s ease-in-out;
5555
padding: 5px;
5656
box-sizing: border-box;
5757
object-fit: contain;
58+
overflow: hidden;
59+
z-index: 1;
5860
}
5961

62+
.grid-cell:hover {
63+
z-index: 99;
64+
transform: scale(1.02);
65+
}
66+
6067
.grid-cell.fullscreen {
6168
position: fixed !important;
6269
top: 0 !important;
@@ -70,24 +77,85 @@
7077
background-color: #000;
7178
}
7279

80+
.grid-cell.fullscreen .cell-controls,
81+
.grid-cell.fullscreen .cell-name,
82+
.grid-cell.fullscreen .cell-stats {
83+
opacity: 1;
84+
}
85+
7386
.grid-cell.fullscreen .cell-controls {
7487
position: fixed;
7588
top: 10px;
7689
right: 15px;
7790
}
7891

7992
.grid-cell.drag-over { border: 2px dashed var(--accent-color); background-color: var(--bg-light); }
80-
.grid-cell canvas { width: 100%; height: 100%; object-fit: contain; border-radius: 4px; }
93+
94+
.grid-cell canvas {
95+
width: 100%;
96+
height: 100%;
97+
object-fit: contain;
98+
border-radius: 4px;
99+
pointer-events: none;
100+
}
101+
81102
.grid-cell.active { border: 2px solid var(--accent-color); }
82-
.cell-controls { position: absolute; top: 5px; right: 5px; background-color: rgba(0,0,0,0.6); border-radius: 4px; z-index: 10; display: flex; gap: 5px; pointer-events: all; padding: 2px 4px; }
83103

104+
.cell-controls {
105+
position: absolute;
106+
top: 5px;
107+
right: 5px;
108+
background-color: rgba(0,0,0,0.6);
109+
border-radius: 4px;
110+
z-index: 10;
111+
display: flex;
112+
gap: 5px;
113+
pointer-events: all;
114+
padding: 2px 4px;
115+
opacity: 0;
116+
transition: opacity 0.2s ease-in-out;
117+
}
118+
119+
.grid-cell:hover .cell-controls,
120+
.grid-cell:hover .cell-name,
121+
.grid-cell:hover .cell-stats {
122+
opacity: 1;
123+
}
124+
84125
.cell-controls button { background: none; border: none; color: white; cursor: pointer; font-size: 16px; padding: 0; display: flex; align-items: center; }
85126
.cell-controls button.fullscreen-btn { font-size: 20px; }
86127
.cell-controls button.record-btn.recording { color: var(--danger-color); animation: pulse-rec 1.5s infinite; }
87128
@keyframes pulse-rec { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }
88129

89-
.cell-name { position: absolute; bottom: 5px; right: 10px; background-color: rgba(0,0,0,0.6); color: white; padding: 3px 8px; border-radius: 4px; font-size: 13px; z-index: 10; pointer-events: none; }
90-
.cell-stats { position: absolute; bottom: 5px; left: 10px; background-color: rgba(0,0,0,0.6); color: #fff; padding: 3px 8px; border-radius: 4px; font-size: 13px; font-family: monospace; pointer-events: none; }
130+
.cell-name {
131+
position: absolute;
132+
bottom: 5px;
133+
right: 10px;
134+
background-color: rgba(0,0,0,0.6);
135+
color: white;
136+
padding: 3px 8px;
137+
border-radius: 4px;
138+
font-size: 13px;
139+
z-index: 10;
140+
pointer-events: none;
141+
opacity: 0;
142+
transition: opacity 0.2s ease-in-out;
143+
}
144+
145+
.cell-stats {
146+
position: absolute;
147+
bottom: 5px;
148+
left: 10px;
149+
background-color: rgba(0,0,0,0.6);
150+
color: #fff;
151+
padding: 3px 8px;
152+
border-radius: 4px;
153+
font-size: 13px;
154+
font-family: monospace;
155+
pointer-events: none;
156+
opacity: 0;
157+
transition: opacity 0.2s ease-in-out;
158+
}
91159

92160
.grid-cell .placeholder-icon { font-size: 48px; margin-bottom: 10px; }
93161

@@ -369,16 +437,21 @@ <h3 data-i18n-key="settings_app_header"></h3>
369437
<input type="text" id="app-settings-recordings-path" readonly>
370438
<button id="select-rec-path-btn" style="padding: 0 10px; min-width: 40px; height: 35px;"><i class="material-icons" style="font-size: 20px;">folder_open</i></button>
371439
</div>
440+
<span data-i18n-key="settings_hw_accel"></span>
441+
<select id="app-settings-hw-accel">
442+
<option value="auto" data-i18n-key="settings_hw_accel_auto"></option>
443+
<option value="nvidia" data-i18n-key="settings_hw_accel_nvidia"></option>
444+
<option value="intel" data-i18n-key="settings_hw_accel_intel"></option>
445+
<option value="none" data-i18n-key="settings_hw_accel_none"></option>
446+
</select>
372447
</div>
373-
<!-- === ИЗМЕНЕННЫЙ БЛОК === -->
374448
<h3 data-i18n-key="update_header"></h3>
375449
<div class="form-grid simple">
376450
<span data-i18n-key="update_status_label"></span>
377451
<span id="update-status-text" style="font-style: italic;" data-i18n-key="update_check_prompt"></span>
378452
<span data-i18n-key="update_action_label"></span>
379453
<button id="check-for-updates-btn" style="width: 200px; justify-self: start;" data-i18n-key="update_check_button"></button>
380454
</div>
381-
<!-- === КОНЕЦ ИЗМЕНЕННОГО БЛОКА === -->
382455
</div>
383456

384457
<div id="tab-system" class="tab-content">

js/archive-manager.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@
2424
}
2525

2626
async function loadArchiveList() {
27-
archiveListEl.innerHTML = '<li>Загрузка...</li>';
27+
archiveListEl.innerHTML = `<li>${App.t('loading_text')}</li>`;
2828
const files = await window.api.getRecordingsList();
2929
archiveListEl.innerHTML = '';
3030

3131
if (files.length === 0) {
32-
archiveListEl.innerHTML = '<li>Нет записей.</li>';
32+
archiveListEl.innerHTML = `<li>${App.t('archive_no_recordings')}</li>`;
3333
return;
3434
}
3535

@@ -79,15 +79,16 @@
7979
archiveRefreshBtn.addEventListener('click', loadArchiveList);
8080

8181
archiveDeleteBtn.addEventListener('click', async () => {
82-
if (!selectedArchiveFile || !confirm(`Вы уверены, что хотите удалить файл "${selectedArchiveFile}"?`)) return;
82+
const confirmationMessage = App.t('confirm_delete_recording', { filename: selectedArchiveFile });
83+
if (!selectedArchiveFile || !confirm(confirmationMessage)) return;
84+
8385
const result = await window.api.deleteRecording(selectedArchiveFile);
8486
if (result.success) {
85-
// We don't have access to showToast here, maybe we can expose it on App
86-
console.log('Файл удален.');
87+
console.log('File deleted.');
8788
resetArchivePlayer();
8889
loadArchiveList();
8990
} else {
90-
alert(`Ошибка удаления: ${result.error}`);
91+
alert(`${App.t('error_deleting')}: ${result.error}`);
9192
}
9293
});
9394

js/grid-manager.js

Lines changed: 96 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// js/grid-manager.js
1+
// js/grid-manager.js (ФИНАЛЬНАЯ ВЕРСИЯ С ИСПРАВЛЕНИЯМИ)
22

33
(function(window) {
44
window.AppModules = window.AppModules || {};
@@ -8,6 +8,7 @@
88
const layoutControls = document.getElementById('layout-controls');
99
const MAX_GRID_SIZE = 64;
1010
let reconnectTimers = {};
11+
const manuallyClosedStreams = new Set();
1112

1213
let gridCols = 2;
1314
let gridRows = 2;
@@ -42,7 +43,7 @@
4243
}
4344

4445
function initializeLayoutControls() {
45-
const layouts = ["1x1", "2x2", "3x3", "4x4", "5x5", "6x6", "8x4"];
46+
const layouts = ["1x1", "2x2", "3x3", "4x4", "5x5", "8x4","8x8"];
4647
layouts.forEach(layout => {
4748
const btn = document.createElement('button');
4849
btn.className = 'layout-btn';
@@ -304,102 +305,146 @@
304305
try { state.player.destroy(); } catch (e) { console.error(`Error destroying JSMpeg player:`, e); }
305306
state.player = null;
306307
}
307-
if (state.uniqueStreamIdentifier) await window.api.stopVideoStream(state.uniqueStreamIdentifier);
308+
if (state.uniqueStreamIdentifier) {
309+
await window.api.stopVideoStream(state.uniqueStreamIdentifier);
310+
}
308311
}
309312

313+
// --- ИСПРАВЛЕННАЯ ФУНКЦИЯ ---
310314
async function stopStreamInCell(cellIndex, clearCellUI = true) {
315+
// Очищаем таймер переподключения, если он есть
311316
if (reconnectTimers[cellIndex]) {
312317
clearTimeout(reconnectTimers[cellIndex]);
313318
delete reconnectTimers[cellIndex];
314319
}
315-
320+
321+
// Захватываем состояние ячейки в самом начале
316322
const state = gridCellsState[cellIndex];
323+
324+
// Если в ячейке ничего нет, то и делать нечего
325+
if (!state) {
326+
return;
327+
}
328+
329+
// Безопасно получаем ID камеры и уникальный идентификатор потока
330+
const { uniqueStreamIdentifier, camera } = state;
331+
const cameraId = camera.id;
332+
333+
// Регистрируем, что поток закрывается вручную, чтобы избежать авто-переподключения
334+
if (uniqueStreamIdentifier) {
335+
manuallyClosedStreams.add(uniqueStreamIdentifier);
336+
}
337+
338+
// Уничтожаем плеер и останавливаем ffmpeg на бэкенде
317339
await destroyPlayerInCell(cellIndex);
318-
if (state) {
319-
const isAnotherCellWithSameCam = gridCellsState.some((s, idx) => idx !== cellIndex && s?.camera.id === state.camera.id);
320-
if (App.recordingStates[state.camera.id] && !isAnotherCellWithSameCam) await window.api.stopRecording(state.camera.id);
340+
341+
// Проверяем, нужно ли остановить запись.
342+
// Это делается только если это последняя ячейка с данной камерой.
343+
const isAnotherCellWithSameCam = gridCellsState.some(
344+
(s, idx) => idx !== cellIndex && s?.camera.id === cameraId
345+
);
346+
if (App.recordingStates[cameraId] && !isAnotherCellWithSameCam) {
347+
await window.api.stopRecording(cameraId);
321348
}
322-
gridCellsState[cellIndex] = null;
323-
await App.saveConfiguration();
324-
if (clearCellUI) {
325-
const cellElement = document.querySelector(`[data-cell-id='${cellIndex}']`);
326-
if(cellElement) {
327-
cellElement.innerHTML = `<span><i class="material-icons placeholder-icon">add_photo_alternate</i><br>${App.t('drop_camera_here')}</span>`;
328-
cellElement.classList.remove('active');
329-
cellElement.draggable = false;
349+
350+
// Финальная проверка: очищаем состояние ячейки только если оно не было изменено
351+
// другой операцией, пока мы ждали завершения `destroyPlayerInCell`.
352+
if (gridCellsState[cellIndex] === state) {
353+
gridCellsState[cellIndex] = null;
354+
355+
if (clearCellUI) {
356+
const cellElement = document.querySelector(`[data-cell-id='${cellIndex}']`);
357+
if(cellElement) {
358+
cellElement.innerHTML = `<span><i class="material-icons placeholder-icon">add_photo_alternate</i><br>${App.t('drop_camera_here')}</span>`;
359+
cellElement.classList.remove('active');
360+
cellElement.draggable = false;
361+
}
330362
}
331363
}
364+
365+
// Сохраняем конфигурацию в любом случае
366+
await App.saveConfiguration();
332367
}
333-
368+
334369
async function toggleStream(cellIndex) {
335370
const currentState = gridCellsState[cellIndex];
336371
if (!currentState || !currentState.camera) return;
337-
372+
373+
const cellElement = document.querySelector(`[data-cell-id='${cellIndex}']`);
374+
if (!cellElement) return;
375+
338376
const newStreamId = currentState.streamId === 0 ? 1 : 0;
339377
const cameraId = currentState.camera.id;
340378
const currentVolume = currentState.player ? currentState.player.volume : 0;
341-
379+
380+
cellElement.innerHTML = `<span>${App.t('switch_stream')}</span>`;
381+
cellElement.classList.add('active');
382+
cellElement.draggable = true;
383+
342384
await destroyPlayerInCell(cellIndex);
343-
const cellElement = document.querySelector(`[data-cell-id='${cellIndex}']`);
344-
if(cellElement) cellElement.innerHTML = `<span>${App.t('switch_stream')}</span>`;
345-
346385
await startStreamInCell(cellIndex, cameraId, newStreamId);
347-
386+
348387
const newState = gridCellsState[cellIndex];
349388
if (newState && newState.player) {
389+
const newCellElement = document.querySelector(`[data-cell-id='${cellIndex}']`);
350390
newState.player.volume = currentVolume;
351-
const audioBtnIcon = cellElement.querySelector('.audio-btn i');
352-
if (audioBtnIcon) audioBtnIcon.textContent = currentVolume === 0 ? 'volume_off' : 'volume_up';
391+
const newAudioBtnIcon = newCellElement.querySelector('.audio-btn i');
392+
if (newAudioBtnIcon) {
393+
newAudioBtnIcon.textContent = currentVolume === 0 ? 'volume_off' : 'volume_up';
394+
}
353395
}
354396
}
355-
397+
356398
async function toggleFullscreen(cellIndex) {
357399
const cell = document.querySelector(`[data-cell-id='${cellIndex}']`);
358-
if (!cell || !gridCellsState[cellIndex]) return;
359-
360400
const state = gridCellsState[cellIndex];
401+
if (!cell || !state) return;
402+
361403
const isCurrentlyFullscreen = cell.classList.contains('fullscreen');
362-
const { id: cameraId } = state.camera;
363-
const streamId = state.streamId;
364-
const currentVolume = state.player ? state.player.volume : 0;
365-
366-
await destroyPlayerInCell(cellIndex);
367-
cell.innerHTML = `<span>${App.t('switch_fullscreen')}</span>`;
368404

369405
if (isCurrentlyFullscreen) {
370-
fullscreenCellIndex = null;
371406
gridContainer.classList.remove('fullscreen-mode');
372407
cell.classList.remove('fullscreen');
373-
} else {
408+
const fsBtnIcon = cell.querySelector('.fullscreen-btn i');
409+
if(fsBtnIcon) fsBtnIcon.textContent = 'fullscreen';
410+
fullscreenCellIndex = null;
411+
}
412+
else {
413+
if (fullscreenCellIndex !== null) {
414+
const oldFullscreenCell = document.querySelector(`[data-cell-id='${fullscreenCellIndex}']`);
415+
if(oldFullscreenCell) {
416+
oldFullscreenCell.classList.remove('fullscreen');
417+
const oldFsBtnIcon = oldFullscreenCell.querySelector('.fullscreen-btn i');
418+
if(oldFsBtnIcon) oldFsBtnIcon.textContent = 'fullscreen';
419+
}
420+
}
421+
374422
fullscreenCellIndex = cellIndex;
375423
gridContainer.classList.add('fullscreen-mode');
376424
cell.classList.add('fullscreen');
377-
}
378-
379-
await startStreamInCell(cellIndex, cameraId, streamId);
380-
381-
const newState = gridCellsState[cellIndex];
382-
if (newState && newState.player) {
383-
newState.player.volume = currentVolume;
384-
const newControls = cell.querySelector('.cell-controls');
385-
if (newControls) {
386-
const audioBtnIcon = newControls.querySelector('.audio-btn i');
387-
if(audioBtnIcon) audioBtnIcon.textContent = currentVolume === 0 ? 'volume_off' : 'volume_up';
388-
const fullscreenBtnIcon = newControls.querySelector('.fullscreen-btn i');
389-
if(fullscreenBtnIcon) fullscreenBtnIcon.textContent = isCurrentlyFullscreen ? 'fullscreen' : 'fullscreen_exit';
390-
}
425+
const fsBtnIcon = cell.querySelector('.fullscreen-btn i');
426+
if(fsBtnIcon) fsBtnIcon.textContent = 'fullscreen_exit';
391427
}
392428
}
393429

394430
function handleStreamDeath(uniqueStreamIdentifier) {
431+
if (manuallyClosedStreams.has(uniqueStreamIdentifier)) {
432+
manuallyClosedStreams.delete(uniqueStreamIdentifier);
433+
console.log(`[Grid] Ignoring reconnect for manually closed stream ${uniqueStreamIdentifier}.`);
434+
return;
435+
}
436+
395437
const cellIndex = gridCellsState.findIndex(s => s?.uniqueStreamIdentifier === uniqueStreamIdentifier);
396438
if (cellIndex === -1) return;
397439

398440
if (reconnectTimers[cellIndex]) {
399441
clearTimeout(reconnectTimers[cellIndex]);
400442
}
401443

402-
const { camera, streamId } = gridCellsState[cellIndex];
444+
const state = gridCellsState[cellIndex];
445+
if (!state) return;
446+
const { camera, streamId } = state;
447+
403448
const cellElement = document.querySelector(`[data-cell-id='${cellIndex}']`);
404449
if (cellElement) {
405450
cellElement.innerHTML = `
@@ -451,7 +496,7 @@
451496
for (let i = 0; i < gridCellsState.length; i++) {
452497
if (gridCellsState[i]?.camera.id === cameraId) {
453498
const oldStreamId = gridCellsState[i].streamId;
454-
await destroyPlayerInCell(i);
499+
await stopStreamInCell(i, false);
455500
await startStreamInCell(i, cameraId, oldStreamId);
456501
}
457502
}

0 commit comments

Comments
 (0)