Compare commits
6 Commits
549b0c5e63
...
361ffc5578
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
361ffc5578 | ||
|
|
ae56334637 | ||
|
|
02e6bd7b47 | ||
|
|
f4a21e6974 | ||
|
|
5433a036aa | ||
|
|
e36823d3ba |
@@ -6,6 +6,8 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// ===== EMBED WEB =====
|
||||
//
|
||||
//go:embed web/*
|
||||
var webFiles embed.FS
|
||||
|
||||
@@ -75,8 +78,76 @@ func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ===== PIPE READER =====
|
||||
func (a *API) HandleStream(w http.ResponseWriter, r *http.Request) {
|
||||
// Проверяем доступность go2rtc
|
||||
resp, err := http.Get("http://localhost:1984/api/streams?src=cam")
|
||||
camAvailable := err == nil && resp.StatusCode == 200
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"cam": "/api/cam",
|
||||
"available": camAvailable,
|
||||
"source": fmt.Sprintf("http://%s:1984/api/stream.mjpeg?src=cam", r.Host),
|
||||
})
|
||||
}
|
||||
|
||||
// Прокси для MJPEG потока с go2rtc
|
||||
func handleCamProxy(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[cam] proxy request from %s", r.RemoteAddr)
|
||||
|
||||
resp, err := http.Get(
|
||||
"http://127.0.0.1:1984/api/stream.mjpeg?src=cam",
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[cam] go2rtc unavailable: %v", err)
|
||||
http.Error(w, "camera unavailable", 503)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "stream unsupported", 500)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
|
||||
if n > 0 {
|
||||
_, err = w.Write(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("[cam] client disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[cam] stream ended: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PIPE READER =====
|
||||
func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string) {
|
||||
go func() {
|
||||
buffer := make([]byte, 64)
|
||||
@@ -116,6 +187,14 @@ func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string)
|
||||
func cors(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -137,7 +216,6 @@ func main() {
|
||||
|
||||
// ===== API =====
|
||||
http.Handle("/api/", http.StripPrefix("/api", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/latest":
|
||||
cors(api.HandleLatest)(w, r)
|
||||
@@ -145,6 +223,10 @@ func main() {
|
||||
cors(api.HandleHistory)(w, r)
|
||||
case "/health":
|
||||
cors(api.HandleHealth)(w, r)
|
||||
case "/stream":
|
||||
cors(api.HandleStream)(w, r)
|
||||
case "/cam":
|
||||
handleCamProxy(w, r)
|
||||
default:
|
||||
http.Error(w, "not found", 404)
|
||||
}
|
||||
@@ -158,6 +240,7 @@ func main() {
|
||||
|
||||
http.Handle("/", http.FileServer(http.FS(webFS)))
|
||||
|
||||
log.Println("server :8080")
|
||||
log.Println("Server started on :8080")
|
||||
log.Println("Camera proxy available at /api/cam")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
@@ -10,10 +10,44 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ===== HEADER BAR ===== */
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
margin: 0 0 15px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ===== CAMERA BUTTON (теперь в header) ===== */
|
||||
.cam-btn {
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #00ff88;
|
||||
border: 1px solid #00ff88;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cam-btn:hover {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.cam-btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ===== GRID ===== */
|
||||
@@ -21,6 +55,7 @@ h1 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
@@ -39,6 +74,10 @@ h1 {
|
||||
padding: 14px;
|
||||
background: rgba(0,255,136,0.05);
|
||||
overflow: hidden;
|
||||
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
@@ -53,7 +92,6 @@ h1 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* СТАТУСЫ - ЦВЕТА */
|
||||
.status-ok {
|
||||
color: #00ff88 !important;
|
||||
}
|
||||
@@ -113,7 +151,6 @@ h1 {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* ЗАЛИВКА ПРОГРЕСС-БАРА */
|
||||
.fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
@@ -124,7 +161,6 @@ h1 {
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
|
||||
/* Заливка при потере связи */
|
||||
.fill.no-connection {
|
||||
background: #555555 !important;
|
||||
box-shadow: none !important;
|
||||
@@ -154,13 +190,11 @@ h1 {
|
||||
.sig-bar-2 { height: 66%; }
|
||||
.sig-bar-3 { height: 100%; }
|
||||
|
||||
/* Неактивные сигнальные бары */
|
||||
.sig-bar.dim {
|
||||
background-color: transparent;
|
||||
border-color: #1a3a1a;
|
||||
}
|
||||
|
||||
/* Активные сигнальные бары */
|
||||
.sig-bar.active-1,
|
||||
.sig-bar.active-2,
|
||||
.sig-bar.active-3 {
|
||||
@@ -168,7 +202,6 @@ h1 {
|
||||
border-color: #c46b1f;
|
||||
}
|
||||
|
||||
/* Нет связи - сигнальные бары */
|
||||
.sig-bar.no-connection {
|
||||
background-color: #555555 !important;
|
||||
border-color: #555555 !important;
|
||||
@@ -224,8 +257,162 @@ h1 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== SPLIT LAYOUT (FIXED) ===== */
|
||||
.layout {
|
||||
display: flex;
|
||||
height: calc(100vh - 100px);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* панели */
|
||||
.panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* LEFT */
|
||||
.left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 300px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* RIGHT */
|
||||
.right {
|
||||
flex: 0 0 40%;
|
||||
min-width: 0;
|
||||
background: black;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right.hidden {
|
||||
flex: 0 0 0 !important;
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== RESIZER ===== */
|
||||
.resizer {
|
||||
width: 8px;
|
||||
min-width: 8px;
|
||||
cursor: col-resize;
|
||||
background: #00ff88;
|
||||
opacity: 0.3;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resizer:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.resizer.hidden {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* камера */
|
||||
.cam-modal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: black;
|
||||
}
|
||||
|
||||
.cam-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
background: #111;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.cam-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
background: black;
|
||||
}
|
||||
/* ===== CAMERA OVERLAY ===== */
|
||||
.cam-frame-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cam-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: black;
|
||||
}
|
||||
|
||||
/* Перекрестие (crosshair) */
|
||||
.cam-crosshair {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Вертикальная линия */
|
||||
.crosshair-v {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 4px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Горизонтальная линия */
|
||||
.crosshair-h {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 4px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
/* Центральный квадрат */
|
||||
.crosshair-box {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px solid #00ff88;
|
||||
box-shadow:
|
||||
0 0 10px rgba(0, 255, 136, 0.3),
|
||||
inset 0 0 10px rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
/* ===== MOBILE ===== */
|
||||
@media (max-width: 500px) {
|
||||
.header-bar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -248,4 +435,22 @@ h1 {
|
||||
line-height: 1.5;
|
||||
max-height: 55vh;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.left {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 0 0 50vh !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -11,57 +11,94 @@
|
||||
|
||||
<body>
|
||||
|
||||
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
|
||||
<div class="header-bar">
|
||||
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
|
||||
<button id="camToggle" class="cam-btn">
|
||||
<span class="cam-btn-icon">📷</span>
|
||||
<span class="cam-btn-text">Камера</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="layout">
|
||||
<!-- LEFT: графики / дашборд -->
|
||||
<div class="panel left" id="leftPanel">
|
||||
<div class="grid">
|
||||
|
||||
<div class="card">
|
||||
<h3>СОСТОЯНИЕ</h3>
|
||||
<div id="status">...</div>
|
||||
<div id="meta"></div>
|
||||
</div>
|
||||
<div class="card wide">
|
||||
<h3>СОСТОЯНИЕ</h3>
|
||||
<div id="status">...</div>
|
||||
<div id="meta"></div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<h3>УРОВНИ КАНАЛОВ</h3>
|
||||
<div class="card wide">
|
||||
<h3>УРОВНИ КАНАЛОВ</h3>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Канал 1 — <span id="last-byte">0</span></div>
|
||||
<div class="progress-with-signal">
|
||||
<div class="progress progress-90">
|
||||
<div class="fill" id="bar1"></div>
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Канал 1 — <span id="last-byte">0</span></div>
|
||||
<div class="progress-with-signal">
|
||||
<div class="progress progress-90">
|
||||
<div class="fill" id="bar1"></div>
|
||||
</div>
|
||||
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
|
||||
<div class="sig-bar sig-bar-1"></div>
|
||||
<div class="sig-bar sig-bar-2"></div>
|
||||
<div class="sig-bar sig-bar-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
|
||||
<div class="sig-bar sig-bar-1"></div>
|
||||
<div class="sig-bar sig-bar-2"></div>
|
||||
<div class="sig-bar sig-bar-3"></div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Звук</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Высота</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Звук</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar2"></div>
|
||||
<div class="card wide">
|
||||
<h3>ГРАФИК КАНАЛА 1</h3>
|
||||
<canvas id="signalChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-label">Высота</div>
|
||||
<div class="progress">
|
||||
<div class="fill" id="bar3"></div>
|
||||
<div class="card wide">
|
||||
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
|
||||
<div class="accordion-body" id="history-body">
|
||||
<div id="history"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<h3>ГРАФИК КАНАЛА 1</h3>
|
||||
<canvas id="signalChart" height="120"></canvas>
|
||||
</div>
|
||||
<!-- DRAG HANDLE -->
|
||||
<div class="resizer" id="dragBar"></div>
|
||||
|
||||
<div class="card wide">
|
||||
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
|
||||
<div class="accordion-body" id="history-body">
|
||||
<div id="history"></div>
|
||||
<!-- RIGHT: камера -->
|
||||
<div class="panel right" id="rightPanel">
|
||||
<div class="cam-modal">
|
||||
<div class="cam-header">
|
||||
<span>Видео с камеры</span>
|
||||
</div>
|
||||
<div class="cam-frame-wrapper">
|
||||
<img id="camImage" src="" alt="Camera" class="cam-frame">
|
||||
|
||||
<!-- ПЕРЕКРЕСТИЕ -->
|
||||
<div class="cam-crosshair" id="crosshair">
|
||||
<!-- Основные линии -->
|
||||
<div class="crosshair-v"></div>
|
||||
<div class="crosshair-h"></div>
|
||||
|
||||
<!-- Центральный квадрат -->
|
||||
<div class="crosshair-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,4 +107,4 @@
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
|
||||
import { initAccordion } from "./accordion.js";
|
||||
import { drawChart } from "./chart.js";
|
||||
import { initResize } from "./layout.js";
|
||||
import { initCamera } from "./camera.js";
|
||||
|
||||
let lastChart = 0;
|
||||
let connectionLost = false;
|
||||
@@ -23,6 +25,29 @@ let pollTimer = null;
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initDOM();
|
||||
initAccordion();
|
||||
initResize();
|
||||
initCamera();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
switch(e.key.toLowerCase()) {
|
||||
case 'c':
|
||||
// Переключение камеры
|
||||
const camBtn = document.getElementById('camToggle');
|
||||
if (camBtn) camBtn.click();
|
||||
break;
|
||||
case 'f':
|
||||
// На весь экран для левой панели
|
||||
if (leftPanel) {
|
||||
leftPanel.style.width = "100%";
|
||||
if (rightPanel) rightPanel.style.display = "none";
|
||||
}
|
||||
break;
|
||||
case 'escape':
|
||||
if (rightPanel) rightPanel.style.display = "flex";
|
||||
applyRatio(0.6); // 60% левая, 40% правая
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ================= UPDATE =================
|
||||
|
||||
161
cmd/server/web/js/camera.js
Normal file
161
cmd/server/web/js/camera.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// camera.js
|
||||
import { updateResizerVisibility } from './layout.js';
|
||||
|
||||
let rightPanel;
|
||||
let camImage;
|
||||
let crosshair;
|
||||
let isOpen = false;
|
||||
let streamCheckInterval = null;
|
||||
|
||||
export function initCamera() {
|
||||
rightPanel = document.getElementById("rightPanel");
|
||||
camImage = document.getElementById("camImage");
|
||||
crosshair = document.getElementById("crosshair");
|
||||
|
||||
// Проверяем доступность потока
|
||||
checkStreamAvailability();
|
||||
|
||||
const btn = document.getElementById("camToggle");
|
||||
|
||||
const streamUrl = "/api/cam";
|
||||
|
||||
function open() {
|
||||
if (isOpen) return;
|
||||
|
||||
isOpen = true;
|
||||
|
||||
rightPanel.classList.remove("hidden");
|
||||
|
||||
// Показываем перекрестие
|
||||
if (crosshair) {
|
||||
crosshair.style.display = "block";
|
||||
}
|
||||
|
||||
camImage.src = streamUrl;
|
||||
|
||||
if (btn) {
|
||||
btn.querySelector('.cam-btn-text').textContent = 'Скрыть';
|
||||
}
|
||||
|
||||
console.log("[camera] opened");
|
||||
|
||||
updateResizerVisibility();
|
||||
|
||||
// Запускаем мониторинг потока
|
||||
startStreamMonitoring();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!isOpen) return;
|
||||
|
||||
isOpen = false;
|
||||
|
||||
// Скрываем перекрестие
|
||||
if (crosshair) {
|
||||
crosshair.style.display = "none";
|
||||
}
|
||||
|
||||
// остановка stream
|
||||
camImage.src = "";
|
||||
|
||||
rightPanel.classList.add("hidden");
|
||||
|
||||
if (btn) {
|
||||
btn.querySelector('.cam-btn-text').textContent = 'Камера';
|
||||
}
|
||||
|
||||
console.log("[camera] closed");
|
||||
|
||||
updateResizerVisibility();
|
||||
|
||||
// Останавливаем мониторинг
|
||||
stopStreamMonitoring();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
camImage.onload = () => {
|
||||
console.log("[camera] stream started");
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '1';
|
||||
}
|
||||
};
|
||||
|
||||
camImage.onerror = (e) => {
|
||||
console.error("[camera] stream error", e);
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '0.3';
|
||||
}
|
||||
};
|
||||
|
||||
btn?.addEventListener("click", toggle);
|
||||
|
||||
// Проверка доступности камеры
|
||||
async function checkStreamAvailability() {
|
||||
try {
|
||||
const response = await fetch('/api/stream');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.available && data.cam) {
|
||||
console.log("[camera] stream available:", data.source);
|
||||
// Автозапуск если камера доступна
|
||||
if (!isOpen) {
|
||||
open();
|
||||
}
|
||||
} else {
|
||||
console.log("[camera] stream not available");
|
||||
if (crosshair) {
|
||||
crosshair.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[camera] check failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Мониторинг состояния потока
|
||||
function startStreamMonitoring() {
|
||||
stopStreamMonitoring();
|
||||
streamCheckInterval = setInterval(() => {
|
||||
if (isOpen && camImage) {
|
||||
if (!camImage.complete || camImage.naturalWidth === 0) {
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '0.3';
|
||||
}
|
||||
} else {
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopStreamMonitoring() {
|
||||
if (streamCheckInterval) {
|
||||
clearInterval(streamCheckInterval);
|
||||
streamCheckInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем для возможности ручного управления
|
||||
export function showCrosshair(show = true) {
|
||||
const crosshair = document.getElementById("crosshair");
|
||||
if (crosshair) {
|
||||
crosshair.style.display = show ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function setCrosshairOpacity(opacity) {
|
||||
const crosshair = document.getElementById("crosshair");
|
||||
if (crosshair) {
|
||||
crosshair.style.opacity = opacity;
|
||||
}
|
||||
}
|
||||
83
cmd/server/web/js/layout.js
Normal file
83
cmd/server/web/js/layout.js
Normal file
@@ -0,0 +1,83 @@
|
||||
//layout.js
|
||||
let leftPanel, rightPanel, dragBar;
|
||||
let isDragging = false;
|
||||
let pendingX = null;
|
||||
|
||||
export function initResize() {
|
||||
leftPanel = document.getElementById("leftPanel");
|
||||
rightPanel = document.getElementById("rightPanel");
|
||||
dragBar = document.getElementById("dragBar");
|
||||
|
||||
if (!leftPanel || !rightPanel || !dragBar) return;
|
||||
|
||||
const savedRatio = localStorage.getItem('layoutRatio');
|
||||
if (savedRatio) {
|
||||
applyRatio(parseFloat(savedRatio));
|
||||
}
|
||||
|
||||
dragBar.addEventListener("mousedown", (e) => {
|
||||
if (rightPanel.classList.contains("hidden")) return;
|
||||
|
||||
isDragging = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (!isDragging) return;
|
||||
|
||||
isDragging = false;
|
||||
document.body.style.cursor = "default";
|
||||
|
||||
if (!rightPanel.classList.contains("hidden")) {
|
||||
const total = window.innerWidth;
|
||||
const leftWidth = leftPanel.getBoundingClientRect().width;
|
||||
localStorage.setItem('layoutRatio', leftWidth / total);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (!isDragging) return;
|
||||
if (rightPanel.classList.contains("hidden")) return;
|
||||
|
||||
pendingX = e.clientX;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (pendingX === null) return;
|
||||
|
||||
const total = window.innerWidth;
|
||||
let leftWidth = pendingX;
|
||||
|
||||
const min = 300;
|
||||
if (leftWidth < min) leftWidth = min;
|
||||
if (leftWidth > total - min) leftWidth = total - min;
|
||||
|
||||
const rightWidth = total - leftWidth - dragBar.offsetWidth;
|
||||
|
||||
leftPanel.style.flex = `0 0 ${leftWidth}px`;
|
||||
rightPanel.style.flex = `0 0 ${rightWidth}px`;
|
||||
|
||||
pendingX = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyRatio(ratio) {
|
||||
const total = window.innerWidth;
|
||||
const leftWidth = Math.floor(total * ratio);
|
||||
const rightWidth = total - leftWidth;
|
||||
|
||||
leftPanel.style.flex = `0 0 ${leftWidth}px`;
|
||||
rightPanel.style.flex = `0 0 ${rightWidth}px`;
|
||||
}
|
||||
|
||||
export function updateResizerVisibility() {
|
||||
if (!dragBar || !rightPanel) return;
|
||||
|
||||
if (rightPanel.classList.contains("hidden")) {
|
||||
dragBar.classList.add("hidden");
|
||||
leftPanel.style.flex = "1";
|
||||
} else {
|
||||
dragBar.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user