mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
sync
This commit is contained in:
@@ -15,6 +15,8 @@ from backend.app.services.camera import (
|
||||
build_camera_url,
|
||||
capture_camera_frame,
|
||||
test_camera_connection,
|
||||
get_ffmpeg_path,
|
||||
get_camera_port,
|
||||
)
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
@@ -41,20 +43,29 @@ async def generate_mjpeg_stream(
|
||||
|
||||
This captures frames continuously and yields them in MJPEG format.
|
||||
"""
|
||||
from backend.app.services.camera import get_camera_port
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
if not ffmpeg:
|
||||
logger.error("ffmpeg not found - camera streaming requires ffmpeg")
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: text/plain\r\n\r\n"
|
||||
b"Error: ffmpeg not installed\r\n"
|
||||
)
|
||||
return
|
||||
|
||||
port = get_camera_port(model)
|
||||
camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
|
||||
|
||||
# ffmpeg command to output MJPEG stream to stdout
|
||||
# -re: Read input at native frame rate
|
||||
# -rtsp_transport tcp: Use TCP for reliability
|
||||
# -rtsp_flags prefer_tcp: Prefer TCP for RTSP
|
||||
# -f mjpeg: Output as MJPEG
|
||||
# -q:v 5: Quality (lower = better, 2-10 is good range)
|
||||
# -r: Output framerate
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
ffmpeg,
|
||||
"-rtsp_transport", "tcp",
|
||||
"-rtsp_flags", "prefer_tcp",
|
||||
"-i", camera_url,
|
||||
"-f", "mjpeg",
|
||||
"-q:v", "5",
|
||||
@@ -63,7 +74,8 @@ async def generate_mjpeg_stream(
|
||||
"-" # Output to stdout
|
||||
]
|
||||
|
||||
logger.info(f"Starting camera stream for {ip_address}")
|
||||
logger.info(f"Starting camera stream for {ip_address} using URL: rtsps://bblp:***@{ip_address}:{port}/streaming/live/1")
|
||||
logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
|
||||
|
||||
process = None
|
||||
try:
|
||||
@@ -73,6 +85,18 @@ async def generate_mjpeg_stream(
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Give ffmpeg a moment to start and check for immediate failures
|
||||
await asyncio.sleep(0.5)
|
||||
if process.returncode is not None:
|
||||
stderr = await process.stderr.read()
|
||||
logger.error(f"ffmpeg failed immediately: {stderr.decode()}")
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: text/plain\r\n\r\n"
|
||||
b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n"
|
||||
)
|
||||
return
|
||||
|
||||
# Read JPEG frames from ffmpeg output
|
||||
# JPEG images start with 0xFFD8 and end with 0xFFD9
|
||||
buffer = b""
|
||||
|
||||
@@ -190,8 +190,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
|
||||
# humidity_raw is the actual percentage value from the sensor
|
||||
humidity_raw = ams_data.get("humidity_raw")
|
||||
humidity_idx = ams_data.get("humidity")
|
||||
# Use humidity_raw if available, otherwise fall back to humidity index
|
||||
humidity_value = None
|
||||
|
||||
if humidity_raw is not None:
|
||||
try:
|
||||
humidity_value = int(humidity_raw)
|
||||
@@ -202,10 +202,14 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
|
||||
humidity_value = int(humidity_idx)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# AMS-HT has 1 tray, regular AMS has 4 trays
|
||||
is_ams_ht = len(trays) == 1
|
||||
|
||||
ams_units.append(AMSUnit(
|
||||
id=ams_data.get("id", 0),
|
||||
humidity=humidity_value,
|
||||
temp=ams_data.get("temp"),
|
||||
is_ams_ht=is_ams_ht,
|
||||
tray=trays,
|
||||
))
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -95,21 +92,9 @@ async def reset_settings(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/check-ffmpeg")
|
||||
async def check_ffmpeg():
|
||||
"""Check if ffmpeg is installed and available."""
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
from backend.app.services.camera import get_ffmpeg_path
|
||||
|
||||
# If not found via PATH, check common installation locations
|
||||
# (systemd services often have limited PATH)
|
||||
if ffmpeg_path is None:
|
||||
common_paths = [
|
||||
"/usr/bin/ffmpeg",
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"/snap/bin/ffmpeg",
|
||||
]
|
||||
for path in common_paths:
|
||||
if Path(path).exists():
|
||||
ffmpeg_path = path
|
||||
break
|
||||
ffmpeg_path = get_ffmpeg_path()
|
||||
|
||||
return {
|
||||
"installed": ffmpeg_path is not None,
|
||||
|
||||
@@ -100,3 +100,12 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
# Column already exists
|
||||
pass
|
||||
|
||||
# Migration: Add location column to printers for grouping
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE printers ADD COLUMN location VARCHAR(100)"
|
||||
))
|
||||
except Exception:
|
||||
# Column already exists
|
||||
pass
|
||||
|
||||
@@ -14,6 +14,7 @@ class Printer(Base):
|
||||
ip_address: Mapped[str] = mapped_column(String(45))
|
||||
access_code: Mapped[str] = mapped_column(String(20))
|
||||
model: Mapped[str | None] = mapped_column(String(50))
|
||||
location: Mapped[str | None] = mapped_column(String(100)) # Group/location name
|
||||
nozzle_count: Mapped[int] = mapped_column(default=1) # 1 or 2, auto-detected from MQTT
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
@@ -8,6 +8,7 @@ class PrinterBase(BaseModel):
|
||||
ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
|
||||
access_code: str = Field(..., min_length=1, max_length=20)
|
||||
model: str | None = None
|
||||
location: str | None = None # Group/location name
|
||||
auto_archive: bool = True
|
||||
|
||||
|
||||
@@ -20,6 +21,7 @@ class PrinterUpdate(BaseModel):
|
||||
ip_address: str | None = None
|
||||
access_code: str | None = None
|
||||
model: str | None = None
|
||||
location: str | None = None
|
||||
is_active: bool | None = None
|
||||
auto_archive: bool | None = None
|
||||
print_hours_offset: float | None = None
|
||||
@@ -63,6 +65,7 @@ class AMSUnit(BaseModel):
|
||||
id: int
|
||||
humidity: int | None = None
|
||||
temp: float | None = None
|
||||
is_ams_ht: bool = False # True for AMS-HT (single spool), False for regular AMS (4 spools)
|
||||
tray: list[AMSTray] = []
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Captures images from the printer's RTSPS camera stream using ffmpeg.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
@@ -14,6 +14,46 @@ from backend.app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache the ffmpeg path after first lookup
|
||||
_ffmpeg_path: str | None = None
|
||||
|
||||
|
||||
def get_ffmpeg_path() -> str | None:
|
||||
"""Find the ffmpeg executable path.
|
||||
|
||||
Uses shutil.which first, then checks common installation locations
|
||||
for systems where PATH may be limited (e.g., systemd services).
|
||||
"""
|
||||
global _ffmpeg_path
|
||||
|
||||
if _ffmpeg_path is not None:
|
||||
return _ffmpeg_path
|
||||
|
||||
# Try PATH first
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
|
||||
# If not found via PATH, check common installation locations
|
||||
if ffmpeg_path is None:
|
||||
common_paths = [
|
||||
"/usr/bin/ffmpeg",
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg", # macOS Homebrew
|
||||
"/snap/bin/ffmpeg", # Ubuntu Snap
|
||||
"C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common
|
||||
]
|
||||
for path in common_paths:
|
||||
if Path(path).exists():
|
||||
ffmpeg_path = path
|
||||
break
|
||||
|
||||
_ffmpeg_path = ffmpeg_path
|
||||
if ffmpeg_path:
|
||||
logger.info(f"Found ffmpeg at: {ffmpeg_path}")
|
||||
else:
|
||||
logger.warning("ffmpeg not found in PATH or common locations")
|
||||
|
||||
return ffmpeg_path
|
||||
|
||||
|
||||
def get_camera_port(model: str | None) -> int:
|
||||
"""Get the RTSPS port based on printer model.
|
||||
@@ -59,17 +99,26 @@ async def capture_camera_frame(
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
if not ffmpeg:
|
||||
logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.")
|
||||
return False
|
||||
|
||||
# ffmpeg command to capture a single frame from RTSPS stream
|
||||
# -rtsp_transport tcp: Use TCP for RTSP (more reliable)
|
||||
# -rtsp_flags prefer_tcp: Prefer TCP for RTSP
|
||||
# -y: Overwrite output file
|
||||
# -frames:v 1: Capture only 1 frame
|
||||
# -update 1: Allow writing single image without sequence pattern
|
||||
# -q:v 2: High quality JPEG (1-31, lower is better)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
ffmpeg,
|
||||
"-y", # Overwrite output
|
||||
"-rtsp_transport", "tcp",
|
||||
"-rtsp_flags", "prefer_tcp",
|
||||
"-i", camera_url,
|
||||
"-frames:v", "1",
|
||||
"-update", "1",
|
||||
"-q:v", "2",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
@@ -268,6 +268,74 @@ class PrinterManager:
|
||||
|
||||
def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict:
|
||||
"""Convert PrinterState to a JSON-serializable dict."""
|
||||
# Parse AMS data from raw_data
|
||||
ams_units = []
|
||||
vt_tray = None
|
||||
raw_data = state.raw_data or {}
|
||||
|
||||
if "ams" in raw_data and isinstance(raw_data["ams"], list):
|
||||
for ams_data in raw_data["ams"]:
|
||||
trays = []
|
||||
for tray in ams_data.get("tray", []):
|
||||
tag_uid = tray.get("tag_uid")
|
||||
if tag_uid in ("", "0000000000000000"):
|
||||
tag_uid = None
|
||||
tray_uuid = tray.get("tray_uuid")
|
||||
if tray_uuid in ("", "00000000000000000000000000000000"):
|
||||
tray_uuid = None
|
||||
trays.append({
|
||||
"id": tray.get("id", 0),
|
||||
"tray_color": tray.get("tray_color"),
|
||||
"tray_type": tray.get("tray_type"),
|
||||
"tray_sub_brands": tray.get("tray_sub_brands"),
|
||||
"remain": tray.get("remain", 0),
|
||||
"k": tray.get("k"),
|
||||
"tag_uid": tag_uid,
|
||||
"tray_uuid": tray_uuid,
|
||||
})
|
||||
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
|
||||
humidity_raw = ams_data.get("humidity_raw")
|
||||
humidity_idx = ams_data.get("humidity")
|
||||
humidity_value = None
|
||||
|
||||
if humidity_raw is not None:
|
||||
try:
|
||||
humidity_value = int(humidity_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Fall back to index if no raw value (index is 1-5, not percentage)
|
||||
if humidity_value is None and humidity_idx is not None:
|
||||
try:
|
||||
humidity_value = int(humidity_idx)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# AMS-HT has 1 tray, regular AMS has 4 trays
|
||||
is_ams_ht = len(trays) == 1
|
||||
|
||||
ams_units.append({
|
||||
"id": ams_data.get("id", 0),
|
||||
"humidity": humidity_value,
|
||||
"temp": ams_data.get("temp"),
|
||||
"is_ams_ht": is_ams_ht,
|
||||
"tray": trays,
|
||||
})
|
||||
|
||||
# Parse virtual tray (external spool)
|
||||
if "vt_tray" in raw_data:
|
||||
vt_data = raw_data["vt_tray"]
|
||||
vt_tag_uid = vt_data.get("tag_uid")
|
||||
if vt_tag_uid in ("", "0000000000000000"):
|
||||
vt_tag_uid = None
|
||||
vt_tray = {
|
||||
"id": 254,
|
||||
"tray_color": vt_data.get("tray_color"),
|
||||
"tray_type": vt_data.get("tray_type"),
|
||||
"tray_sub_brands": vt_data.get("tray_sub_brands"),
|
||||
"remain": vt_data.get("remain", 0),
|
||||
"tag_uid": vt_tag_uid,
|
||||
}
|
||||
|
||||
result = {
|
||||
"connected": state.connected,
|
||||
"state": state.state,
|
||||
@@ -283,6 +351,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
|
||||
{"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
|
||||
for e in (state.hms_errors or [])
|
||||
],
|
||||
# AMS data for filament colors
|
||||
"ams": ams_units if ams_units else None,
|
||||
"vt_tray": vt_tray,
|
||||
# AMS status for filament change tracking
|
||||
"ams_status_main": state.ams_status_main,
|
||||
"ams_status_sub": state.ams_status_sub,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { ProfilesPage } from './pages/ProfilesPage';
|
||||
import { MaintenancePage } from './pages/MaintenancePage';
|
||||
import { CameraPage } from './pages/CameraPage';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
@@ -34,6 +35,9 @@ function App() {
|
||||
<WebSocketProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Camera page - standalone, no layout */}
|
||||
<Route path="/camera/:printerId" element={<CameraPage />} />
|
||||
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<PrintersPage />} />
|
||||
<Route path="archives" element={<ArchivesPage />} />
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Printer {
|
||||
ip_address: string;
|
||||
access_code: string;
|
||||
model: string | null;
|
||||
location: string | null; // Group/location name
|
||||
nozzle_count: number; // 1 or 2, auto-detected from MQTT
|
||||
is_active: boolean;
|
||||
auto_archive: boolean;
|
||||
@@ -61,6 +62,7 @@ export interface AMSUnit {
|
||||
id: number;
|
||||
humidity: number | null;
|
||||
temp: number | null;
|
||||
is_ams_ht: boolean; // True for AMS-HT (single spool), False for regular AMS (4 spools)
|
||||
tray: AMSTray[];
|
||||
}
|
||||
|
||||
@@ -106,6 +108,8 @@ export interface PrinterStatus {
|
||||
bed_target?: number;
|
||||
nozzle?: number;
|
||||
nozzle_target?: number;
|
||||
nozzle_2?: number; // Second nozzle for H2 series (dual nozzle)
|
||||
nozzle_2_target?: number;
|
||||
chamber?: number;
|
||||
} | null;
|
||||
cover_url: string | null;
|
||||
@@ -157,6 +161,7 @@ export interface PrinterCreate {
|
||||
ip_address: string;
|
||||
access_code: string;
|
||||
model?: string;
|
||||
location?: string;
|
||||
auto_archive?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,14 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-2 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Calendar className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to="/queue"
|
||||
className="block mb-3 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-bambu-gray">Next in queue</p>
|
||||
<p className="text-sm text-white truncate">
|
||||
{nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
|
||||
@@ -56,15 +59,9 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
|
||||
+{totalPending - 1}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to="/queue"
|
||||
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors text-bambu-gray hover:text-white"
|
||||
title="View queue"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-bambu-gray" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,3 +122,24 @@ body {
|
||||
.dark .jogpad-theme {
|
||||
filter: brightness(0.35) contrast(1.2);
|
||||
}
|
||||
|
||||
/* Empty AMS slot with diagonal stripes */
|
||||
.ams-empty-slot {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
#444,
|
||||
#444 2px,
|
||||
#222 2px,
|
||||
#222 4px
|
||||
);
|
||||
}
|
||||
|
||||
.dark .ams-empty-slot {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
#555,
|
||||
#555 2px,
|
||||
#333 2px,
|
||||
#333 4px
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function CameraPage() {
|
||||
const { printerId } = useParams<{ printerId: string }>();
|
||||
const id = parseInt(printerId || '0', 10);
|
||||
|
||||
const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');
|
||||
const [streamError, setStreamError] = useState(false);
|
||||
const [streamLoading, setStreamLoading] = useState(true);
|
||||
const [imageKey, setImageKey] = useState(Date.now());
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch printer info for the title
|
||||
const { data: printer } = useQuery({
|
||||
queryKey: ['printer', id],
|
||||
queryFn: () => api.getPrinter(id),
|
||||
enabled: id > 0,
|
||||
});
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
if (printer) {
|
||||
document.title = `${printer.name} - Camera`;
|
||||
}
|
||||
return () => {
|
||||
document.title = 'Bambusy';
|
||||
};
|
||||
}, [printer]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = '';
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-hide loading after timeout
|
||||
useEffect(() => {
|
||||
if (streamLoading && !transitioning) {
|
||||
const timeout = streamMode === 'stream' ? 3000 : 20000;
|
||||
const timer = setTimeout(() => {
|
||||
setStreamLoading(false);
|
||||
}, timeout);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [streamMode, streamLoading, imageKey, transitioning]);
|
||||
|
||||
// Fullscreen change listener
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const handleStreamError = () => {
|
||||
setStreamError(true);
|
||||
setStreamLoading(false);
|
||||
};
|
||||
|
||||
const handleStreamLoad = () => {
|
||||
setStreamLoading(false);
|
||||
setStreamError(false);
|
||||
};
|
||||
|
||||
const switchToMode = (newMode: 'stream' | 'snapshot') => {
|
||||
if (streamMode === newMode || transitioning) return;
|
||||
setTransitioning(true);
|
||||
setStreamLoading(true);
|
||||
setStreamError(false);
|
||||
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = '';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setStreamMode(newMode);
|
||||
setImageKey(Date.now());
|
||||
setTransitioning(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (transitioning) return;
|
||||
setTransitioning(true);
|
||||
setStreamLoading(true);
|
||||
setStreamError(false);
|
||||
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = '';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setImageKey(Date.now());
|
||||
setTransitioning(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const currentUrl = transitioning
|
||||
? ''
|
||||
: streamMode === 'stream'
|
||||
? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
|
||||
: `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
|
||||
|
||||
const isDisabled = streamLoading || transitioning;
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<p className="text-white">Invalid printer ID</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="min-h-screen bg-black flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
|
||||
<h1 className="text-sm font-medium text-white flex items-center gap-2">
|
||||
<Camera className="w-4 h-4" />
|
||||
{printer?.name || `Printer ${id}`}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex bg-bambu-dark rounded p-0.5">
|
||||
<button
|
||||
onClick={() => switchToMode('stream')}
|
||||
disabled={isDisabled}
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${
|
||||
streamMode === 'stream'
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'text-bambu-gray hover:text-white disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
Live
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchToMode('snapshot')}
|
||||
disabled={isDisabled}
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${
|
||||
streamMode === 'snapshot'
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'text-bambu-gray hover:text-white disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
Snapshot
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isDisabled}
|
||||
className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
|
||||
title={streamMode === 'stream' ? 'Restart stream' : 'Refresh snapshot'}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="w-4 h-4 text-bambu-gray" />
|
||||
) : (
|
||||
<Maximize className="w-4 h-4 text-bambu-gray" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video area */}
|
||||
<div className="flex-1 flex items-center justify-center p-2">
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
{(streamLoading || transitioning) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{streamError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black z-10">
|
||||
<div className="text-center p-4">
|
||||
<AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
|
||||
<p className="text-white mb-2">Camera unavailable</p>
|
||||
<p className="text-xs text-bambu-gray mb-4 max-w-md">
|
||||
Make sure the printer is powered on and connected.
|
||||
</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
ref={imgRef}
|
||||
key={imageKey}
|
||||
src={currentUrl}
|
||||
alt="Camera stream"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onError={currentUrl ? handleStreamError : undefined}
|
||||
onLoad={currentUrl ? handleStreamLoad : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+781
-188
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-B5zVKOmX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BkIG8nsB.css">
|
||||
<script type="module" crossorigin src="/assets/index-DzQp7Kty.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-SGO6jLr2.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user