diff --git a/cmd/server/main.go b/cmd/server/main.go index 43b55d8..f3b0a7e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,8 @@ import ( "embed" "encoding/json" "flag" + "fmt" + "io" "io/fs" "log" "net/http" @@ -77,13 +79,75 @@ func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) { } func (a *API) HandleStream(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]string{ - "cam": "http://192.168.77.1:1984/stream.html?src=cam", + // Проверяем доступность 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), }) } -// ===== PIPE READER ===== +// Прокси для 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) @@ -123,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) } } @@ -153,6 +225,8 @@ func main() { cors(api.HandleHealth)(w, r) case "/stream": cors(api.HandleStream)(w, r) + case "/cam": + handleCamProxy(w, r) default: http.Error(w, "not found", 404) } @@ -166,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)) } diff --git a/cmd/server/web/css/style.css b/cmd/server/web/css/style.css index 37331c6..06f0da8 100644 --- a/cmd/server/web/css/style.css +++ b/cmd/server/web/css/style.css @@ -53,7 +53,6 @@ h1 { margin-bottom: 8px; } -/* СТАТУСЫ - ЦВЕТА */ .status-ok { color: #00ff88 !important; } @@ -113,7 +112,6 @@ h1 { margin-right: 10px; } -/* ЗАЛИВКА ПРОГРЕСС-БАРА */ .fill { height: 100%; width: 0%; @@ -124,7 +122,6 @@ h1 { transition: width 0.25s linear; } -/* Заливка при потере связи */ .fill.no-connection { background: #555555 !important; box-shadow: none !important; @@ -154,13 +151,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 +163,6 @@ h1 { border-color: #c46b1f; } -/* Нет связи - сигнальные бары */ .sig-bar.no-connection { background-color: #555555 !important; border-color: #555555 !important; @@ -224,99 +218,90 @@ h1 { opacity: 0; } - -/* ОТКРЫТОЕ СОСТОЯНИЕ */ -.cam-modal.open { - transform: translateX(0); -} - -.cam-header { - display: flex; - justify-content: space-between; - padding: 8px; - color: white; - background: #222; -} - -.cam-frame { - flex: 1; - width: 100%; - border: none; - object-fit: contain; - background: #000; - min-height: 200px; -} - -.cam-loading { - display: flex; - align-items: center; - justify-content: center; - color: #00ff88; - font-size: 18px; -} - -.hidden { - display: none; -} - /* ===== SPLIT LAYOUT ===== */ - .layout { display: flex; - height: 100vh; - width: 100vw; - overflow: hidden; + height: calc(100vh - 60px); + width: 100%; + margin: 0; + padding: 0; } .panel { height: 100%; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; } .left { - flex: 1; + flex: 1 1 auto; min-width: 300px; - transition: all 0.3s ease; + padding: 10px; + transition: flex 0.3s ease; } .right { flex: 0 0 40%; - min-width: 300px; - transition: all 0.3s ease; + min-width: 0; + background: black; + position: relative; + transition: flex 0.3s ease, min-width 0.3s ease, opacity 0.3s ease; + overflow: hidden; } .right.hidden { flex: 0 0 0 !important; min-width: 0 !important; width: 0 !important; - overflow: hidden; opacity: 0; + pointer-events: none; } /* DRAG BAR */ .resizer { - width: 6px; + width: 8px; + min-width: 8px; cursor: col-resize; background: #00ff88; - opacity: 0.25; - transition: opacity 0.2s; + opacity: 0.3; + transition: opacity 0.2s, width 0.3s ease, min-width 0.3s ease; + flex-shrink: 0; + z-index: 10; } .resizer:hover { + opacity: 0.8; +} + +.resizer:active { opacity: 1; } .resizer.hidden { - display: none; + width: 0 !important; + min-width: 0 !important; + opacity: 0; + pointer-events: none; +} + +/* Когда камера скрыта, левая панель занимает всё пространство */ +.layout:has(.right.hidden) .left { + flex: 1 1 100% !important; } /* ===== CAMERA ===== */ - .cam-modal { height: 100%; display: flex; flex-direction: column; background: black; + opacity: 1; + transition: opacity 0.2s ease; +} + +.right.hidden .cam-modal { + opacity: 0; + transition: opacity 0.2s ease; } .cam-header { @@ -341,6 +326,7 @@ h1 { color: #00ff88; } +/* ===== CAMERA BUTTON ===== */ .cam-btn { position: fixed; top: 20px; @@ -369,20 +355,6 @@ h1 { font-size: 18px; } -/* Панель камеры скрыта */ -.panel.right[style*="display: none"] { - display: none !important; -} - -/* Когда камера скрыта, левая панель на весь экран */ -.layout:has(.panel.right[style*="display: none"]) .left { - flex: 1; -} - -.layout:has(.panel.right[style*="display: none"]) .resizer { - display: none; -} - /* ===== MOBILE ===== */ @media (max-width: 500px) { h1 { @@ -407,4 +379,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; + } } \ No newline at end of file diff --git a/cmd/server/web/js/app.js b/cmd/server/web/js/app.js index 92d040b..b379801 100644 --- a/cmd/server/web/js/app.js +++ b/cmd/server/web/js/app.js @@ -25,8 +25,8 @@ let pollTimer = null; window.addEventListener("DOMContentLoaded", () => { initDOM(); initAccordion(); - initCamera(); initResize(); + initCamera(); }); document.addEventListener("keydown", (e) => { diff --git a/cmd/server/web/js/camera.js b/cmd/server/web/js/camera.js index 01db9f3..212af5d 100644 --- a/cmd/server/web/js/camera.js +++ b/cmd/server/web/js/camera.js @@ -1,29 +1,36 @@ -//camera.js -let rightPanel, camImage, camClose; +// camera.js +import { updateResizerVisibility } from './layout.js'; + +let rightPanel; +let camImage; +let camClose; + let isOpen = false; -let streamUrl; export function initCamera() { rightPanel = document.getElementById("rightPanel"); camImage = document.getElementById("camImage"); camClose = document.getElementById("camClose"); + const btn = document.getElementById("camToggle"); - streamUrl = "http://192.168.77.1:1984/api/stream.mjpeg?src=cam"; + const streamUrl = "/api/cam"; function open() { if (isOpen) return; isOpen = true; + rightPanel.classList.remove("hidden"); - camImage.src = streamUrl + "&t=" + Date.now(); - - // Обновляем текст кнопки + + camImage.src = streamUrl; + if (btn) { btn.querySelector('.cam-btn-text').textContent = 'Скрыть'; } - - console.log("[camera] Камера открыта"); + + console.log("[camera] opened"); + updateResizerVisibility(); } @@ -31,43 +38,40 @@ export function initCamera() { if (!isOpen) return; isOpen = false; - camImage.src = "about:blank"; - - setTimeout(() => { - camImage.src = ""; - }, 50); + + // остановка stream + camImage.src = ""; rightPanel.classList.add("hidden"); - - // Обновляем текст кнопки + if (btn) { btn.querySelector('.cam-btn-text').textContent = 'Камера'; } - - console.log("[camera] Камера закрыта"); + + console.log("[camera] closed"); + updateResizerVisibility(); } function toggle() { - isOpen ? close() : open(); + if (isOpen) { + close(); + } else { + open(); + } } - // Обработчики событий + camImage.onload = () => { + console.log("[camera] stream started"); + }; + + camImage.onerror = (e) => { + console.error("[camera] stream error", e); + }; + btn?.addEventListener("click", toggle); camClose?.addEventListener("click", close); - // Горячие клавиши - document.addEventListener("keydown", (e) => { - if (e.key === 'c' || e.key === 'C') { - toggle(); - } - if (e.key === 'Escape' && isOpen) { - close(); - } - }); - - // Автозапуск камеры + // автозапуск open(); - - console.log("[camera] Модуль камеры инициализирован"); } \ No newline at end of file diff --git a/cmd/server/web/js/layout.js b/cmd/server/web/js/layout.js index f276f06..632c8df 100644 --- a/cmd/server/web/js/layout.js +++ b/cmd/server/web/js/layout.js @@ -7,7 +7,12 @@ export function initResize() { rightPanel = document.getElementById("rightPanel"); dragBar = document.getElementById("dragBar"); - if (!leftPanel || !rightPanel || !dragBar) return; + if (!leftPanel || !rightPanel || !dragBar) { + console.warn("[layout] Элементы не найдены"); + return; + } + + console.log("[layout] Инициализация ресайза"); // Загрузка сохранённых пропорций const savedRatio = localStorage.getItem('layoutRatio'); @@ -17,48 +22,52 @@ export function initResize() { } dragBar.addEventListener("mousedown", (e) => { + if (rightPanel.classList.contains("hidden")) return; + + console.log("[layout] mousedown на resizer"); isDragging = true; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; e.preventDefault(); + e.stopPropagation(); }); document.addEventListener("mouseup", () => { if (isDragging) { + console.log("[layout] mouseup - конец перетаскивания"); isDragging = false; document.body.style.cursor = "default"; document.body.style.userSelect = ""; - // Сохраняем пропорции - const total = window.innerWidth; - const leftWidth = leftPanel.getBoundingClientRect().width; - const ratio = leftWidth / total; - localStorage.setItem('layoutRatio', ratio.toString()); + // Сохраняем пропорции только если камера открыта + if (!rightPanel.classList.contains("hidden")) { + const total = window.innerWidth; + const leftWidth = leftPanel.getBoundingClientRect().width; + const ratio = leftWidth / total; + localStorage.setItem('layoutRatio', ratio.toString()); + } } }); - // ЕДИНСТВЕННЫЙ обработчик mousemove document.addEventListener("mousemove", (e) => { if (!isDragging) return; - if (rightPanel.classList.contains("hidden")) return; // Не ресайзим если камера скрыта + if (rightPanel.classList.contains("hidden")) return; const total = window.innerWidth; let leftWidth = e.clientX; - // Минимальные размеры const minWidth = 300; if (leftWidth < minWidth) leftWidth = minWidth; if (leftWidth > total - minWidth) leftWidth = total - minWidth; const rightWidth = total - leftWidth - dragBar.offsetWidth; - leftPanel.style.flex = "none"; - rightPanel.style.flex = "none"; - leftPanel.style.width = leftWidth + "px"; - rightPanel.style.width = rightWidth + "px"; + leftPanel.style.flex = `0 0 ${leftWidth}px`; + leftPanel.style.width = `${leftWidth}px`; + rightPanel.style.flex = `0 0 ${rightWidth}px`; + rightPanel.style.width = `${rightWidth}px`; }); - // Адаптация при ресайзе окна window.addEventListener("resize", () => { if (rightPanel.classList.contains("hidden")) return; @@ -76,17 +85,26 @@ function applyRatio(ratio) { const leftWidth = Math.floor(total * ratio); const rightWidth = total - leftWidth - dragBar.offsetWidth; - leftPanel.style.flex = "none"; - rightPanel.style.flex = "none"; - leftPanel.style.width = leftWidth + "px"; - rightPanel.style.width = rightWidth + "px"; + leftPanel.style.flex = `0 0 ${leftWidth}px`; + leftPanel.style.width = `${leftWidth}px`; + rightPanel.style.flex = `0 0 ${rightWidth}px`; + rightPanel.style.width = `${rightWidth}px`; } export function updateResizerVisibility() { - if (!dragBar) return; - if (rightPanel?.classList.contains("hidden")) { + if (!dragBar || !rightPanel) return; + + if (rightPanel.classList.contains("hidden")) { dragBar.classList.add("hidden"); + // Сбрасываем инлайн-стили левой панели, чтобы CSS flex заработал + leftPanel.style.flex = ""; + leftPanel.style.width = ""; } else { dragBar.classList.remove("hidden"); + // Восстанавливаем сохранённые пропорции + const savedRatio = localStorage.getItem('layoutRatio'); + if (savedRatio) { + applyRatio(parseFloat(savedRatio)); + } } } \ No newline at end of file