камера на локальном хосте теперь
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,8 @@ let pollTimer = null;
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initDOM();
|
||||
initAccordion();
|
||||
initCamera();
|
||||
initResize();
|
||||
initCamera();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
|
||||
@@ -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] Модуль камеры инициализирован");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user