fix:改进atx usb 继电器适配;修复 webrtc 无法建立连接问题;网页样式优化

This commit is contained in:
mofeng-git
2026-05-05 00:52:16 +08:00
parent 6723f432a3
commit c27d3a6703
27 changed files with 1388 additions and 709 deletions

View File

@@ -7,6 +7,7 @@ use gpio_cdev::{Chip, LineHandle, LineRequestFlags};
use serialport::SerialPort; use serialport::SerialPort;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::Write; use std::io::Write;
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@@ -18,6 +19,10 @@ use crate::error::{AppError, Result};
pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>; pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>;
const USB_RELAY_MAX_CHANNEL: u8 = 8;
const USB_RELAY_REPORT_LEN: usize = 9;
const HIDIOCSFEATURE_9: libc::c_ulong = 0xC009_4806; // _IOC(_IOC_READ|_IOC_WRITE, 'H', 0x06, 9)
/// Timing constants for ATX operations /// Timing constants for ATX operations
pub mod timing { pub mod timing {
use std::time::Duration; use std::time::Duration;
@@ -129,12 +134,23 @@ impl AtxKeyExecutor {
} }
} }
AtxDriverType::UsbRelay => { AtxDriverType::UsbRelay => {
if self.config.pin == 0 {
return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(),
));
}
if self.config.pin > u8::MAX as u32 { if self.config.pin > u8::MAX as u32 {
return Err(AppError::Config(format!( return Err(AppError::Config(format!(
"USB relay channel must be <= {}", "USB relay channel must be <= {}",
u8::MAX u8::MAX
))); )));
} }
if self.config.pin > USB_RELAY_MAX_CHANNEL as u32 {
return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
)));
}
} }
AtxDriverType::Gpio | AtxDriverType::None => {} AtxDriverType::Gpio | AtxDriverType::None => {}
} }
@@ -292,26 +308,64 @@ impl AtxKeyExecutor {
u8::MAX u8::MAX
)) ))
})?; })?;
if channel == 0 {
return Err(AppError::Config(
"USB relay channel must be 1-based (>= 1)".to_string(),
));
}
if channel > USB_RELAY_MAX_CHANNEL {
return Err(AppError::Config(format!(
"USB HID relay channel must be <= {}",
USB_RELAY_MAX_CHANNEL
)));
}
// Standard HID relay command format let cmd = Self::build_usb_relay_command(channel, on);
let cmd = if on {
[0x00, channel + 1, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00]
} else {
[0x00, channel + 1, 0xFD, 0x00, 0x00, 0x00, 0x00, 0x00]
};
let mut guard = self.usb_relay_handle.lock().unwrap(); let mut guard = self.usb_relay_handle.lock().unwrap();
let device = guard let device = guard
.as_mut() .as_mut()
.ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?; .ok_or_else(|| AppError::Internal("USB relay not initialized".to_string()))?;
if let Err(feature_err) = Self::send_usb_relay_feature_report(device, &cmd) {
debug!(
"USB relay feature report failed ({}), falling back to hidraw write",
feature_err
);
device.write_all(&cmd).map_err(|write_err| {
AppError::Internal(format!(
"USB relay feature report failed: {}; raw write failed: {}",
feature_err, write_err
))
})?;
device device
.write_all(&cmd) .flush()
.map_err(|e| AppError::Internal(format!("USB relay write failed: {}", e)))?; .map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?;
}
Ok(()) Ok(())
} }
fn build_usb_relay_command(channel: u8, on: bool) -> [u8; USB_RELAY_REPORT_LEN] {
let mut cmd = [0x00; USB_RELAY_REPORT_LEN];
cmd[1] = if on { 0xFF } else { 0xFD };
cmd[2] = channel;
cmd
}
fn send_usb_relay_feature_report(
device: &File,
report: &[u8; USB_RELAY_REPORT_LEN],
) -> std::io::Result<()> {
// Linux hidraw feature reports include the report ID as the first byte.
let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9, report.as_ptr()) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
/// Pulse Serial relay /// Pulse Serial relay
async fn pulse_serial(&self, duration: Duration) -> Result<()> { async fn pulse_serial(&self, duration: Duration) -> Result<()> {
info!( info!(
@@ -367,6 +421,8 @@ impl AtxKeyExecutor {
port.write_all(&cmd) port.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?; .map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?;
port.flush()
.map_err(|e| AppError::Internal(format!("Serial relay flush failed: {}", e)))?;
Ok(()) Ok(())
} }
@@ -453,7 +509,7 @@ mod tests {
let config = AtxKeyConfig { let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay, driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(), device: "/dev/hidraw0".to_string(),
pin: 0, pin: 1,
active_level: ActiveLevel::High, // Ignored for USB relay active_level: ActiveLevel::High, // Ignored for USB relay
baud_rate: 9600, baud_rate: 9600,
}; };
@@ -481,6 +537,18 @@ mod tests {
assert_eq!(timing::RESET_PRESS.as_millis(), 500); assert_eq!(timing::RESET_PRESS.as_millis(), 500);
} }
#[test]
fn test_usb_relay_command_format() {
assert_eq!(
AtxKeyExecutor::build_usb_relay_command(1, true),
[0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
);
assert_eq!(
AtxKeyExecutor::build_usb_relay_command(1, false),
[0x00, 0xFD, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
);
}
#[tokio::test] #[tokio::test]
async fn test_executor_init_rejects_serial_channel_zero() { async fn test_executor_init_rejects_serial_channel_zero() {
let config = AtxKeyConfig { let config = AtxKeyConfig {
@@ -495,6 +563,34 @@ mod tests {
assert!(matches!(err, AppError::Config(_))); assert!(matches!(err, AppError::Config(_)));
} }
#[tokio::test]
async fn test_executor_init_rejects_usb_relay_channel_zero() {
let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(),
pin: 0,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test]
async fn test_executor_init_rejects_usb_relay_channel_overflow() {
let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(),
pin: USB_RELAY_MAX_CHANNEL as u32 + 1,
active_level: ActiveLevel::High,
baud_rate: 9600,
};
let mut executor = AtxKeyExecutor::new(config);
let err = executor.init().await.unwrap_err();
assert!(matches!(err, AppError::Config(_)));
}
#[tokio::test] #[tokio::test]
async fn test_executor_init_rejects_serial_channel_overflow() { async fn test_executor_init_rejects_serial_channel_overflow() {
let config = AtxKeyConfig { let config = AtxKeyConfig {

View File

@@ -15,6 +15,7 @@
//! //!
//! - **GPIO**: Uses Linux GPIO character device (/dev/gpiochipX) for direct hardware control //! - **GPIO**: Uses Linux GPIO character device (/dev/gpiochipX) for direct hardware control
//! - **USB Relay**: Uses HID USB relay modules for isolated switching //! - **USB Relay**: Uses HID USB relay modules for isolated switching
//! - **Serial Relay**: Uses LCUS-style serial relay modules
//! //!
//! # Example //! # Example
//! //!
@@ -59,9 +60,25 @@ pub use types::{
}; };
pub use wol::send_wol; pub use wol::send_wol;
fn hidraw_uevent_is_usb_relay(uevent: &str) -> bool {
let upper = uevent.to_ascii_uppercase();
upper.contains("000016C0:000005DF")
|| upper.contains("16C0:05DF")
|| upper.contains("PRODUCT=16C0/5DF")
|| upper.contains("USBRELAY")
|| upper.contains("USB RELAY")
}
fn is_usb_relay_hidraw(name: &str) -> bool {
let uevent_path = format!("/sys/class/hidraw/{}/device/uevent", name);
std::fs::read_to_string(uevent_path)
.map(|uevent| hidraw_uevent_is_usb_relay(&uevent))
.unwrap_or(false)
}
/// Discover available ATX devices on the system /// Discover available ATX devices on the system
/// ///
/// Scans for GPIO chips and USB HID relay devices in a single pass. /// Scans for GPIO chips, LCUS USB HID relay devices, and serial relay ports.
pub fn discover_devices() -> AtxDevices { pub fn discover_devices() -> AtxDevices {
let mut devices = AtxDevices::default(); let mut devices = AtxDevices::default();
@@ -72,7 +89,7 @@ pub fn discover_devices() -> AtxDevices {
let name_str = name.to_string_lossy(); let name_str = name.to_string_lossy();
if name_str.starts_with("gpiochip") { if name_str.starts_with("gpiochip") {
devices.gpio_chips.push(format!("/dev/{}", name_str)); devices.gpio_chips.push(format!("/dev/{}", name_str));
} else if name_str.starts_with("hidraw") { } else if name_str.starts_with("hidraw") && is_usb_relay_hidraw(&name_str) {
devices.usb_relays.push(format!("/dev/{}", name_str)); devices.usb_relays.push(format!("/dev/{}", name_str));
} else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") { } else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") {
devices.serial_ports.push(format!("/dev/{}", name_str)); devices.serial_ports.push(format!("/dev/{}", name_str));
@@ -96,6 +113,20 @@ mod tests {
let _devices = discover_devices(); let _devices = discover_devices();
} }
#[test]
fn test_hidraw_uevent_detects_usb_relay_id() {
assert!(hidraw_uevent_is_usb_relay(
"HID_ID=0003:000016C0:000005DF\nHID_NAME=www.dcttech.com USBRelay2\n"
));
}
#[test]
fn test_hidraw_uevent_rejects_unrelated_hid() {
assert!(!hidraw_uevent_is_usb_relay(
"HID_ID=0003:0000046D:0000C534\nHID_NAME=Logitech USB Receiver\n"
));
}
#[test] #[test]
fn test_module_exports() { fn test_module_exports() {
// Verify all public exports are accessible // Verify all public exports are accessible

View File

@@ -61,7 +61,7 @@ pub struct AtxKeyConfig {
pub device: String, pub device: String,
/// Pin or channel number: /// Pin or channel number:
/// - For GPIO: GPIO pin number /// - For GPIO: GPIO pin number
/// - For USB Relay: relay channel (0-based) /// - For USB Relay: relay channel (1-based)
/// - For Serial Relay (LCUS): relay channel (1-based) /// - For Serial Relay (LCUS): relay channel (1-based)
pub pin: u32, pub pin: u32,
/// Active level (only applicable to GPIO, ignored for USB Relay) /// Active level (only applicable to GPIO, ignored for USB Relay)

View File

@@ -17,7 +17,7 @@ use super::encoder::{OpusConfig, OpusFrame};
use super::monitor::AudioHealthMonitor; use super::monitor::AudioHealthMonitor;
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig}; use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent}; use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
const AUDIO_RECOVERY_RETRY_DELAY: Duration = Duration::from_secs(1); const AUDIO_RECOVERY_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -165,6 +165,7 @@ impl AudioController {
) { ) {
if let Some(ref bus) = *event_bus.read().await { if let Some(ref bus) = *event_bus.read().await {
bus.publish(SystemEvent::StreamDeviceLost { bus.publish(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: device.to_string(), device: device.to_string(),
reason: reason.to_string(), reason: reason.to_string(),
}); });

View File

@@ -6,7 +6,7 @@ use self::types::EXACT_EVENT_TOPICS;
pub use types::{ pub use types::{
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo, AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
SystemEvent, TtydDeviceInfo, VideoDeviceInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
}; };
use tokio::sync::broadcast; use tokio::sync::broadcast;

View File

@@ -79,6 +79,14 @@ pub struct ClientStats {
pub connected_secs: u64, pub connected_secs: u64,
} }
/// Video vs audio source for [`SystemEvent::StreamDeviceLost`] (WebSocket `stream.device_lost`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamDeviceLostKind {
Video,
Audio,
}
/// JSON: `{"event": "<name>", "data": { ... }}`. /// JSON: `{"event": "<name>", "data": { ... }}`.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "event", content = "data")] #[serde(tag = "event", content = "data")]
@@ -119,7 +127,11 @@ pub enum SystemEvent {
}, },
#[serde(rename = "stream.device_lost")] #[serde(rename = "stream.device_lost")]
StreamDeviceLost { device: String, reason: String }, StreamDeviceLost {
kind: StreamDeviceLostKind,
device: String,
reason: String,
},
#[serde(rename = "stream.reconnecting")] #[serde(rename = "stream.reconnecting")]
StreamReconnecting { device: String, attempt: u32 }, StreamReconnecting { device: String, attempt: u32 },
@@ -255,6 +267,19 @@ mod tests {
assert_eq!(event.event_name(), "stream.state_changed"); assert_eq!(event.event_name(), "stream.state_changed");
} }
#[test]
fn stream_device_lost_json_snake_case_kind() {
let event = SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: "hw:0,0".to_string(),
reason: "test".to_string(),
};
let v = serde_json::to_value(&event).unwrap();
let data = v.get("data").unwrap();
assert_eq!(data.get("kind").and_then(|x| x.as_str()), Some("audio"));
assert_eq!(data.get("device").and_then(|x| x.as_str()), Some("hw:0,0"));
}
#[test] #[test]
fn exact_topics_covers_all_variants() { fn exact_topics_covers_all_variants() {
use std::collections::HashSet; use std::collections::HashSet;
@@ -283,6 +308,7 @@ mod tests {
fps: 0, fps: 0,
}, },
SystemEvent::StreamDeviceLost { SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
device: String::new(), device: String::new(),
reason: String::new(), reason: String::new(),
}, },

View File

@@ -8,6 +8,9 @@ use std::time::{Duration, Instant};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
/// Generation token paired with `client_id` so [`unregister_client`] ignores stale drops.
pub type ClientGeneration = u64;
use crate::video::encoder::traits::{Encoder, EncoderConfig}; use crate::video::encoder::traits::{Encoder, EncoderConfig};
use crate::video::encoder::JpegEncoder; use crate::video::encoder::JpegEncoder;
use crate::video::format::PixelFormat; use crate::video::format::PixelFormat;
@@ -18,6 +21,7 @@ pub type ClientId = String;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClientSession { pub struct ClientSession {
pub id: ClientId, pub id: ClientId,
pub generation: ClientGeneration,
pub connected_at: Instant, pub connected_at: Instant,
pub last_activity: Instant, pub last_activity: Instant,
pub frames_sent: u64, pub frames_sent: u64,
@@ -25,10 +29,11 @@ pub struct ClientSession {
} }
impl ClientSession { impl ClientSession {
pub fn new(id: ClientId) -> Self { pub fn new(id: ClientId, generation: ClientGeneration) -> Self {
let now = Instant::now(); let now = Instant::now();
Self { Self {
id, id,
generation,
connected_at: now, connected_at: now,
last_activity: now, last_activity: now,
frames_sent: 0, frames_sent: 0,
@@ -45,7 +50,6 @@ impl ClientSession {
pub struct FpsCalculator { pub struct FpsCalculator {
frame_times: VecDeque<Instant>, frame_times: VecDeque<Instant>,
window: Duration, window: Duration,
count_in_window: usize,
} }
impl FpsCalculator { impl FpsCalculator {
@@ -53,29 +57,27 @@ impl FpsCalculator {
Self { Self {
frame_times: VecDeque::with_capacity(120), frame_times: VecDeque::with_capacity(120),
window: Duration::from_secs(1), window: Duration::from_secs(1),
count_in_window: 0,
} }
} }
pub fn record_frame(&mut self) { pub fn record_frame(&mut self) {
let now = Instant::now(); let now = Instant::now();
self.frame_times.push_back(now); self.frame_times.push_back(now);
self.prune(now);
}
/// Rolling-window FPS sample count (~1s).
pub fn current_fps(&mut self) -> u32 {
self.prune(Instant::now());
self.frame_times.len() as u32
}
fn prune(&mut self, now: Instant) {
let cutoff = now - self.window; let cutoff = now - self.window;
while let Some(&oldest) = self.frame_times.front() { while matches!(self.frame_times.front(), Some(&t) if t < cutoff) {
if oldest < cutoff {
self.frame_times.pop_front(); self.frame_times.pop_front();
} else {
break;
} }
} }
self.count_in_window = self.frame_times.len();
}
pub fn current_fps(&self) -> u32 {
self.count_in_window as u32
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -101,6 +103,7 @@ pub struct MjpegStreamHandler {
online: AtomicBool, online: AtomicBool,
sequence: AtomicU64, sequence: AtomicU64,
clients: ParkingRwLock<HashMap<ClientId, ClientSession>>, clients: ParkingRwLock<HashMap<ClientId, ClientSession>>,
next_generation: AtomicU64,
auto_pause_config: ParkingRwLock<AutoPauseConfig>, auto_pause_config: ParkingRwLock<AutoPauseConfig>,
last_frame_ts: ParkingRwLock<Option<Instant>>, last_frame_ts: ParkingRwLock<Option<Instant>>,
dropped_same_frames: AtomicU64, dropped_same_frames: AtomicU64,
@@ -122,6 +125,7 @@ impl MjpegStreamHandler {
online: AtomicBool::new(false), online: AtomicBool::new(false),
sequence: AtomicU64::new(0), sequence: AtomicU64::new(0),
clients: ParkingRwLock::new(HashMap::new()), clients: ParkingRwLock::new(HashMap::new()),
next_generation: AtomicU64::new(1),
jpeg_encoder: ParkingMutex::new(None), jpeg_encoder: ParkingMutex::new(None),
auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()), auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()),
last_frame_ts: ParkingRwLock::new(None), last_frame_ts: ParkingRwLock::new(None),
@@ -292,18 +296,26 @@ impl MjpegStreamHandler {
self.clients.read().len() as u64 self.clients.read().len() as u64
} }
pub fn register_client(&self, client_id: ClientId) { /// Connects `client_id`; return value must be passed to [`unregister_client`].
let session = ClientSession::new(client_id.clone()); pub fn register_client(&self, client_id: ClientId) -> ClientGeneration {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let session = ClientSession::new(client_id.clone(), generation);
self.clients.write().insert(client_id.clone(), session); self.clients.write().insert(client_id.clone(), session);
info!( info!(
"Client {} connected (total: {})", "Client {} connected (total: {})",
client_id, client_id,
self.client_count() self.client_count()
); );
generation
} }
pub fn unregister_client(&self, client_id: &str) { pub fn unregister_client(&self, client_id: &str, expected_generation: ClientGeneration) {
if let Some(session) = self.clients.write().remove(client_id) { let mut clients = self.clients.write();
match clients.get(client_id) {
Some(session) if session.generation == expected_generation => {}
_ => return,
}
if let Some(session) = clients.remove(client_id) {
let duration = session.connected_elapsed(); let duration = session.connected_elapsed();
let duration_secs = duration.as_secs_f32(); let duration_secs = duration.as_secs_f32();
let avg_fps = if duration_secs > 0.1 { let avg_fps = if duration_secs > 0.1 {
@@ -327,9 +339,12 @@ impl MjpegStreamHandler {
} }
pub fn get_clients_stat(&self) -> HashMap<String, crate::events::types::ClientStats> { pub fn get_clients_stat(&self) -> HashMap<String, crate::events::types::ClientStats> {
// write() because `current_fps()` mutates the underlying VecDeque
// to prune stale samples. Held for ~microseconds, called once per
// second by the stats broadcaster.
self.clients self.clients
.read() .write()
.iter() .iter_mut()
.map(|(id, session)| { .map(|(id, session)| {
( (
id.clone(), id.clone(),
@@ -379,13 +394,18 @@ impl MjpegStreamHandler {
pub struct ClientGuard { pub struct ClientGuard {
client_id: ClientId, client_id: ClientId,
generation: ClientGeneration,
handler: Arc<MjpegStreamHandler>, handler: Arc<MjpegStreamHandler>,
} }
impl ClientGuard { impl ClientGuard {
pub fn new(client_id: ClientId, handler: Arc<MjpegStreamHandler>) -> Self { pub fn new(client_id: ClientId, handler: Arc<MjpegStreamHandler>) -> Self {
handler.register_client(client_id.clone()); let generation = handler.register_client(client_id.clone());
Self { client_id, handler } Self {
client_id,
generation,
handler,
}
} }
pub fn id(&self) -> &ClientId { pub fn id(&self) -> &ClientId {
@@ -395,7 +415,8 @@ impl ClientGuard {
impl Drop for ClientGuard { impl Drop for ClientGuard {
fn drop(&mut self) { fn drop(&mut self) {
self.handler.unregister_client(&self.client_id); self.handler
.unregister_client(&self.client_id, self.generation);
} }
} }
@@ -525,6 +546,41 @@ mod tests {
calc.record_frame(); calc.record_frame();
calc.record_frame(); calc.record_frame();
assert!(calc.frame_times.len() == 3); assert_eq!(calc.current_fps(), 3);
assert_eq!(calc.frame_times.len(), 3);
}
#[test]
fn test_fps_calculator_decays_without_new_frames() {
let mut calc = FpsCalculator::new();
calc.window = Duration::from_millis(50);
calc.record_frame();
calc.record_frame();
assert_eq!(calc.current_fps(), 2);
std::thread::sleep(Duration::from_millis(80));
assert_eq!(calc.current_fps(), 0);
assert!(calc.frame_times.is_empty());
}
#[test]
fn test_client_guard_generation_isolation() {
let handler = Arc::new(MjpegStreamHandler::new());
let id = "shared-id".to_string();
let stale = ClientGuard::new(id.clone(), handler.clone());
let stale_gen = stale.generation;
let fresh = ClientGuard::new(id.clone(), handler.clone());
assert_ne!(stale_gen, fresh.generation);
assert_eq!(handler.client_count(), 1);
drop(stale);
assert_eq!(handler.client_count(), 1);
drop(fresh);
assert_eq!(handler.client_count(), 0);
} }
} }

View File

@@ -20,7 +20,7 @@ use super::format::{PixelFormat, Resolution};
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
use super::is_csi_hdmi_bridge; use super::is_csi_hdmi_bridge;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent}; use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
use crate::stream::MjpegStreamHandler; use crate::stream::MjpegStreamHandler;
use crate::utils::LogThrottler; use crate::utils::LogThrottler;
use crate::video::capture_limits::{should_validate_jpeg_frame, MIN_CAPTURE_FRAME_SIZE}; use crate::video::capture_limits::{should_validate_jpeg_frame, MIN_CAPTURE_FRAME_SIZE};
@@ -1417,6 +1417,7 @@ impl Streamer {
// Publish device lost event // Publish device lost event
self.publish_event(SystemEvent::StreamDeviceLost { self.publish_event(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
device: device.clone(), device: device.clone(),
reason: reason.clone(), reason: reason.clone(),
}) })

View File

@@ -34,7 +34,7 @@ pub async fn update_stream_config(
&state, &state,
&old_stream_config, &old_stream_config,
&new_stream_config, &new_stream_config,
ConfigApplyOptions::forced(), ConfigApplyOptions::default(),
) )
.await?; .await?;

View File

@@ -509,6 +509,12 @@ impl AtxConfigUpdate {
} }
crate::atx::AtxDriverType::UsbRelay => { crate::atx::AtxDriverType::UsbRelay => {
if let Some(pin) = key.pin { if let Some(pin) = key.pin {
if pin == 0 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be 1-based (>= 1)",
name
)));
}
if pin > u8::MAX as u32 { if pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}", "{} USB relay channel must be <= {}",
@@ -516,6 +522,12 @@ impl AtxConfigUpdate {
u8::MAX u8::MAX
))); )));
} }
if pin > 8 {
return Err(AppError::BadRequest(format!(
"{} USB HID relay channel must be <= 8",
name
)));
}
} }
} }
crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {}
@@ -551,6 +563,12 @@ impl AtxConfigUpdate {
} }
} }
crate::atx::AtxDriverType::UsbRelay => { crate::atx::AtxDriverType::UsbRelay => {
if key.pin == 0 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be 1-based (>= 1)",
name
)));
}
if key.pin > u8::MAX as u32 { if key.pin > u8::MAX as u32 {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}", "{} USB relay channel must be <= {}",
@@ -558,6 +576,12 @@ impl AtxConfigUpdate {
u8::MAX u8::MAX
))); )));
} }
if key.pin > 8 {
return Err(AppError::BadRequest(format!(
"{} USB HID relay channel must be <= 8",
name
)));
}
} }
crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {} crate::atx::AtxDriverType::Gpio | crate::atx::AtxDriverType::None => {}
} }

View File

@@ -1493,12 +1493,8 @@ pub async fn mjpeg_stream(
handler.clone(), handler.clone(),
)); ));
// Use bounded channel (capacity=1) to implement backpressure
// This ensures record_frame_sent() is only called when the previous frame
// has been successfully consumed by the HTTP client
let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(1); let (tx, mut rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(1);
// Spawn background task to send frames to channel
let guard_clone = guard.clone(); let guard_clone = guard.clone();
let handler_clone = handler.clone(); let handler_clone = handler.clone();
tokio::spawn(async move { tokio::spawn(async move {
@@ -1593,20 +1589,19 @@ pub async fn mjpeg_stream(
} }
} }
// Guard is automatically dropped here
}); });
// Create stream that receives from channel // Create stream that receives from channel and forwards to the HTTP
// Record FPS after yield - this is closer to actual TCP send than tx.send() // body. Record FPS *before* yield so the final frame of a session
// still gets counted (after-yield code in async_stream! only runs
// when the consumer polls again, which never happens for the last
// frame of a closing connection).
let handler_for_stream = handler.clone(); let handler_for_stream = handler.clone();
let guard_for_stream = guard.clone(); let guard_for_stream = guard.clone();
let body_stream = async_stream::stream! { let body_stream = async_stream::stream! {
// Consume from channel - this drives the backpressure
while let Some(data) = rx.recv().await { while let Some(data) = rx.recv().await {
yield Ok::<bytes::Bytes, std::io::Error>(data);
// Record FPS after yield - data has been handed to Axum/hyper
// This is closer to actual TCP send than recording at tx.send()
handler_for_stream.record_frame_sent(guard_for_stream.id()); handler_for_stream.record_frame_sent(guard_for_stream.id());
yield Ok::<bytes::Bytes, std::io::Error>(data);
} }
}; };

View File

@@ -12,6 +12,7 @@ use webrtc::data_channel::data_channel_message::DataChannelMessage;
use webrtc::data_channel::RTCDataChannel; use webrtc::data_channel::RTCDataChannel;
use webrtc::ice::mdns::MulticastDnsMode; use webrtc::ice::mdns::MulticastDnsMode;
use webrtc::ice_transport::ice_candidate::RTCIceCandidate; use webrtc::ice_transport::ice_candidate::RTCIceCandidate;
use webrtc::ice_transport::ice_connection_state::RTCIceConnectionState;
use webrtc::ice_transport::ice_server::RTCIceServer; use webrtc::ice_transport::ice_server::RTCIceServer;
use webrtc::interceptor::registry::Registry; use webrtc::interceptor::registry::Registry;
use webrtc::peer_connection::configuration::RTCConfiguration; use webrtc::peer_connection::configuration::RTCConfiguration;
@@ -30,14 +31,12 @@ use super::signaling::{ConnectionState, IceCandidate, SdpAnswer, SdpOffer};
use super::video_track::{UniversalVideoTrack, UniversalVideoTrackConfig, VideoCodec}; use super::video_track::{UniversalVideoTrack, UniversalVideoTrackConfig, VideoCodec};
use crate::audio::OpusFrame; use crate::audio::OpusFrame;
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::hid::datachannel::{parse_hid_message, HidChannelEvent}; use crate::hid::datachannel::{parse_hid_message, HidChannelEvent};
use crate::hid::HidController; use crate::hid::HidController;
use crate::video::types::{ use crate::video::types::{
BitratePreset, EncodedVideoFrame, PixelFormat, Resolution, VideoEncoderType, BitratePreset, EncodedVideoFrame, PixelFormat, Resolution, VideoEncoderType,
}; };
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use webrtc::ice_transport::ice_gatherer_state::RTCIceGathererState;
const MIME_TYPE_H265: &str = "video/H265"; const MIME_TYPE_H265: &str = "video/H265";
@@ -140,7 +139,6 @@ pub struct UniversalSession {
state_rx: watch::Receiver<ConnectionState>, state_rx: watch::Receiver<ConnectionState>,
ice_candidates: Arc<Mutex<Vec<IceCandidate>>>, ice_candidates: Arc<Mutex<Vec<IceCandidate>>>,
hid_controller: Option<Arc<HidController>>, hid_controller: Option<Arc<HidController>>,
event_bus: Option<Arc<EventBus>>,
video_receiver_handle: Mutex<Option<tokio::task::JoinHandle<()>>>, video_receiver_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
audio_receiver_handle: Mutex<Option<tokio::task::JoinHandle<()>>>, audio_receiver_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
fps: u32, fps: u32,
@@ -150,7 +148,7 @@ impl UniversalSession {
pub async fn new( pub async fn new(
config: UniversalSessionConfig, config: UniversalSessionConfig,
session_id: String, session_id: String,
event_bus: Option<Arc<EventBus>>, _event_bus: Option<Arc<crate::events::EventBus>>,
) -> Result<Self> { ) -> Result<Self> {
info!( info!(
"Creating {} session: {} @ {}x{} (audio={})", "Creating {} session: {} @ {}x{} (audio={})",
@@ -338,7 +336,6 @@ impl UniversalSession {
state_rx, state_rx,
ice_candidates: Arc::new(Mutex::new(vec![])), ice_candidates: Arc::new(Mutex::new(vec![])),
hid_controller: None, hid_controller: None,
event_bus,
video_receiver_handle: Mutex::new(None), video_receiver_handle: Mutex::new(None),
audio_receiver_handle: Mutex::new(None), audio_receiver_handle: Mutex::new(None),
fps: config.fps, fps: config.fps,
@@ -353,8 +350,6 @@ impl UniversalSession {
let state = self.state.clone(); let state = self.state.clone();
let session_id = self.session_id.clone(); let session_id = self.session_id.clone();
let codec = self.codec; let codec = self.codec;
let event_bus = self.event_bus.clone();
self.pc self.pc
.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { .on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
let state = state.clone(); let state = state.clone();
@@ -372,42 +367,49 @@ impl UniversalSession {
}; };
info!("{} session {} state: {}", codec, session_id, new_state); info!("{} session {} state: {}", codec, session_id, new_state);
if matches!(
(*state.borrow(), new_state),
(
ConnectionState::Connected,
ConnectionState::New | ConnectionState::Connecting
)
) {
return;
}
let _ = state.send(new_state); let _ = state.send(new_state);
}) })
})); }));
let state_for_ice = self.state.clone();
let session_id_ice = self.session_id.clone(); let session_id_ice = self.session_id.clone();
self.pc self.pc
.on_ice_connection_state_change(Box::new(move |state| { .on_ice_connection_state_change(Box::new(move |ice_state| {
let state = state_for_ice.clone();
let session_id = session_id_ice.clone(); let session_id = session_id_ice.clone();
Box::pin(async move { Box::pin(async move {
info!("[ICE] Session {} connection state: {:?}", session_id, state); info!(
}) "[ICE] Session {} connection state: {:?}",
})); session_id, ice_state
);
let session_id_gather = self.session_id.clone(); let new_state = match ice_state {
let event_bus_gather = event_bus.clone(); RTCIceConnectionState::Connected | RTCIceConnectionState::Completed => {
self.pc ConnectionState::Connected
.on_ice_gathering_state_change(Box::new(move |state| {
let session_id = session_id_gather.clone();
let event_bus = event_bus_gather.clone();
Box::pin(async move {
if matches!(state, RTCIceGathererState::Complete) {
if let Some(bus) = event_bus.as_ref() {
bus.publish(SystemEvent::WebRTCIceComplete { session_id });
}
} }
RTCIceConnectionState::Disconnected => ConnectionState::Disconnected,
RTCIceConnectionState::Failed => ConnectionState::Failed,
RTCIceConnectionState::Closed => ConnectionState::Closed,
_ => return,
};
let _ = state.send(new_state);
}) })
})); }));
let ice_candidates = self.ice_candidates.clone(); let ice_candidates = self.ice_candidates.clone();
let session_id_candidate = self.session_id.clone();
let event_bus_candidate = event_bus.clone();
self.pc self.pc
.on_ice_candidate(Box::new(move |candidate: Option<RTCIceCandidate>| { .on_ice_candidate(Box::new(move |candidate: Option<RTCIceCandidate>| {
let ice_candidates = ice_candidates.clone(); let ice_candidates = ice_candidates.clone();
let session_id = session_id_candidate.clone();
let event_bus = event_bus_candidate.clone();
Box::pin(async move { Box::pin(async move {
if let Some(c) = candidate { if let Some(c) = candidate {
@@ -430,14 +432,6 @@ impl UniversalSession {
let mut candidates = ice_candidates.lock().await; let mut candidates = ice_candidates.lock().await;
candidates.push(candidate.clone()); candidates.push(candidate.clone());
drop(candidates); drop(candidates);
if let Some(bus) = event_bus.as_ref() {
bus.publish(SystemEvent::WebRTCIceCandidate {
session_id,
candidate: serde_json::to_value(&candidate)
.unwrap_or(serde_json::Value::Null),
});
}
} }
}) })
})); }));
@@ -660,10 +654,22 @@ impl UniversalSession {
.await; .await;
let _ = send_in_flight; let _ = send_in_flight;
if send_result.is_ok() { match send_result {
Ok(()) => {
frames_sent += 1; frames_sent += 1;
last_sequence = Some(encoded_frame.sequence); last_sequence = Some(encoded_frame.sequence);
} }
Err(e) => {
warn!(
"Session {} failed to write video frame: sequence={}, keyframe={}, bytes={}, error={}",
session_id,
encoded_frame.sequence,
encoded_frame.is_keyframe,
encoded_frame.data.len(),
e
);
}
}
} }
} }
} }
@@ -810,25 +816,14 @@ impl UniversalSession {
} }
} }
let mut gather_complete = self.pc.gathering_complete_promise().await;
self.pc self.pc
.set_local_description(answer.clone()) .set_local_description(answer.clone())
.await .await
.map_err(|e| AppError::VideoError(format!("Failed to set local description: {}", e)))?; .map_err(|e| AppError::VideoError(format!("Failed to set local description: {}", e)))?;
const ICE_GATHER_TIMEOUT: Duration = Duration::from_millis(2500); tokio::time::sleep(Duration::from_millis(500)).await;
if tokio::time::timeout(ICE_GATHER_TIMEOUT, gather_complete.recv())
.await
.is_err()
{
debug!(
"ICE gathering timeout after {:?} for session {}",
ICE_GATHER_TIMEOUT, self.session_id
);
}
let candidates = self.ice_candidates.lock().await.clone(); let candidates = self.ice_candidates.lock().await.clone();
Ok(SdpAnswer::with_candidates(answer.sdp, candidates)) Ok(SdpAnswer::with_candidates(answer.sdp, candidates))
} }
@@ -842,10 +837,16 @@ impl UniversalSession {
username_fragment: candidate.username_fragment, username_fragment: candidate.username_fragment,
}; };
self.pc if let Err(e) = self.pc.add_ice_candidate(init).await {
.add_ice_candidate(init) warn!(
.await "[ICE] Session {} failed to add remote candidate: {}",
.map_err(|e| AppError::VideoError(format!("Failed to add ICE candidate: {}", e)))?; self.session_id, e
);
return Err(AppError::VideoError(format!(
"Failed to add ICE candidate: {}",
e
)));
}
Ok(()) Ok(())
} }

View File

@@ -14,7 +14,7 @@ use webrtc::track::track_local::{TrackLocal, TrackLocalWriter};
// rtp `HevcPayloader` mishandles AP+IDR and NAL 20 (`IDR_N_LP`). // rtp `HevcPayloader` mishandles AP+IDR and NAL 20 (`IDR_N_LP`).
use super::h265_payloader::H265Payloader; use super::h265_payloader::H265Payloader;
use crate::error::Result; use crate::error::{AppError, Result};
use crate::video::types::Resolution; use crate::video::types::Resolution;
const RTP_MTU: usize = 1200; const RTP_MTU: usize = 1200;
@@ -250,6 +250,10 @@ impl UniversalVideoTrack {
TrackType::Sample(track) => { TrackType::Sample(track) => {
if let Err(e) = track.write_sample(&sample).await { if let Err(e) = track.write_sample(&sample).await {
debug!("H264 write_sample failed: {}", e); debug!("H264 write_sample failed: {}", e);
return Err(AppError::WebRtcError(format!(
"H264 write_sample failed: {}",
e
)));
} }
} }
TrackType::Rtp(_) => { TrackType::Rtp(_) => {
@@ -276,6 +280,10 @@ impl UniversalVideoTrack {
TrackType::Sample(track) => { TrackType::Sample(track) => {
if let Err(e) = track.write_sample(&sample).await { if let Err(e) = track.write_sample(&sample).await {
debug!("VP8 write_sample failed: {}", e); debug!("VP8 write_sample failed: {}", e);
return Err(AppError::WebRtcError(format!(
"VP8 write_sample failed: {}",
e
)));
} }
} }
TrackType::Rtp(_) => { TrackType::Rtp(_) => {
@@ -298,6 +306,10 @@ impl UniversalVideoTrack {
TrackType::Sample(track) => { TrackType::Sample(track) => {
if let Err(e) = track.write_sample(&sample).await { if let Err(e) = track.write_sample(&sample).await {
debug!("VP9 write_sample failed: {}", e); debug!("VP9 write_sample failed: {}", e);
return Err(AppError::WebRtcError(format!(
"VP9 write_sample failed: {}",
e
)));
} }
} }
TrackType::Rtp(_) => { TrackType::Rtp(_) => {
@@ -366,6 +378,10 @@ impl UniversalVideoTrack {
if let Err(e) = rtp_track.write_rtp(&packet).await { if let Err(e) = rtp_track.write_rtp(&packet).await {
trace!("H265 write_rtp failed: {}", e); trace!("H265 write_rtp failed: {}", e);
return Err(AppError::WebRtcError(format!(
"H265 write_rtp failed: {}",
e
)));
} }
} }

View File

@@ -9,7 +9,7 @@ use tracing::{debug, info, trace, warn};
use crate::audio::{AudioController, OpusFrame}; use crate::audio::{AudioController, OpusFrame};
use crate::error::{AppError, Result}; use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent}; use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
use crate::hid::HidController; use crate::hid::HidController;
use crate::video::device::{ use crate::video::device::{
enumerate_devices, select_recovery_device, VideoDevice, VideoDeviceRecoveryHint, enumerate_devices, select_recovery_device, VideoDevice, VideoDeviceRecoveryHint,
@@ -352,6 +352,7 @@ impl WebRtcStreamer {
); );
streamer streamer
.publish_stream_event(SystemEvent::StreamDeviceLost { .publish_stream_event(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
device: original_device.clone(), device: original_device.clone(),
reason: reason.clone(), reason: reason.clone(),
}) })

10
web/package-lock.json generated
View File

@@ -1368,7 +1368,6 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1783,7 +1782,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2450,7 +2448,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2498,7 +2495,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2791,8 +2787,7 @@
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@@ -2846,7 +2841,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2912,7 +2906,6 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -2994,7 +2987,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.25", "@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25", "@vue/compiler-sfc": "3.5.25",

View File

@@ -72,12 +72,12 @@ watch(
<Toaster <Toaster
rich-colors rich-colors
close-button close-button
expand
position="top-center" position="top-center"
close-button-position="top-right" close-button-position="top-right"
theme="system" theme="system"
:duration="4000" :duration="4000"
:gap="14" :gap="14"
:visible-toasts="3"
:offset="{ top: '1rem', right: '1rem', left: '1rem', bottom: '1rem' }" :offset="{ top: '1rem', right: '1rem', left: '1rem', bottom: '1rem' }"
:mobile-offset="{ top: 'max(1rem, env(safe-area-inset-top))', bottom: 'max(1rem, env(safe-area-inset-bottom))', left: '1rem', right: '1rem' }" :mobile-offset="{ top: 'max(1rem, env(safe-area-inset-top))', bottom: 'max(1rem, env(safe-area-inset-bottom))', left: '1rem', right: '1rem' }"
:toast-options="toasterToastOptions" :toast-options="toasterToastOptions"

View File

@@ -42,8 +42,6 @@ const isAttached = ref(props.attached ?? true)
const selectedOs = ref<KeyboardOsType>('windows') const selectedOs = ref<KeyboardOsType>('windows')
const mainKeyboard = ref<Keyboard | null>(null) const mainKeyboard = ref<Keyboard | null>(null)
const controlKeyboard = ref<Keyboard | null>(null)
const arrowsKeyboard = ref<Keyboard | null>(null)
const pressedModifiers = ref<number>(0) const pressedModifiers = ref<number>(0)
const keysDown = ref<CanonicalKey[]>([]) const keysDown = ref<CanonicalKey[]>([])
@@ -81,47 +79,49 @@ const position = ref({ x: 100, y: 100 })
const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
const getBottomRow = () => osBottomRows[selectedOs.value].join(' ') const getBottomRow = () =>
[...osBottomRows[selectedOs.value], 'ArrowLeft', 'ArrowDown', 'ArrowRight'].join(' ')
const keyboardLayout = { const keyboardLayout = {
main: { main: {
default: [ default: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace', 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12', 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace', 'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace Insert Home PageUp',
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash', 'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash Delete End PageDown',
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter', 'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight', 'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight', 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
], ],
shift: [ shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace', 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12', 'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace', '(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace Insert Home PageUp',
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)', 'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown',
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter', 'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight', 'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight', 'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
],
},
control: {
default: [
'PrintScreen ScrollLock Pause',
'Insert Home PageUp',
'Delete End PageDown',
],
},
arrows: {
default: [
'ArrowUp',
'ArrowLeft ArrowDown ArrowRight',
], ],
}, },
} }
const compactMainLayout = { const compactMainLayout = {
default: keyboardLayout.main.default.slice(2), default: [
shift: keyboardLayout.main.shift.slice(2), 'Escape Insert Delete Home End PageUp PageDown PrintScreen',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace',
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
],
shift: [
'Escape Insert Delete Home End PageUp PageDown PrintScreen',
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace',
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
],
} }
const isCompactLayout = ref(false) const isCompactLayout = ref(false)
@@ -286,8 +286,6 @@ function updateKeyboardLayout() {
], ],
} }
mainKeyboard.value?.setOptions({ layout: newLayout, display: keyDisplayMap.value }) mainKeyboard.value?.setOptions({ layout: newLayout, display: keyDisplayMap.value })
controlKeyboard.value?.setOptions({ display: keyDisplayMap.value })
arrowsKeyboard.value?.setOptions({ display: keyDisplayMap.value })
updateKeyboardButtonTheme() updateKeyboardButtonTheme()
} }
@@ -431,8 +429,6 @@ function updateKeyboardButtonTheme() {
] ]
mainKeyboard.value?.setOptions({ buttonTheme }) mainKeyboard.value?.setOptions({ buttonTheme })
controlKeyboard.value?.setOptions({ buttonTheme })
arrowsKeyboard.value?.setOptions({ buttonTheme })
} }
watch([layoutName, () => props.capsLock], ([name]) => { watch([layoutName, () => props.capsLock], ([name]) => {
@@ -447,10 +443,8 @@ function initKeyboards() {
const id = keyboardId.value const id = keyboardId.value
const mainEl = document.querySelector(`#${id}-main`) const mainEl = document.querySelector(`#${id}-main`)
const controlEl = document.querySelector(`#${id}-control`)
const arrowsEl = document.querySelector(`#${id}-arrows`)
if (!mainEl || !controlEl || !arrowsEl) { if (!mainEl) {
console.warn('[VirtualKeyboard] DOM elements not ready, retrying...', id) console.warn('[VirtualKeyboard] DOM elements not ready, retrying...', id)
setTimeout(initKeyboards, 50) setTimeout(initKeyboards, 50)
return return
@@ -476,45 +470,13 @@ function initKeyboards() {
stopMouseUpPropagation: true, stopMouseUpPropagation: true,
}) })
controlKeyboard.value = new Keyboard(controlEl, {
layout: keyboardLayout.control,
layoutName: 'default',
display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
arrowsKeyboard.value = new Keyboard(arrowsEl, {
layout: keyboardLayout.arrows,
layoutName: 'default',
display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
updateKeyboardLayout() updateKeyboardLayout()
console.log('[VirtualKeyboard] Keyboards initialized:', id) console.log('[VirtualKeyboard] Keyboards initialized:', id)
} }
function destroyKeyboards() { function destroyKeyboards() {
mainKeyboard.value?.destroy() mainKeyboard.value?.destroy()
controlKeyboard.value?.destroy()
arrowsKeyboard.value?.destroy()
mainKeyboard.value = null mainKeyboard.value = null
controlKeyboard.value = null
arrowsKeyboard.value = null
} }
function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null { function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null {
@@ -682,10 +644,6 @@ onUnmounted(() => {
</div> </div>
<div class="vkb-keyboards"> <div class="vkb-keyboards">
<div :id="`${keyboardId}-main`" class="kb-main-container"></div> <div :id="`${keyboardId}-main`" class="kb-main-container"></div>
<div class="vkb-side">
<div :id="`${keyboardId}-control`" class="kb-control-container"></div>
<div :id="`${keyboardId}-arrows`" class="kb-arrows-container"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -818,10 +776,9 @@ onUnmounted(() => {
min-width: 90px; min-width: 90px;
} }
/* Right Shift - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] { .vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
flex-grow: 2.75; flex-grow: 1.75;
min-width: 110px; min-width: 70px;
} }
/* Bottom row modifiers */ /* Bottom row modifiers */
@@ -852,21 +809,33 @@ onUnmounted(() => {
min-width: 55px; min-width: 55px;
} }
/* Control keyboard */ .vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.kb-control-container .hg-button { .vkb .simple-keyboard .hg-button[data-skbtn="ScrollLock"],
min-width: 54px !important; .vkb .simple-keyboard .hg-button[data-skbtn="Pause"],
justify-content: center; .vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"] {
font-size: 11px;
flex-grow: 0.95;
} }
/* Arrow buttons */ .vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.kb-arrows-container .hg-button { .vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
min-width: 44px !important; .vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
width: 44px !important; .vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
justify-content: center; .vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] {
margin-left: 6px;
} }
.kb-arrows-container .hg-row { .vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
justify-content: center; .vkb .simple-keyboard .hg-button[data-skbtn="ArrowDown"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowRight"] {
font-size: 14px;
flex-grow: 1;
} }
/* Dark mode - must be after simple-keyboard CSS import */ /* Dark mode - must be after simple-keyboard CSS import */
@@ -1127,31 +1096,7 @@ html.dark .hg-theme-default .hg-button.down-key,
flex: 1; flex: 1;
} }
.vkb-side {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.kb-control-container,
.kb-arrows-container {
display: inline-block;
}
/* Responsive */ /* Responsive */
@media (max-width: 900px) {
.vkb-keyboards {
flex-direction: column;
}
.vkb-side {
flex-direction: row;
justify-content: center;
gap: 16px;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.vkb-body { .vkb-body {
padding: 4px; padding: 4px;
@@ -1159,11 +1104,11 @@ html.dark .hg-theme-default .hg-button.down-key,
} }
.vkb .simple-keyboard .hg-button { .vkb .simple-keyboard .hg-button {
height: 28px; height: 30px;
font-size: 10px; font-size: 11px;
padding: 0 3px; padding: 0 2px;
margin: 0 1px 2px 0; margin: 0 1px 2px 0;
min-width: 24px; min-width: 0;
} }
.vkb .simple-keyboard .hg-button.combination-key { .vkb .simple-keyboard .hg-button.combination-key {
@@ -1172,35 +1117,15 @@ html.dark .hg-theme-default .hg-button.down-key,
padding: 0 6px; padding: 0 6px;
} }
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] { .vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
min-width: 60px; .vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
}
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"], .vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] { .vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
min-width: 52px; .vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"],
} .vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] { .vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"],
min-width: 60px; .vkb .simple-keyboard .hg-button[data-skbtn="Space"],
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 80px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"], .vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
@@ -1208,20 +1133,29 @@ html.dark .hg-theme-default .hg-button.down-key,
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"], .vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"], .vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] { .vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 46px; min-width: 0;
} }
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] { .vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
min-width: 140px; .vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"],
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
font-size: 11px;
flex-grow: 1;
} }
.kb-control-container .hg-button { .vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
min-width: 44px !important; .vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] {
margin-left: 0;
} }
.kb-arrows-container .hg-button { .vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"] {
min-width: 36px !important; margin-left: 4px;
width: 36px !important;
} }
.vkb-media-btn { .vkb-media-btn {
@@ -1233,55 +1167,27 @@ html.dark .hg-theme-default .hg-button.down-key,
@media (max-width: 400px) { @media (max-width: 400px) {
.vkb .simple-keyboard .hg-button { .vkb .simple-keyboard .hg-button {
height: 26px; height: 28px;
font-size: 9px; font-size: 10px;
padding: 0 2px; padding: 0 1px;
margin: 0 1px 2px 0; margin: 0 1px 2px 0;
min-width: 20px;
border-radius: 4px; border-radius: 4px;
} }
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 100px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 44px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 50px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 34px;
}
.vkb .simple-keyboard .hg-button.combination-key { .vkb .simple-keyboard .hg-button.combination-key {
font-size: 8px; font-size: 8px;
height: 22px; height: 22px;
padding: 0 4px; padding: 0 4px;
} }
.kb-control-container .hg-button { .vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
min-width: 34px !important; .vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
} .vkb .simple-keyboard .hg-button[data-skbtn="Home"],
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
.kb-arrows-container .hg-button { .vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
min-width: 30px !important; .vkb .simple-keyboard .hg-button[data-skbtn="PageDown"],
width: 30px !important; .vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
font-size: 10px;
} }
.vkb-media-btn { .vkb-media-btn {
@@ -1325,15 +1231,6 @@ html.dark .hg-theme-default .hg-button.down-key,
font-size: 10px; font-size: 10px;
} }
.vkb--floating :deep(.kb-control-container .hg-button) {
min-width: 52px !important;
}
.vkb--floating :deep(.kb-arrows-container .hg-button) {
min-width: 42px !important;
width: 42px !important;
}
/* Animation */ /* Animation */
.keyboard-fade-enter-active, .keyboard-fade-enter-active,
.keyboard-fade-leave-active { .keyboard-fade-leave-active {

View File

@@ -1,7 +1,9 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
import type { StreamDeviceLostEventData } from '@/types/websocket'
import { useWebSocket } from '@/composables/useWebSocket' import { useWebSocket } from '@/composables/useWebSocket'
import { isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
export interface ConsoleEventHandlers { export interface ConsoleEventHandlers {
onStreamConfigChanging?: (data: { reason?: string }) => void onStreamConfigChanging?: (data: { reason?: string }) => void
@@ -14,12 +16,10 @@ export interface ConsoleEventHandlers {
onStreamStateChanged?: (data: { onStreamStateChanged?: (data: {
state: string state: string
device?: string | null device?: string | null
/** Optional fine-grained diagnostic tag (e.g. `no_cable`, `out_of_range`, `recovering`). */
reason?: string | null reason?: string | null
/** Optional countdown (ms) until the next backend self-recovery attempt. */
next_retry_ms?: number | null next_retry_ms?: number | null
}) => void }) => void
onStreamDeviceLost?: (data: { device: string; reason: string }) => void onStreamDeviceLost?: (data: StreamDeviceLostEventData) => void
onStreamReconnecting?: (data: { device: string; attempt: number }) => void onStreamReconnecting?: (data: { device: string; attempt: number }) => void
onStreamRecovered?: (data: { device: string }) => void onStreamRecovered?: (data: { device: string }) => void
onDeviceInfo?: (data: any) => void onDeviceInfo?: (data: any) => void
@@ -31,12 +31,16 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
const { on, off, connect } = useWebSocket() const { on, off, connect } = useWebSocket()
const noop = () => {} const noop = () => {}
function handleStreamDeviceLost(data: { device: string; reason: string }) { function handleStreamDeviceLost(data: StreamDeviceLostEventData) {
if (systemStore.stream) { const audioLost = isAudioStreamDeviceLostPayload(data)
if (systemStore.stream && !audioLost) {
systemStore.stream.online = false systemStore.stream.online = false
} }
toast.error(t('console.deviceLost'), { toast.error(t(audioLost ? 'audio.deviceLost' : 'console.deviceLost'), {
description: t('console.deviceLostDesc', { device: data.device, reason: data.reason }), description: t(audioLost ? 'audio.deviceLostDesc' : 'console.deviceLostDesc', {
device: data.device,
reason: data.reason,
}),
duration: 5000, duration: 5000,
}) })
handlers.onStreamDeviceLost?.(data) handlers.onStreamDeviceLost?.(data)

View File

@@ -1,6 +1,3 @@
// WebRTC composable for H264 video streaming
// Provides low-latency video via WebRTC with DataChannel for HID
import { ref, onUnmounted, computed, type Ref } from 'vue' import { ref, onUnmounted, computed, type Ref } from 'vue'
import { webrtcApi, type IceCandidate } from '@/api' import { webrtcApi, type IceCandidate } from '@/api'
import { import {
@@ -9,7 +6,7 @@ import {
encodeKeyboardEvent, encodeKeyboardEvent,
encodeMouseEvent, encodeMouseEvent,
} from '@/types/hid' } from '@/types/hid'
import { useWebSocket } from '@/composables/useWebSocket' import { videoDebugLog } from '@/lib/debugLog'
export type { HidKeyboardEvent, HidMouseEvent } export type { HidKeyboardEvent, HidMouseEvent }
@@ -28,7 +25,6 @@ export type WebRTCConnectStage =
| 'disconnected' | 'disconnected'
| 'failed' | 'failed'
// ICE candidate type: host=P2P local, srflx=P2P STUN, relay=TURN relay
export type IceCandidateType = 'host' | 'srflx' | 'prflx' | 'relay' | 'unknown' export type IceCandidateType = 'host' | 'srflx' | 'prflx' | 'relay' | 'unknown'
export interface WebRTCStats { export interface WebRTCStats {
@@ -42,26 +38,14 @@ export interface WebRTCStats {
framesPerSecond: number framesPerSecond: number
jitter: number jitter: number
roundTripTime: number roundTripTime: number
// ICE connection info
localCandidateType: IceCandidateType localCandidateType: IceCandidateType
remoteCandidateType: IceCandidateType remoteCandidateType: IceCandidateType
transportProtocol: string // 'udp' | 'tcp' transportProtocol: string
isRelay: boolean // true if using TURN relay isRelay: boolean
} }
// Cached ICE servers from backend API
let cachedIceServers: RTCIceServer[] | null = null let cachedIceServers: RTCIceServer[] | null = null
interface WebRTCIceCandidateEvent {
session_id: string
candidate: IceCandidate
}
interface WebRTCIceCompleteEvent {
session_id: string
}
// Fetch ICE servers from backend API
async function fetchIceServers(): Promise<RTCIceServer[]> { async function fetchIceServers(): Promise<RTCIceServer[]> {
try { try {
const response = await webrtcApi.getIceServers() const response = await webrtcApi.getIceServers()
@@ -84,7 +68,6 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
console.warn('[WebRTC] Failed to fetch ICE servers from API, using fallback:', err) console.warn('[WebRTC] Failed to fetch ICE servers from API, using fallback:', err)
} }
// Fallback: for local connections, use no ICE servers (host candidates only)
const isLocalConnection = typeof window !== 'undefined' && const isLocalConnection = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' || (window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' || window.location.hostname === '127.0.0.1' ||
@@ -107,20 +90,16 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
let peerConnection: RTCPeerConnection | null = null let peerConnection: RTCPeerConnection | null = null
let dataChannel: RTCDataChannel | null = null let dataChannel: RTCDataChannel | null = null
let sessionId: string | null = null let sessionId: string | null = null
const sessionIdRef = ref<string | null>(null)
let statsInterval: number | null = null let statsInterval: number | null = null
let isConnecting = false // Lock to prevent concurrent connect calls let isConnecting = false
let connectInFlight: Promise<boolean> | null = null let connectInFlight: Promise<boolean> | null = null
let pendingIceCandidates: RTCIceCandidate[] = [] // Queue for ICE candidates before sessionId is set let pendingIceCandidates: RTCIceCandidate[] = []
let pendingRemoteCandidates: WebRTCIceCandidateEvent[] = [] // Queue for server ICE candidates let seenRemoteCandidates = new Set<string>()
let pendingRemoteIceComplete = new Set<string>() // Session IDs waiting for end-of-candidates let cachedMediaStream: MediaStream | null = null
let seenRemoteCandidates = new Set<string>() // Deduplicate server ICE candidates
let cachedMediaStream: MediaStream | null = null // Cached MediaStream to avoid recreating
let allowMdnsHostCandidates = false let allowMdnsHostCandidates = false
let wsHandlersRegistered = false
const { on: wsOn } = useWebSocket()
const state = ref<WebRTCState>('disconnected') const state = ref<WebRTCState>('disconnected')
const videoTrack = ref<MediaStreamTrack | null>(null) const videoTrack = ref<MediaStreamTrack | null>(null)
const audioTrack = ref<MediaStreamTrack | null>(null) const audioTrack = ref<MediaStreamTrack | null>(null)
@@ -144,10 +123,44 @@ const error = ref<string | null>(null)
const dataChannelReady = ref(false) const dataChannelReady = ref(false)
const connectStage = ref<WebRTCConnectStage>('idle') const connectStage = ref<WebRTCConnectStage>('idle')
function setConnectStage(stage: WebRTCConnectStage, details?: unknown) {
connectStage.value = stage
videoDebugLog(`WebRTC stage -> ${stage}`, details)
}
function getIceCandidatePoolSize(): number {
if (typeof window === 'undefined') return 0
const icePoolParam = new URLSearchParams(window.location.search).get('ice_pool')
if (icePoolParam === null) return 0
return Math.max(0, Number.parseInt(icePoolParam, 10) || 0)
}
function summarizeIceCandidate(candidate: RTCIceCandidate | IceCandidate | RTCIceCandidateInit | null) {
if (!candidate) return null
const candidateLine = candidate.candidate ?? ''
const parts = candidateLine.trim().split(/\s+/)
const typIndex = parts.indexOf('typ')
const raddrIndex = parts.indexOf('raddr')
const rportIndex = parts.indexOf('rport')
return {
type: typIndex >= 0 ? parts[typIndex + 1] : 'unknown',
protocol: parts[2] ?? '',
address: parts[4] ?? '',
port: parts[5] ?? '',
relatedAddress: raddrIndex >= 0 ? parts[raddrIndex + 1] : undefined,
relatedPort: rportIndex >= 0 ? parts[rportIndex + 1] : undefined,
sdpMid: candidate.sdpMid ?? undefined,
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
usernameFragment: candidate.usernameFragment ?? undefined,
raw: candidateLine,
}
}
function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection { function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
const config: RTCConfiguration = { const config: RTCConfiguration = {
iceServers, iceServers,
iceCandidatePoolSize: 10, iceCandidatePoolSize: getIceCandidatePoolSize(),
} }
const pc = new RTCPeerConnection(config) const pc = new RTCPeerConnection(config)
@@ -159,38 +172,35 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
break break
case 'connected': case 'connected':
state.value = 'connected' state.value = 'connected'
connectStage.value = 'connected' setConnectStage('connected')
error.value = null error.value = null
startStatsCollection() startStatsCollection()
break break
case 'disconnected': case 'disconnected':
case 'closed': case 'closed':
state.value = 'disconnected' state.value = 'disconnected'
connectStage.value = 'disconnected' setConnectStage('disconnected')
stopStatsCollection() stopStatsCollection()
break break
case 'failed': case 'failed':
state.value = 'failed' state.value = 'failed'
connectStage.value = 'failed' setConnectStage('failed')
error.value = 'Connection failed' error.value = 'Connection failed'
stopStatsCollection() stopStatsCollection()
break break
} }
} }
// Handle ICE connection state
pc.oniceconnectionstatechange = () => {
// ICE state changes handled silently
}
// Handle ICE candidates
pc.onicecandidate = async (event) => { pc.onicecandidate = async (event) => {
if (!event.candidate) return if (!event.candidate) {
if (shouldSkipLocalCandidate(event.candidate)) return return
}
if (shouldSkipLocalCandidate(event.candidate)) {
return
}
const currentSessionId = sessionId const currentSessionId = sessionId
if (currentSessionId && pc.connectionState !== 'closed') { if (currentSessionId && pc.connectionState !== 'closed') {
// Session ready, send immediately
try { try {
await webrtcApi.addIceCandidate(currentSessionId, { await webrtcApi.addIceCandidate(currentSessionId, {
candidate: event.candidate.candidate, candidate: event.candidate.candidate,
@@ -198,11 +208,14 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
sdpMLineIndex: event.candidate.sdpMLineIndex ?? undefined, sdpMLineIndex: event.candidate.sdpMLineIndex ?? undefined,
usernameFragment: event.candidate.usernameFragment ?? undefined, usernameFragment: event.candidate.usernameFragment ?? undefined,
}) })
} catch { } catch (err) {
// ICE candidate send failures are non-fatal videoDebugLog('Failed to send local ICE candidate', {
sessionId: currentSessionId,
candidate: summarizeIceCandidate(event.candidate),
error: err,
})
} }
} else if (!currentSessionId) { } else if (!currentSessionId) {
// Queue candidate until sessionId is set
pendingIceCandidates.push(event.candidate) pendingIceCandidates.push(event.candidate)
} }
} }
@@ -235,7 +248,13 @@ function setupDataChannel(channel: RTCDataChannel) {
dataChannelReady.value = false dataChannelReady.value = false
} }
channel.onerror = () => { channel.onerror = (event) => {
videoDebugLog('WebRTC data channel error', {
label: channel.label,
readyState: channel.readyState,
event,
sessionId,
})
} }
channel.onmessage = () => { channel.onmessage = () => {
@@ -251,59 +270,18 @@ function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
return channel return channel
} }
function registerWebSocketHandlers() {
if (wsHandlersRegistered) return
wsHandlersRegistered = true
wsOn('webrtc.ice_candidate', handleRemoteIceCandidate)
wsOn('webrtc.ice_complete', handleRemoteIceComplete)
}
function shouldSkipLocalCandidate(candidate: RTCIceCandidate): boolean { function shouldSkipLocalCandidate(candidate: RTCIceCandidate): boolean {
if (allowMdnsHostCandidates) return false if (allowMdnsHostCandidates) return false
const value = candidate.candidate || '' const value = candidate.candidate || ''
return value.includes(' typ host') && value.includes('.local') return value.includes(' typ host') && value.includes('.local')
} }
async function handleRemoteIceCandidate(data: WebRTCIceCandidateEvent) { async function addRemoteIceCandidate(candidate: IceCandidate): Promise<boolean> {
if (!data || !data.candidate) return if (!peerConnection) return false
if (!candidate.candidate) return false
// Queue until session is ready and remote description is set if (seenRemoteCandidates.has(candidate.candidate)) {
if (!sessionId) { return false
pendingRemoteCandidates.push(data)
return
} }
if (data.session_id !== sessionId) return
if (!peerConnection || !peerConnection.remoteDescription) {
pendingRemoteCandidates.push(data)
return
}
await addRemoteIceCandidate(data.candidate)
}
async function handleRemoteIceComplete(data: WebRTCIceCompleteEvent) {
if (!data || !data.session_id) return
if (!sessionId) {
pendingRemoteIceComplete.add(data.session_id)
return
}
if (data.session_id !== sessionId) return
if (!peerConnection || !peerConnection.remoteDescription) {
pendingRemoteIceComplete.add(data.session_id)
return
}
try {
await peerConnection.addIceCandidate(null)
} catch {
}
}
async function addRemoteIceCandidate(candidate: IceCandidate) {
if (!peerConnection) return
if (!candidate.candidate) return
if (seenRemoteCandidates.has(candidate.candidate)) return
seenRemoteCandidates.add(candidate.candidate) seenRemoteCandidates.add(candidate.candidate)
const iceCandidate: RTCIceCandidateInit = { const iceCandidate: RTCIceCandidateInit = {
@@ -315,33 +293,17 @@ async function addRemoteIceCandidate(candidate: IceCandidate) {
try { try {
await peerConnection.addIceCandidate(iceCandidate) await peerConnection.addIceCandidate(iceCandidate)
} catch { return true
// ICE candidate add failures are non-fatal } catch (err) {
videoDebugLog('Failed to apply remote ICE candidate', {
sessionId,
candidate: summarizeIceCandidate(candidate),
error: err,
})
return false
} }
} }
async function flushPendingRemoteIce() {
if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return
const queued = pendingRemoteCandidates
pendingRemoteCandidates = []
for (const event of queued) {
if (event.session_id === sessionId) {
await addRemoteIceCandidate(event.candidate)
}
}
if (pendingRemoteIceComplete.has(sessionId)) {
pendingRemoteIceComplete.delete(sessionId)
try {
await peerConnection.addIceCandidate(null)
} catch {
}
}
}
// Start collecting stats
function startStatsCollection() { function startStatsCollection() {
if (statsInterval) return if (statsInterval) return
@@ -369,7 +331,6 @@ function startStatsCollection() {
(stat.state === 'succeeded' && stat.selected === true) || (stat.state === 'succeeded' && stat.selected === true) ||
(stat.state === 'in-progress' && !foundActivePair) (stat.state === 'in-progress' && !foundActivePair)
// Also check if this pair has actual data transfer (more reliable indicator)
const hasData = (stat.bytesReceived > 0 || stat.bytesSent > 0) const hasData = (stat.bytesReceived > 0 || stat.bytesSent > 0)
if ((isActive || (stat.state === 'succeeded' && hasData)) && !foundActivePair) { if ((isActive || (stat.state === 'succeeded' && hasData)) && !foundActivePair) {
@@ -382,7 +343,6 @@ function startStatsCollection() {
} }
} }
// Update video stats
if (stat.type === 'inbound-rtp' && stat.kind === 'video') { if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
stats.value.bytesReceived = stat.bytesReceived || 0 stats.value.bytesReceived = stat.bytesReceived || 0
stats.value.packetsReceived = stat.packetsReceived || 0 stats.value.packetsReceived = stat.packetsReceived || 0
@@ -396,7 +356,6 @@ function startStatsCollection() {
} }
}) })
// Update ICE connection info from selected pair
const localCandidate = selectedPairLocalId ? candidates[selectedPairLocalId] : undefined const localCandidate = selectedPairLocalId ? candidates[selectedPairLocalId] : undefined
const remoteCandidate = selectedPairRemoteId ? candidates[selectedPairRemoteId] : undefined const remoteCandidate = selectedPairRemoteId ? candidates[selectedPairRemoteId] : undefined
@@ -410,12 +369,10 @@ function startStatsCollection() {
stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay' stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay'
} catch { } catch {
// Stats collection errors are non-fatal
} }
}, 1000) }, 1000)
} }
// Stop collecting stats
function stopStatsCollection() { function stopStatsCollection() {
if (statsInterval) { if (statsInterval) {
clearInterval(statsInterval) clearInterval(statsInterval)
@@ -423,37 +380,42 @@ function stopStatsCollection() {
} }
} }
// Send queued ICE candidates after sessionId is set
async function flushPendingIceCandidates() { async function flushPendingIceCandidates() {
if (!sessionId || pendingIceCandidates.length === 0) return if (!sessionId || pendingIceCandidates.length === 0) return
const currentSessionId = sessionId
const candidates = [...pendingIceCandidates] const candidates = [...pendingIceCandidates]
pendingIceCandidates = [] pendingIceCandidates = []
for (const candidate of candidates) { const sendTasks = candidates.map(async (candidate) => {
if (shouldSkipLocalCandidate(candidate)) continue if (shouldSkipLocalCandidate(candidate)) {
return
}
try { try {
await webrtcApi.addIceCandidate(sessionId, { await webrtcApi.addIceCandidate(currentSessionId, {
candidate: candidate.candidate, candidate: candidate.candidate,
sdpMid: candidate.sdpMid ?? undefined, sdpMid: candidate.sdpMid ?? undefined,
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined, sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
usernameFragment: candidate.usernameFragment ?? undefined, usernameFragment: candidate.usernameFragment ?? undefined,
}) })
} catch { } catch (err) {
// ICE candidate send failures are non-fatal videoDebugLog('Failed to send queued local ICE candidate', {
} sessionId: currentSessionId,
candidate: summarizeIceCandidate(candidate),
error: err,
})
} }
})
await Promise.allSettled(sendTasks)
} }
// Connect to WebRTC server
async function connect(): Promise<boolean> { async function connect(): Promise<boolean> {
if (connectInFlight) { if (connectInFlight) {
return connectInFlight return connectInFlight
} }
connectInFlight = (async () => { connectInFlight = (async () => {
registerWebSocketHandlers()
if (isConnecting) { if (isConnecting) {
return state.value === 'connected' return state.value === 'connected'
} }
@@ -468,67 +430,85 @@ async function connect(): Promise<boolean> {
await disconnect() await disconnect()
} }
// Clear pending ICE candidates from previous attempt
pendingIceCandidates = [] pendingIceCandidates = []
seenRemoteCandidates.clear()
try { try {
state.value = 'connecting' state.value = 'connecting'
error.value = null error.value = null
connectStage.value = 'fetching_ice_servers' setConnectStage('fetching_ice_servers')
// Fetch ICE servers from backend API
const iceServers = await fetchIceServers() const iceServers = await fetchIceServers()
connectStage.value = 'creating_peer_connection' setConnectStage('creating_peer_connection', { iceServerCount: iceServers.length })
// Create peer connection with fetched ICE servers
peerConnection = createPeerConnection(iceServers) peerConnection = createPeerConnection(iceServers)
connectStage.value = 'creating_data_channel'
setConnectStage('creating_data_channel')
createDataChannel(peerConnection) createDataChannel(peerConnection)
peerConnection.addTransceiver('video', { direction: 'recvonly' }) peerConnection.addTransceiver('video', { direction: 'recvonly' })
peerConnection.addTransceiver('audio', { direction: 'recvonly' }) peerConnection.addTransceiver('audio', { direction: 'recvonly' })
connectStage.value = 'creating_offer' setConnectStage('creating_offer')
const offer = await peerConnection.createOffer() const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer) await peerConnection.setLocalDescription(offer)
connectStage.value = 'waiting_server_answer' setConnectStage('waiting_server_answer')
// Do not pass client_id here: each connect creates a fresh session. // Do not pass client_id here: each connect creates a fresh session.
const response = await webrtcApi.offer(offer.sdp!) const response = await webrtcApi.offer(offer.sdp!)
sessionId = response.session_id sessionId = response.session_id
sessionIdRef.value = response.session_id
// Send any ICE candidates that were queued while waiting for sessionId
await flushPendingIceCandidates()
const answer: RTCSessionDescriptionInit = { const answer: RTCSessionDescriptionInit = {
type: 'answer', type: 'answer',
sdp: response.sdp, sdp: response.sdp,
} }
connectStage.value = 'setting_remote_description' setConnectStage('setting_remote_description', { sessionId })
await peerConnection.setRemoteDescription(answer) await peerConnection.setRemoteDescription(answer)
// Flush any pending server ICE candidates once remote description is set setConnectStage('applying_ice_candidates', {
connectStage.value = 'applying_ice_candidates' sessionId,
await flushPendingRemoteIce() answerCandidates: response.ice_candidates?.length ?? 0,
})
// Add any ICE candidates from the response let appliedAnswerCandidates = 0
if (response.ice_candidates && response.ice_candidates.length > 0) { for (const candidate of response.ice_candidates ?? []) {
for (const candidateObj of response.ice_candidates) { if (await addRemoteIceCandidate(candidate)) {
await addRemoteIceCandidate(candidateObj) appliedAnswerCandidates += 1
} }
} }
try {
await peerConnection.addIceCandidate(null)
} catch (err) {
videoDebugLog('Failed to apply remote ICE end-of-candidates from answer response', {
sessionId,
appliedAnswerCandidates,
error: err,
})
}
void flushPendingIceCandidates()
// Wait for connection to establish (5s for LAN, sufficient for most scenarios)
const connectionTimeout = 5000 const connectionTimeout = 5000
const iceConnectedTimeout = 12000
const pollInterval = 100 const pollInterval = 100
let waited = 0 let waited = 0
connectStage.value = 'waiting_connection' setConnectStage('waiting_connection', {
sessionId,
connectionTimeout,
iceConnectedTimeout,
pollInterval,
})
while (waited < connectionTimeout && peerConnection) { while (peerConnection) {
const pcState = peerConnection.connectionState const pcState = peerConnection.connectionState
const iceState = peerConnection.iceConnectionState
const timeoutForState = iceState === 'connected' || iceState === 'completed'
? iceConnectedTimeout
: connectionTimeout
if (waited >= timeoutForState) break
if (pcState === 'connected') { if (pcState === 'connected') {
connectStage.value = 'connected' setConnectStage('connected', { sessionId, waited })
isConnecting = false isConnecting = false
return true return true
} }
@@ -539,10 +519,25 @@ async function connect(): Promise<boolean> {
waited += pollInterval waited += pollInterval
} }
videoDebugLog('WebRTC connect timed out waiting for ICE/DTLS', {
sessionId,
waited,
connectionState: peerConnection?.connectionState,
iceConnectionState: peerConnection?.iceConnectionState,
iceGatheringState: peerConnection?.iceGatheringState,
signalingState: peerConnection?.signalingState,
})
throw new Error('Connection timeout waiting for ICE negotiation') throw new Error('Connection timeout waiting for ICE negotiation')
} catch (err) { } catch (err) {
state.value = 'failed' state.value = 'failed'
connectStage.value = 'failed' setConnectStage('failed', {
sessionId,
error: err,
connectionState: peerConnection?.connectionState,
iceConnectionState: peerConnection?.iceConnectionState,
iceGatheringState: peerConnection?.iceGatheringState,
signalingState: peerConnection?.signalingState,
})
error.value = err instanceof Error ? err.message : 'Connection failed' error.value = err instanceof Error ? err.message : 'Connection failed'
isConnecting = false isConnecting = false
await disconnect() await disconnect()
@@ -557,17 +552,15 @@ async function connect(): Promise<boolean> {
} }
} }
// Disconnect from WebRTC server
async function disconnect() { async function disconnect() {
stopStatsCollection() stopStatsCollection()
// Clear state FIRST to prevent ICE candidates from being sent // Clear state FIRST to prevent ICE candidates from being sent
const oldSessionId = sessionId const oldSessionId = sessionId
sessionId = null sessionId = null
sessionIdRef.value = null
isConnecting = false isConnecting = false
pendingIceCandidates = [] pendingIceCandidates = []
pendingRemoteCandidates = []
pendingRemoteIceComplete.clear()
seenRemoteCandidates.clear() seenRemoteCandidates.clear()
if (dataChannel) { if (dataChannel) {
@@ -584,18 +577,21 @@ async function disconnect() {
if (oldSessionId) { if (oldSessionId) {
try { try {
await webrtcApi.close(oldSessionId) await webrtcApi.close(oldSessionId)
} catch { } catch (err) {
videoDebugLog('Failed to close backend WebRTC session', {
sessionId: oldSessionId,
error: err,
})
} }
} }
videoTrack.value = null videoTrack.value = null
audioTrack.value = null audioTrack.value = null
cachedMediaStream = null // Clear cached stream on disconnect cachedMediaStream = null
state.value = 'disconnected' state.value = 'disconnected'
connectStage.value = 'disconnected' setConnectStage('disconnected', { previousSessionId: oldSessionId })
error.value = null error.value = null
// Reset stats
stats.value = { stats.value = {
bytesReceived: 0, bytesReceived: 0,
packetsReceived: 0, packetsReceived: 0,
@@ -614,7 +610,6 @@ async function disconnect() {
} }
} }
// Send keyboard event via DataChannel (binary format)
function sendKeyboard(event: HidKeyboardEvent): boolean { function sendKeyboard(event: HidKeyboardEvent): boolean {
if (!dataChannel || dataChannel.readyState !== 'open') { if (!dataChannel || dataChannel.readyState !== 'open') {
return false return false
@@ -629,7 +624,6 @@ function sendKeyboard(event: HidKeyboardEvent): boolean {
} }
} }
// Send mouse event via DataChannel (binary format)
function sendMouse(event: HidMouseEvent): boolean { function sendMouse(event: HidMouseEvent): boolean {
if (!dataChannel || dataChannel.readyState !== 'open') { if (!dataChannel || dataChannel.readyState !== 'open') {
return false return false
@@ -695,7 +689,7 @@ export function useWebRTC() {
error, error,
dataChannelReady, dataChannelReady,
connectStage, connectStage,
sessionId: computed(() => sessionId), sessionId: sessionIdRef,
connect, connect,
disconnect, disconnect,

View File

@@ -28,17 +28,16 @@ function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]) return a.length === b.length && a.every((value, index) => value === b[index])
} }
function syncSubscriptions() { function syncSubscriptions(force = false) {
const topics = getSubscribedTopics() const topics = getSubscribedTopics()
if (arraysEqual(topics, subscribedTopics)) { if (!force && arraysEqual(topics, subscribedTopics)) {
return return
} }
subscribedTopics = topics
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) { if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
subscribe(topics) subscribe(topics)
subscribedTopics = topics
} }
} }
@@ -59,7 +58,7 @@ function connect() {
networkErrorMessage.value = null networkErrorMessage.value = null
reconnectAttempts.value = 0 reconnectAttempts.value = 0
syncSubscriptions() syncSubscriptions(true)
} }
wsInstance.onmessage = (e) => { wsInstance.onmessage = (e) => {

View File

@@ -314,6 +314,10 @@ export default {
title: 'Video device offline', title: 'Video device offline',
detail: 'Capture card is not responding, attempting to re-detect…', detail: 'Capture card is not responding, attempting to re-detect…',
}, },
audioDeviceLost: {
title: 'Audio device offline',
detail: 'Reconnecting the audio capture device…',
},
deviceBusy: { deviceBusy: {
title: 'Video channel busy', title: 'Video channel busy',
detail: 'Applying a new configuration or another component is using the device, please wait…', detail: 'Applying a new configuration or another component is using the device, please wait…',
@@ -335,6 +339,8 @@ export default {
device_lost: 'Video node disappeared, waiting for the driver to recover', device_lost: 'Video node disappeared, waiting for the driver to recover',
config_changing: 'Applying new configuration', config_changing: 'Applying new configuration',
mode_switching: 'Switching video mode', mode_switching: 'Switching video mode',
audio_device_lost: 'Audio capture is unavailable; recovery in progress',
audio_reconnecting: 'Retrying audio device connection',
uvc_usb_error: uvc_usb_error:
'Try another USB port or cable, avoid hubs, or reconnect the device. You can also reset the device from Settings → Environment → USB Devices.', 'Try another USB port or cable, avoid hubs, or reconnect the device. You can also reset the device from Settings → Environment → USB Devices.',
uvc_capture_stall: '', uvc_capture_stall: '',
@@ -352,6 +358,10 @@ export default {
webrtcPhaseSetRemote: 'Applying remote description...', webrtcPhaseSetRemote: 'Applying remote description...',
webrtcPhaseApplyIce: 'Applying ICE candidates...', webrtcPhaseApplyIce: 'Applying ICE candidates...',
webrtcPhaseNegotiating: 'Negotiating secure connection...', webrtcPhaseNegotiating: 'Negotiating secure connection...',
mjpegPhaseWebsocket: 'Connecting control channel...',
mjpegPhaseStream: 'Requesting video stream...',
mjpegPhaseFirstFrame: 'Waiting for first frame...',
stepProgress: 'Step {current}/{total}',
pointerLocked: 'Pointer Locked', pointerLocked: 'Pointer Locked',
pointerLockedDesc: 'Press Escape to release the pointer', pointerLockedDesc: 'Press Escape to release the pointer',
pointerLockFailed: 'Failed to lock pointer', pointerLockFailed: 'Failed to lock pointer',
@@ -476,10 +486,11 @@ export default {
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
sidebarSubtitle: 'Manage device, network and extensions',
basic: 'Basic', basic: 'Basic',
general: 'General', general: 'General',
appearance: 'Appearance', appearance: 'Appearance',
account: 'User', account: 'Account',
access: 'Access', access: 'Access',
video: 'Video', video: 'Video',
encoder: 'Encoder', encoder: 'Encoder',
@@ -496,6 +507,19 @@ export default {
configured: 'Configured', configured: 'Configured',
security: 'Security', security: 'Security',
about: 'About', about: 'About',
appearanceSubtitle: 'Customize interface appearance and language',
accountSubtitle: 'Manage credentials and session policy',
networkSubtitle: 'Configure web server ports, listen addresses and SSL certificate',
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
hidSubtitle: 'Configure keyboard and mouse backend with USB gadget descriptors',
msdSubtitle: 'Manage Mass Storage Device image directory',
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
environmentSubtitle: 'System runtime environment and USB device maintenance',
aboutSubtitle: 'Online upgrade, version info and hardware overview',
extTtydSubtitle: 'Open a host Shell terminal in the browser',
extRustdeskSubtitle: 'Remote graphical access via RustDesk',
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
aboutDesc: 'Open and Lightweight IP-KVM Solution', aboutDesc: 'Open and Lightweight IP-KVM Solution',
deviceInfo: 'Device Info', deviceInfo: 'Device Info',
deviceInfoDesc: 'Host system information', deviceInfoDesc: 'Host system information',
@@ -512,8 +536,8 @@ export default {
changePassword: 'Change Password', changePassword: 'Change Password',
currentPassword: 'Current Password', currentPassword: 'Current Password',
newPassword: 'New Password', newPassword: 'New Password',
usernameDesc: 'Change your login username', usernameDesc: 'Change the console login username',
passwordDesc: 'Change your login password', passwordDesc: 'Change the console login password',
version: 'Version', version: 'Version',
buildInfo: 'Build Info', buildInfo: 'Build Info',
detectDevices: 'Detect Devices', detectDevices: 'Detect Devices',
@@ -542,20 +566,25 @@ export default {
addBindAddress: 'Add address', addBindAddress: 'Add address',
bindAddressListEmpty: 'Add at least one IP address.', bindAddressListEmpty: 'Add at least one IP address.',
httpsEnabled: 'Enable HTTPS', httpsEnabled: 'Enable HTTPS',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)', httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.',
portConfig: 'Port & Protocol', portConfig: 'Port & Protocol',
portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle', portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle',
httpPortReserved: 'HTTP port (reserved)', httpPortReserved: 'HTTP port (reserved)',
httpsPortReserved: 'HTTPS port (reserved)', httpsPortReserved: 'HTTPS port (reserved)',
portActive: 'Active',
portReserved: 'Reserved',
portReservedHint: 'The reserved port is applied only after switching protocol; you can preconfigure it now.',
previewUrl: 'Access URL preview', previewUrl: 'Access URL preview',
copyUrl: 'Copy access URL',
openInBrowser: 'Open in browser',
listenAddress: 'Listen Address', listenAddress: 'Listen Address',
listenAddressDesc: 'Configure which network interfaces the web server listens on', listenAddressDesc: 'Choose which network interfaces the web server binds to',
bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces', bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces',
bindModeLocalDesc: '127.0.0.1 — Allow local access only', bindModeLocalDesc: '127.0.0.1 — Loopback only (local access)',
bindModeCustomDesc: 'Specify a list of IP addresses', bindModeCustomDesc: 'Bind to a specific list of IP addresses',
effectiveAddresses: 'Listen address preview', effectiveAddresses: 'Effective listen addresses',
sslCertificate: 'SSL Certificate', sslCertificate: 'SSL Certificate',
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required', sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one. A service restart is required to apply.',
sslCertCustom: 'Custom Certificate', sslCertCustom: 'Custom Certificate',
sslCertSelfSigned: 'Self-Signed', sslCertSelfSigned: 'Self-Signed',
sslCertActive: 'Custom certificate is active', sslCertActive: 'Custom certificate is active',
@@ -568,6 +597,8 @@ export default {
sslCertSaved: 'Certificate saved, restart to apply', sslCertSaved: 'Certificate saved, restart to apply',
sslCertCleared: 'Reverted to self-signed certificate, restart to apply', sslCertCleared: 'Reverted to self-signed certificate, restart to apply',
restartRequired: 'Restart Required', restartRequired: 'Restart Required',
restartRequiredHint: 'The service will restart automatically to apply the new configuration.',
unsavedChangesHint: 'Click Save to apply changes',
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.', restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
restarting: 'Restarting...', restarting: 'Restarting...',
autoRestarting: 'Restarting automatically', autoRestarting: 'Restarting automatically',
@@ -600,10 +631,10 @@ export default {
updateMsgInstalling: 'Replacing binary', updateMsgInstalling: 'Replacing binary',
updateMsgRestarting: 'Restarting service', updateMsgRestarting: 'Restarting service',
auth: 'Access', auth: 'Access',
authSettings: 'Access Settings', authSettings: 'Session Policy',
authSettingsDesc: 'Single-user access and session behavior', authSettingsDesc: 'Configure single-user login and concurrent session behavior',
allowMultipleSessions: 'Allow multiple web sessions', allowMultipleSessions: 'Allow concurrent web sessions',
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.', allowMultipleSessionsDesc: 'When disabled, a new login automatically signs out the previous session',
userManagement: 'User Management', userManagement: 'User Management',
userManagementDesc: 'Manage user accounts and permissions', userManagementDesc: 'Manage user accounts and permissions',
addUser: 'Add User', addUser: 'Add User',
@@ -665,10 +696,10 @@ export default {
atxWolInterface: 'Network Interface', atxWolInterface: 'Network Interface',
atxWolInterfacePlaceholder: 'e.g. eth0, enp0s3', atxWolInterfacePlaceholder: 'e.g. eth0, enp0s3',
atxWolInterfaceHint: 'Specify network interface for WOL packets, leave empty for default routing', atxWolInterfaceHint: 'Specify network interface for WOL packets, leave empty for default routing',
themeDesc: 'Choose your preferred color scheme', themeDesc: 'Choose the interface color scheme',
languageDesc: 'Select your preferred language', languageDesc: 'Choose the interface display language',
videoSettings: 'Video Settings', videoSettings: 'Video Capture',
videoSettingsDesc: 'Configure video capture device', videoSettingsDesc: 'Configure capture device format, resolution and frame rate',
videoDevice: 'Video Device', videoDevice: 'Video Device',
selectDevice: 'Select device...', selectDevice: 'Select device...',
videoFormat: 'Video Format', videoFormat: 'Video Format',
@@ -676,13 +707,13 @@ export default {
driver: 'Driver', driver: 'Driver',
resolution: 'Resolution', resolution: 'Resolution',
frameRate: 'Frame Rate', frameRate: 'Frame Rate',
encoderBackend: 'Encoder Backend', encoderBackend: 'Video Encoder',
encoderBackendDesc: 'Select encoder backend for WebRTC streaming', encoderBackendDesc: 'Select the encoder backend used for WebRTC streaming',
backend: 'Backend', backend: 'Backend',
autoRecommended: 'Auto (Recommended)', autoRecommended: 'Auto (Recommended)',
software: 'Software', software: 'Software',
supportedFormats: 'Supported Formats', supportedFormats: 'Supported Codecs',
encoderHint: 'Hardware encoders provide better performance with lower CPU usage. Software encoders are more compatible but require more CPU resources.', encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.',
hidSettings: 'HID Settings', hidSettings: 'HID Settings',
hidSettingsDesc: 'Configure keyboard and mouse control', hidSettingsDesc: 'Configure keyboard and mouse control',
hidBackend: 'HID Backend', hidBackend: 'HID Backend',
@@ -824,20 +855,20 @@ export default {
resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?', resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?',
resetAction: 'Reset Device', resetAction: 'Reset Device',
}, },
webrtcSettings: 'WebRTC Settings', webrtcSettings: 'WebRTC Signaling',
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal', webrtcSettingsDesc: 'Configure STUN/TURN servers to assist NAT traversal',
publicIceServersHint: 'Empty uses Google public STUN, configure your own TURN for production', publicIceServersHint: 'Leave empty to use Google\u2019s public STUN servers; TURN must be self-hosted',
stunServer: 'STUN Server', stunServer: 'STUN Server',
stunServerPlaceholder: 'stun:stun.l.google.com:19302', stunServerPlaceholder: 'stun:stun.l.google.com:19302',
stunServerHint: 'Custom STUN server (leave empty to use Google public server)', stunServerHint: 'Leave empty to use Google\u2019s public STUN servers',
turnServer: 'TURN Server', turnServer: 'TURN Server',
turnServerPlaceholder: 'turn:turn.example.com:3478', turnServerPlaceholder: 'turn:turn.example.com:3478',
turnServerHint: 'Custom TURN relay server (required for production)', turnServerHint: 'TURN relay server. Strongly recommended for public deployments or strict NAT environments.',
turnUsername: 'TURN Username', turnUsername: 'TURN Username',
turnPassword: 'TURN Password', turnPassword: 'TURN Password',
turnPasswordConfigured: 'Password already configured. Leave empty to keep current password.', turnPasswordConfigured: 'A password is already saved. Leave empty to keep the current password.',
turnCredentialsHint: 'Credentials for TURN server authentication', turnCredentialsHint: 'Credentials used for TURN server authentication',
iceConfigNote: 'Note: Changes require reconnecting the WebRTC session to take effect.', iceConfigNote: 'Changes apply to the next WebRTC session',
}, },
virtualKeyboard: { virtualKeyboard: {
title: 'Virtual Keyboard', title: 'Virtual Keyboard',
@@ -872,6 +903,10 @@ export default {
format: 'Format', format: 'Format',
resolution: 'Resolution', resolution: 'Resolution',
fps: 'FPS', fps: 'FPS',
fpsTarget: 'Target FPS',
fpsActual: 'Actual FPS',
fpsStaticHint: 'Frame rate drops automatically while the image is static',
paused: 'Paused',
clients: 'Clients', clients: 'Clients',
backend: 'Backend', backend: 'Backend',
mouse: 'Mouse', mouse: 'Mouse',

View File

@@ -313,6 +313,10 @@ export default {
title: '视频设备已断开', title: '视频设备已断开',
detail: '采集卡离线,正在尝试重新识别…', detail: '采集卡离线,正在尝试重新识别…',
}, },
audioDeviceLost: {
title: '音频设备已断开',
detail: '正在尝试重新连接音频采集设备…',
},
deviceBusy: { deviceBusy: {
title: '视频通道忙', title: '视频通道忙',
detail: '正在切换配置或被其他组件占用,请稍候…', detail: '正在切换配置或被其他组件占用,请稍候…',
@@ -334,6 +338,8 @@ export default {
device_lost: '视频节点丢失,等待驱动恢复', device_lost: '视频节点丢失,等待驱动恢复',
config_changing: '正在应用新配置', config_changing: '正在应用新配置',
mode_switching: '正在切换视频模式', mode_switching: '正在切换视频模式',
audio_device_lost: '音频采集不可用,正在自动恢复',
audio_reconnecting: '正在重试连接音频设备',
uvc_usb_error: uvc_usb_error:
'可尝试更换 USB 口或线、避免 HUB、或重新插拔设备也可在 设置 → 环境 → USB 设备 中复位。', '可尝试更换 USB 口或线、避免 HUB、或重新插拔设备也可在 设置 → 环境 → USB 设备 中复位。',
uvc_capture_stall: '', uvc_capture_stall: '',
@@ -351,6 +357,10 @@ export default {
webrtcPhaseSetRemote: '正在应用远端会话描述...', webrtcPhaseSetRemote: '正在应用远端会话描述...',
webrtcPhaseApplyIce: '正在应用 ICE 候选...', webrtcPhaseApplyIce: '正在应用 ICE 候选...',
webrtcPhaseNegotiating: '正在协商安全连接...', webrtcPhaseNegotiating: '正在协商安全连接...',
mjpegPhaseWebsocket: '正在连接控制通道...',
mjpegPhaseStream: '正在请求视频流...',
mjpegPhaseFirstFrame: '正在等待首帧...',
stepProgress: '第 {current}/{total} 步',
pointerLocked: '鼠标已锁定', pointerLocked: '鼠标已锁定',
pointerLockedDesc: '按 Escape 键释放鼠标', pointerLockedDesc: '按 Escape 键释放鼠标',
pointerLockFailed: '鼠标锁定失败', pointerLockFailed: '鼠标锁定失败',
@@ -475,10 +485,11 @@ export default {
}, },
settings: { settings: {
title: '系统设置', title: '系统设置',
sidebarSubtitle: '管理设备、网络与扩展',
basic: '基础', basic: '基础',
general: '通用', general: '通用',
appearance: '外观', appearance: '外观',
account: '户', account: '户',
access: '访问', access: '访问',
video: '视频', video: '视频',
encoder: '编码器', encoder: '编码器',
@@ -495,6 +506,19 @@ export default {
configured: '已配置', configured: '已配置',
security: '安全', security: '安全',
about: '关于', about: '关于',
appearanceSubtitle: '自定义界面外观与显示语言',
accountSubtitle: '管理登录凭据与会话策略',
networkSubtitle: '配置 Web 服务端口、监听地址与 SSL 证书',
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
hidSubtitle: '配置键盘鼠标后端与 USB Gadget 描述符',
msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录',
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
environmentSubtitle: '系统级运行环境与 USB 设备维护',
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问',
extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流',
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
aboutDesc: '开放轻量的 IP-KVM 解决方案', aboutDesc: '开放轻量的 IP-KVM 解决方案',
deviceInfo: '设备信息', deviceInfo: '设备信息',
deviceInfoDesc: '主机系统信息', deviceInfoDesc: '主机系统信息',
@@ -511,8 +535,8 @@ export default {
changePassword: '修改密码', changePassword: '修改密码',
currentPassword: '当前密码', currentPassword: '当前密码',
newPassword: '新密码', newPassword: '新密码',
usernameDesc: '修改登录用户名', usernameDesc: '修改控制台登录用户名',
passwordDesc: '修改登录密码', passwordDesc: '修改控制台登录密码',
version: '版本', version: '版本',
buildInfo: '构建信息', buildInfo: '构建信息',
detectDevices: '探测设备', detectDevices: '探测设备',
@@ -541,20 +565,25 @@ export default {
addBindAddress: '添加地址', addBindAddress: '添加地址',
bindAddressListEmpty: '请至少填写一个 IP 地址。', bindAddressListEmpty: '请至少填写一个 IP 地址。',
httpsEnabled: '启用 HTTPS', httpsEnabled: '启用 HTTPS',
httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书', httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签证书',
portConfig: '端口与协议', portConfig: '端口与协议',
portConfigDesc: '服务一次只运行在一个端口,由 HTTPS 开关决定使用哪个端口', portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口',
httpPortReserved: 'HTTP 端口(备用)', httpPortReserved: 'HTTP 端口(备用)',
httpsPortReserved: 'HTTPS 端口(备用)', httpsPortReserved: 'HTTPS 端口(备用)',
portActive: '当前生效',
portReserved: '备用',
portReservedHint: '备用端口仅在切换协议后生效,可提前配置',
previewUrl: '访问地址预览', previewUrl: '访问地址预览',
copyUrl: '复制访问地址',
openInBrowser: '在浏览器中打开',
listenAddress: '监听地址', listenAddress: '监听地址',
listenAddressDesc: '配置 Web 服务监听哪些网络接口', listenAddressDesc: '配置 Web 服务监听哪些网络接口',
bindModeAllDesc: '0.0.0.0 — 监听所有网络接口', bindModeAllDesc: '0.0.0.0 — 监听所有网络接口',
bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问', bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问',
bindModeCustomDesc: '指定一组 IP 地址', bindModeCustomDesc: '指定一组 IP 地址',
effectiveAddresses: '监听地址预览', effectiveAddresses: '生效监听地址',
sslCertificate: 'SSL 证书', sslCertificate: 'SSL 证书',
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需重启生效', sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,保存后需重启服务生效',
sslCertCustom: '自定义证书', sslCertCustom: '自定义证书',
sslCertSelfSigned: '自签名证书', sslCertSelfSigned: '自签名证书',
sslCertActive: '自定义证书已启用', sslCertActive: '自定义证书已启用',
@@ -567,6 +596,8 @@ export default {
sslCertSaved: '证书已保存,重启后生效', sslCertSaved: '证书已保存,重启后生效',
sslCertCleared: '已恢复自签名证书,重启后生效', sslCertCleared: '已恢复自签名证书,重启后生效',
restartRequired: '需要重启', restartRequired: '需要重启',
restartRequiredHint: '保存后将自动重启服务以应用新配置',
unsavedChangesHint: '点击右侧按钮保存当前配置',
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。', restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
restarting: '正在重启...', restarting: '正在重启...',
autoRestarting: '正在自动重启', autoRestarting: '正在自动重启',
@@ -599,10 +630,10 @@ export default {
updateMsgInstalling: '替换程序中', updateMsgInstalling: '替换程序中',
updateMsgRestarting: '服务重启中', updateMsgRestarting: '服务重启中',
auth: '访问控制', auth: '访问控制',
authSettings: '访问设置', authSettings: '会话策略',
authSettingsDesc: '单用户访问与会话策略', authSettingsDesc: '配置单用户登录与并发会话规则',
allowMultipleSessions: '允许多个 Web 会话', allowMultipleSessions: '允许多个 Web 会话并存',
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话', allowMultipleSessionsDesc: '关闭后,新登录将自动踢出旧会话',
userManagement: '用户管理', userManagement: '用户管理',
userManagementDesc: '管理用户账号和权限', userManagementDesc: '管理用户账号和权限',
addUser: '添加用户', addUser: '添加用户',
@@ -664,10 +695,10 @@ export default {
atxWolInterface: '网络接口', atxWolInterface: '网络接口',
atxWolInterfacePlaceholder: '例如: eth0, enp0s3', atxWolInterfacePlaceholder: '例如: eth0, enp0s3',
atxWolInterfaceHint: '指定发送 WOL 包的网络接口,留空则使用系统默认路由', atxWolInterfaceHint: '指定发送 WOL 包的网络接口,留空则使用系统默认路由',
themeDesc: '选择您喜欢的颜色方案', themeDesc: '选择界面颜色方案',
languageDesc: '选择您的首选语言', languageDesc: '选择界面显示语言',
videoSettings: '视频设置', videoSettings: '视频采集',
videoSettingsDesc: '配置视频采集设备', videoSettingsDesc: '配置视频采集设备的格式、分辨率与帧率',
videoDevice: '视频设备', videoDevice: '视频设备',
selectDevice: '选择设备...', selectDevice: '选择设备...',
videoFormat: '视频格式', videoFormat: '视频格式',
@@ -675,13 +706,13 @@ export default {
driver: '驱动', driver: '驱动',
resolution: '分辨率', resolution: '分辨率',
frameRate: '帧率', frameRate: '帧率',
encoderBackend: '编码器后端', encoderBackend: '视频编码器',
encoderBackendDesc: '选择 WebRTC 流的编码器后端', encoderBackendDesc: '选择 WebRTC 输出使用的视频编码器后端',
backend: '后端', backend: '后端',
autoRecommended: '自动(推荐)', autoRecommended: '自动(推荐)',
software: '软件', software: '软件',
supportedFormats: '支持的格式', supportedFormats: '支持的编码格式',
encoderHint: '硬件编码器性能更好,CPU 占用更低。软件编码器兼容性更好,但需要更多 CPU 资源。', encoderHint: '硬件编码器延迟低、CPU 占用小;软件编码器兼容性更广但占用资源更多。',
hidSettings: 'HID 设置', hidSettings: 'HID 设置',
hidSettingsDesc: '配置键盘和鼠标控制', hidSettingsDesc: '配置键盘和鼠标控制',
hidBackend: 'HID 后端', hidBackend: 'HID 后端',
@@ -823,20 +854,20 @@ export default {
resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?', resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?',
resetAction: '确认复位', resetAction: '确认复位',
}, },
webrtcSettings: 'WebRTC 设置', webrtcSettings: 'WebRTC 信令',
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透', webrtcSettingsDesc: '配置 STUN/TURN 服务器以辅助 NAT 穿透',
publicIceServersHint: '留空将使用 Google 公共 STUN 服务器TURN 服务器需自行配置', publicIceServersHint: '留空将使用 Google 公共 STUN 服务器TURN 服务器需自行部署',
stunServer: 'STUN 服务器', stunServer: 'STUN 服务器',
stunServerPlaceholder: 'stun:stun.l.google.com:19302', stunServerPlaceholder: 'stun:stun.l.google.com:19302',
stunServerHint: '自定义 STUN 服务器(留空使用 Google 公共服务器', stunServerHint: '留空使用 Google 公共 STUN 服务器',
turnServer: 'TURN 服务器', turnServer: 'TURN 服务器',
turnServerPlaceholder: 'turn:turn.example.com:3478', turnServerPlaceholder: 'turn:turn.example.com:3478',
turnServerHint: '自定义 TURN 中继服务器(生产环境必须配置', turnServerHint: 'TURN 中继服务器;公网部署或严格 NAT 环境强烈建议配置',
turnUsername: 'TURN 用户名', turnUsername: 'TURN 用户名',
turnPassword: 'TURN 密码', turnPassword: 'TURN 密码',
turnPasswordConfigured: '密码已配置。留空则保持当前密码。', turnPasswordConfigured: '密码已保存。留空则保持当前密码不变。',
turnCredentialsHint: '用于 TURN 服务器证的凭据', turnCredentialsHint: '用于 TURN 服务器身份验证的凭据',
iceConfigNote: '注意:更改后需要重新连接 WebRTC 会话才能生效', iceConfigNote: '更改后将在下一次 WebRTC 会话建立时生效',
}, },
virtualKeyboard: { virtualKeyboard: {
title: '虚拟键盘', title: '虚拟键盘',
@@ -871,6 +902,10 @@ export default {
format: '格式', format: '格式',
resolution: '分辨率', resolution: '分辨率',
fps: '帧率', fps: '帧率',
fpsTarget: '目标帧率',
fpsActual: '实际帧率',
fpsStaticHint: '画面静止时会自动降帧',
paused: '已暂停',
clients: '客户端', clients: '客户端',
backend: '后端', backend: '后端',
mouse: '鼠标', mouse: '鼠标',

15
web/src/lib/debugLog.ts Normal file
View File

@@ -0,0 +1,15 @@
export function isDebugLogEnabled(): boolean {
if (typeof window === 'undefined') return false
return new URLSearchParams(window.location.search).get('log') === 'debug'
}
export function videoDebugLog(message: string, details?: unknown): void {
if (!isDebugLogEnabled()) return
const timestamp = new Date().toISOString()
if (details === undefined) {
console.log(`[VideoDebug ${timestamp}] ${message}`)
} else {
console.log(`[VideoDebug ${timestamp}] ${message}`, details)
}
}

View File

@@ -0,0 +1,11 @@
import type { StreamDeviceLostEventData } from '@/types/websocket'
const AUDIO_STATE_REASONS = new Set(['audio_device_lost', 'audio_reconnecting'])
export function isAudioDeviceLostStateReason(reason: string | null | undefined): boolean {
return typeof reason === 'string' && AUDIO_STATE_REASONS.has(reason)
}
export function isAudioStreamDeviceLostPayload(data: StreamDeviceLostEventData): boolean {
return data.kind === 'audio'
}

View File

@@ -35,6 +35,14 @@ export function buildWsUrl(path: string): string {
/** Default reconnect delay in milliseconds */ /** Default reconnect delay in milliseconds */
export const WS_RECONNECT_DELAY = 3000 export const WS_RECONNECT_DELAY = 3000
export type StreamDeviceLostKind = 'video' | 'audio'
export interface StreamDeviceLostEventData {
kind: StreamDeviceLostKind
device: string
reason: string
}
/** WebSocket ready states */ /** WebSocket ready states */
export const WS_STATE = { export const WS_STATE = {
CONNECTING: WebSocket.CONNECTING, CONNECTING: WebSocket.CONNECTING,

View File

@@ -16,8 +16,11 @@ import { CanonicalKey, HidBackend } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid' import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings' import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils' import { cn, generateUUID } from '@/lib/utils'
import { formatFpsValue } from '@/lib/fps' import { formatFpsValue } from '@/lib/fps'
import { videoDebugLog } from '@/lib/debugLog'
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
import type { StreamDeviceLostEventData } from '@/types/websocket'
import type { VideoMode } from '@/components/VideoConfigPopover.vue' import type { VideoMode } from '@/components/VideoConfigPopover.vue'
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue' import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
@@ -127,7 +130,6 @@ const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 }) const lastMousePosition = ref({ x: 0, y: 0 })
const isPointerLocked = ref(false) const isPointerLocked = ref(false)
/** Local overlay crosshair position (px, relative to video container); HID uses mousePosition separately */
const localCrosshairPos = ref<{ x: number; y: number } | null>(null) const localCrosshairPos = ref<{ x: number; y: number } | null>(null)
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16 const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
@@ -180,10 +182,6 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
if (webrtc.isConnecting.value) return 'connecting' if (webrtc.isConnecting.value) return 'connecting'
if (webrtc.isConnected.value) return 'connected' if (webrtc.isConnected.value) return 'connected'
} }
// MJPEG: check if frames have actually arrived (frontend-side detection)
// This is more reliable than relying on stream.online from backend,
// which can be stale due to the debounce delay in device_info broadcaster.
// Also handles browsers that don't fire img.onload for multipart MJPEG streams.
if (videoMode.value === 'mjpeg' && mjpegFrameReceived.value) return 'connected' if (videoMode.value === 'mjpeg' && mjpegFrameReceived.value) return 'connected'
if (systemStore.stream?.online) return 'connected' if (systemStore.stream?.online) return 'connected'
return 'disconnected' return 'disconnected'
@@ -201,10 +199,18 @@ function getResolutionShortName(width: number, height: number): string {
return `${height}p` return `${height}p`
} }
const isMjpegPaused = computed(() => {
if (videoMode.value !== 'mjpeg') return false
const stream = systemStore.stream
if (!stream) return false
return stream.online === false
})
const videoQuickInfo = computed(() => { const videoQuickInfo = computed(() => {
const stream = systemStore.stream const stream = systemStore.stream
if (!stream?.resolution) return '' if (!stream?.resolution) return ''
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1]) const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
if (isMjpegPaused.value) return `${resShort} ${t('statusCard.paused')}`
return `${resShort} ${formatFpsValue(backendFps.value)}fps` return `${resShort} ${formatFpsValue(backendFps.value)}fps`
}) })
@@ -212,20 +218,36 @@ const videoDetails = computed<StatusDetail[]>(() => {
const stream = systemStore.stream const stream = systemStore.stream
if (!stream) return [] if (!stream) return []
const receivedFps = backendFps.value const receivedFps = backendFps.value
const paused = isMjpegPaused.value
const inputFmt = stream.format || 'MJPEG' const inputFmt = stream.format || 'MJPEG'
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)` const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt}${outputFmt}` const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt}${outputFmt}`
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}` const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
const actualStatus: StatusDetail['status'] = paused
? undefined
: receivedFps > 5 ? 'ok'
: receivedFps > 0 ? 'warning'
: 'error'
return [ const details: StatusDetail[] = [
{ label: t('statusCard.device'), value: stream.device || '-' }, { label: t('statusCard.device'), value: stream.device || '-' },
{ label: t('statusCard.format'), value: formatDisplay }, { label: t('statusCard.format'), value: formatDisplay },
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' }, { label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
{ label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus }, { label: t('statusCard.fpsTarget'), value: targetFpsValue },
{ label: t('statusCard.fpsActual'), value: actualFpsValue, status: actualStatus },
] ]
if (videoMode.value === 'mjpeg' && !paused && receivedFps > 0 && receivedFps < (stream.targetFps ?? 0)) {
details.push({
label: '',
value: t('statusCard.fpsStaticHint'),
})
}
return details
}) })
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => { const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
@@ -418,7 +440,7 @@ const audioDetails = computed<StatusDetail[]>(() => {
return [ return [
{ label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') }, { label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') },
{ label: t('statusCard.quality'), value: translateAudioQuality(audio.quality) }, { label: t('statusCard.quality'), value: translateAudioQuality(audio.quality) },
{ label: t('statusCard.streaming'), value: audio.streaming ? t('statusCard.yes') : t('statusCard.no'), status: audio.streaming ? 'ok' : undefined }, { label: t('statusCard.streaming'), value: audio.streaming ? t('common.yes') : t('common.no'), status: audio.streaming ? 'ok' : undefined },
] ]
}) })
@@ -484,9 +506,48 @@ const msdDetails = computed<StatusDetail[]>(() => {
return details return details
}) })
const WEBRTC_PROGRESS_STAGES = [
'fetching_ice_servers',
'creating_peer_connection',
'creating_data_channel',
'creating_offer',
'waiting_server_answer',
'setting_remote_description',
'applying_ice_candidates',
'waiting_connection',
] as const
const MJPEG_PROGRESS_STAGES = [
'connecting_websocket',
'requesting_stream',
'waiting_first_frame',
] as const
type MjpegProgressStage = (typeof MJPEG_PROGRESS_STAGES)[number]
const mjpegConnectStage = computed<MjpegProgressStage | null>(() => {
if (videoMode.value !== 'mjpeg') return null
if (videoRestarting.value) return null
if (!wsConnected.value || wsNetworkError.value) return 'connecting_websocket'
if (mjpegTimestamp.value === 0) return 'requesting_stream'
if (!mjpegFrameReceived.value) return 'waiting_first_frame'
return null
})
const webrtcLoadingMessage = computed(() => { const webrtcLoadingMessage = computed(() => {
if (videoMode.value === 'mjpeg') { if (videoMode.value === 'mjpeg') {
return videoRestarting.value ? t('console.videoRestarting') : t('console.connecting') if (videoRestarting.value) return t('console.videoRestarting')
switch (mjpegConnectStage.value) {
case 'connecting_websocket':
return t('console.mjpegPhaseWebsocket')
case 'requesting_stream':
return t('console.mjpegPhaseStream')
case 'waiting_first_frame':
return t('console.mjpegPhaseFirstFrame')
default:
return t('console.connecting')
}
} }
switch (webrtc.connectStage.value) { switch (webrtc.connectStage.value) {
@@ -515,6 +576,44 @@ const webrtcLoadingMessage = computed(() => {
} }
}) })
const connectProgress = computed<{ current: number; total: number } | null>(() => {
if (videoMode.value === 'mjpeg') {
const stage = mjpegConnectStage.value
if (!stage) return null
return {
current: MJPEG_PROGRESS_STAGES.indexOf(stage) + 1,
total: MJPEG_PROGRESS_STAGES.length,
}
}
const stage = webrtc.connectStage.value
const idx = WEBRTC_PROGRESS_STAGES.indexOf(stage as (typeof WEBRTC_PROGRESS_STAGES)[number])
if (idx < 0) return null
return {
current: idx + 1,
total: WEBRTC_PROGRESS_STAGES.length,
}
})
const videoContainerStyle = computed(() => {
if (!videoAspectRatio.value) {
return {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
minHeight: '120px',
}
}
return {
aspectRatio: videoAspectRatio.value,
maxWidth: '100%',
maxHeight: '100%',
minHeight: '120px',
}
})
const showMsdStatusCard = computed(() => { const showMsdStatusCard = computed(() => {
return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329') return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329')
}) })
@@ -603,7 +702,6 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
}) })
} }
/** For WebRTC watch: skip auto-reconnect when these hold. */
function shouldSuppressAutoReconnect(): boolean { function shouldSuppressAutoReconnect(): boolean {
return videoMode.value === 'mjpeg' return videoMode.value === 'mjpeg'
|| !isConsoleActive.value || !isConsoleActive.value
@@ -613,6 +711,14 @@ function shouldSuppressAutoReconnect(): boolean {
} }
function markWebRTCFailure(reason: string, description?: string) { function markWebRTCFailure(reason: string, description?: string) {
videoDebugLog('Marking WebRTC failure', {
reason,
description,
videoMode: videoMode.value,
webrtcState: webrtc.state.value,
webrtcStage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
pendingWebRTCReadyGate = false pendingWebRTCReadyGate = false
videoError.value = true videoError.value = true
videoErrorMessage.value = reason videoErrorMessage.value = reason
@@ -627,25 +733,45 @@ function markWebRTCFailure(reason: string, description?: string) {
async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise<void> { async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise<void> {
if (!pendingWebRTCReadyGate) return if (!pendingWebRTCReadyGate) return
videoDebugLog('Waiting for WebRTC backend ready gate', { reason, timeoutMs })
const ready = await videoSession.waitForWebRTCReadyAny(timeoutMs) const ready = await videoSession.waitForWebRTCReadyAny(timeoutMs)
if (!ready) { if (!ready) {
console.warn(`[WebRTC] Ready gate timeout (${reason}), attempting connection anyway`) console.warn(`[WebRTC] Ready gate timeout (${reason}), attempting connection anyway`)
} }
videoDebugLog('WebRTC backend ready gate completed', { reason, ready })
pendingWebRTCReadyGate = false pendingWebRTCReadyGate = false
} }
async function connectWebRTCSerial(reason: string): Promise<boolean> { async function connectWebRTCSerial(reason: string): Promise<boolean> {
if (webrtcConnectTask) { if (webrtcConnectTask) {
videoDebugLog('Reusing serialized WebRTC connect task', {
reason,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
return webrtcConnectTask return webrtcConnectTask
} }
videoDebugLog('Starting serialized WebRTC connect task', {
reason,
videoMode: videoMode.value,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
webrtcConnectTask = (async () => { webrtcConnectTask = (async () => {
await waitForWebRTCReadyGate(reason) await waitForWebRTCReadyGate(reason)
return webrtc.connect() return webrtc.connect()
})() })()
try { try {
return await webrtcConnectTask const result = await webrtcConnectTask
videoDebugLog('Serialized WebRTC connect task finished', {
reason,
result,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
return result
} finally { } finally {
webrtcConnectTask = null webrtcConnectTask = null
} }
@@ -740,7 +866,11 @@ function handleVideoError() {
}, delay) }, delay)
} }
function handleStreamDeviceLost(data: { device: string; reason: string }) { function handleStreamDeviceLost(data: StreamDeviceLostEventData) {
videoDebugLog('Stream device lost event', data)
if (isAudioStreamDeviceLostPayload(data)) {
return
}
videoError.value = true videoError.value = true
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason }) videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
@@ -750,6 +880,12 @@ function handleStreamDeviceLost(data: { device: string; reason: string }) {
} }
function scheduleWebRTCRecovery() { function scheduleWebRTCRecovery() {
videoDebugLog('Scheduling WebRTC recovery check', {
attempts: webrtcRecoveryAttempts,
videoMode: videoMode.value,
videoError: videoError.value,
sessionId: webrtc.sessionId.value,
})
if (webrtcRecoveryTimerId !== null) { if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId) clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null webrtcRecoveryTimerId = null
@@ -798,6 +934,10 @@ function scheduleWebRTCRecovery() {
} }
function cancelWebRTCRecovery() { function cancelWebRTCRecovery() {
videoDebugLog('Cancelling WebRTC recovery', {
attempts: webrtcRecoveryAttempts,
hadTimer: webrtcRecoveryTimerId !== null,
})
if (webrtcRecoveryTimerId !== null) { if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId) clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null webrtcRecoveryTimerId = null
@@ -806,6 +946,7 @@ function cancelWebRTCRecovery() {
} }
function handleStreamRecovered(_data: { device: string }) { function handleStreamRecovered(_data: { device: string }) {
videoDebugLog('Stream recovered event', _data)
cancelWebRTCRecovery() cancelWebRTCRecovery()
videoError.value = false videoError.value = false
@@ -836,6 +977,13 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
} }
function handleStreamConfigChanging(_data: any) { function handleStreamConfigChanging(_data: any) {
videoDebugLog('Stream config changing event', {
data: _data,
videoMode: videoMode.value,
webrtcState: webrtc.state.value,
webrtcStage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
if (retryTimeoutId !== null) { if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId) clearTimeout(retryTimeoutId)
retryTimeoutId = null retryTimeoutId = null
@@ -856,6 +1004,13 @@ function handleStreamConfigChanging(_data: any) {
} }
async function handleStreamConfigApplied(_data: any) { async function handleStreamConfigApplied(_data: any) {
videoDebugLog('Stream config applied event', {
data: _data,
videoMode: videoMode.value,
isModeSwitching: isModeSwitching.value,
webrtcState: webrtc.state.value,
webrtcStage: webrtc.connectStage.value,
})
consecutiveErrors = 0 consecutiveErrors = 0
gracePeriodTimeoutId = window.setTimeout(() => { gracePeriodTimeoutId = window.setTimeout(() => {
@@ -881,11 +1036,23 @@ async function handleStreamConfigApplied(_data: any) {
function handleWebRTCReady(data: { codec: string; hardware: boolean; transition_id?: string }) { function handleWebRTCReady(data: { codec: string; hardware: boolean; transition_id?: string }) {
console.log(`[WebRTCReady] Backend ready: codec=${data.codec}, hardware=${data.hardware}, transition_id=${data.transition_id || '-'}`) console.log(`[WebRTCReady] Backend ready: codec=${data.codec}, hardware=${data.hardware}, transition_id=${data.transition_id || '-'}`)
videoDebugLog('WebRTC backend ready event', {
...data,
pendingWebRTCReadyGate,
activeTransitionId: videoSession.activeTransitionId.value,
expectedTransitionId: videoSession.expectedTransitionId.value,
})
pendingWebRTCReadyGate = false pendingWebRTCReadyGate = false
videoSession.onWebRTCReady(data) videoSession.onWebRTCReady(data)
} }
function handleStreamModeReady(data: { transition_id: string; mode: string }) { function handleStreamModeReady(data: { transition_id: string; mode: string }) {
videoDebugLog('Stream mode ready event', {
data,
videoMode: videoMode.value,
localSwitching: videoSession.localSwitching.value,
backendSwitching: videoSession.backendSwitching.value,
})
videoSession.onModeReady(data) videoSession.onModeReady(data)
if (data.mode === 'mjpeg') { if (data.mode === 'mjpeg') {
pendingWebRTCReadyGate = false pendingWebRTCReadyGate = false
@@ -894,6 +1061,12 @@ function handleStreamModeReady(data: { transition_id: string; mode: string }) {
} }
function handleStreamModeSwitching(data: { transition_id: string; to_mode: string; from_mode: string }) { function handleStreamModeSwitching(data: { transition_id: string; to_mode: string; from_mode: string }) {
videoDebugLog('Stream mode switching event', {
data,
videoMode: videoMode.value,
localSwitching: videoSession.localSwitching.value,
backendSwitching: videoSession.backendSwitching.value,
})
if (!isModeSwitching.value) { if (!isModeSwitching.value) {
videoRestarting.value = true videoRestarting.value = true
videoLoading.value = true videoLoading.value = true
@@ -904,6 +1077,13 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin
} }
function handleStreamStateChanged(data: any) { function handleStreamStateChanged(data: any) {
videoDebugLog('Stream state changed event', {
data,
videoMode: videoMode.value,
previousSignalState: streamSignalState.value,
webrtcState: webrtc.state.value,
webrtcStage: webrtc.connectStage.value,
})
const state = typeof data?.state === 'string' ? data.state : '' const state = typeof data?.state === 'string' ? data.state : ''
const reason = typeof data?.reason === 'string' && data.reason.length > 0 ? data.reason : null const reason = typeof data?.reason === 'string' && data.reason.length > 0 ? data.reason : null
const nextRetry = typeof data?.next_retry_ms === 'number' && data.next_retry_ms > 0 const nextRetry = typeof data?.next_retry_ms === 'number' && data.next_retry_ms > 0
@@ -950,7 +1130,11 @@ function handleStreamStateChanged(data: any) {
captureFrameOverlay().catch(() => {}) captureFrameOverlay().catch(() => {})
} }
} else if (state === 'device_lost' && videoMode.value !== 'mjpeg') { } else if (state === 'device_lost' && videoMode.value !== 'mjpeg') {
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) { if (
!isAudioDeviceLostStateReason(reason)
&& webrtcRecoveryTimerId === null
&& webrtcRecoveryAttempts === 0
) {
scheduleWebRTCRecovery() scheduleWebRTCRecovery()
} }
} else if (state === 'streaming') { } else if (state === 'streaming') {
@@ -1014,6 +1198,14 @@ const signalOverlayInfo = computed(() => {
tone: 'info' as const, tone: 'info' as const,
} }
case 'device_lost': case 'device_lost':
if (isAudioDeviceLostStateReason(reason)) {
return {
title: t('console.signal.audioDeviceLost.title'),
detail: t('console.signal.audioDeviceLost.detail'),
hint,
tone: 'error' as const,
}
}
return { return {
title: t('console.signal.deviceLost.title'), title: t('console.signal.deviceLost.title'),
detail: t('console.signal.deviceLost.detail'), detail: t('console.signal.deviceLost.detail'),
@@ -1076,6 +1268,10 @@ function normalizeServerMode(mode: string | undefined): VideoMode | null {
async function restoreInitialMode(serverMode: VideoMode) { async function restoreInitialMode(serverMode: VideoMode) {
if (initialModeRestoreDone || initialModeRestoreInProgress) return if (initialModeRestoreDone || initialModeRestoreInProgress) return
initialModeRestoreInProgress = true initialModeRestoreInProgress = true
videoDebugLog('Restoring initial video mode from backend', {
serverMode,
currentMode: videoMode.value,
})
try { try {
initialDeviceInfoReceived = true initialDeviceInfoReceived = true
@@ -1097,6 +1293,14 @@ async function restoreInitialMode(serverMode: VideoMode) {
} }
function handleDeviceInfo(data: any) { function handleDeviceInfo(data: any) {
videoDebugLog('Device info event received', {
streamMode: data.video?.stream_mode,
configChanging: data.video?.config_changing,
currentVideoMode: videoMode.value,
initialDeviceInfoReceived,
initialModeRestoreDone,
initialModeRestoreInProgress,
})
const prevAudioStreaming = systemStore.audio?.streaming ?? false const prevAudioStreaming = systemStore.audio?.streaming ?? false
const prevAudioDevice = systemStore.audio?.device ?? null const prevAudioDevice = systemStore.audio?.device ?? null
systemStore.updateFromDeviceInfo(data) systemStore.updateFromDeviceInfo(data)
@@ -1114,7 +1318,6 @@ function handleDeviceInfo(data: any) {
}) })
} }
// This prevents false-positive mode changes during config switching
if (data.video?.config_changing) { if (data.video?.config_changing) {
return return
} }
@@ -1139,10 +1342,14 @@ function handleDeviceInfo(data: any) {
} }
function handleStreamModeChanged(data: { mode: string; previous_mode: string }) { function handleStreamModeChanged(data: { mode: string; previous_mode: string }) {
videoDebugLog('Stream mode changed event', {
data,
currentVideoMode: videoMode.value,
localSwitching: isModeSwitching.value,
})
const newMode = normalizeServerMode(data.mode) const newMode = normalizeServerMode(data.mode)
if (!newMode) return if (!newMode) return
// Ignore this during a local mode switch because it was triggered by our own request
if (isModeSwitching.value) { if (isModeSwitching.value) {
console.log('[StreamModeChanged] Mode switch in progress, ignoring event') console.log('[StreamModeChanged] Mode switch in progress, ignoring event')
return return
@@ -1161,6 +1368,11 @@ function reloadPage() {
} }
function refreshVideo() { function refreshVideo() {
videoDebugLog('Refreshing MJPEG video', {
videoMode: videoMode.value,
previousTimestamp: mjpegTimestamp.value,
streamSignalState: streamSignalState.value,
})
backendFps.value = 0 backendFps.value = 0
videoError.value = false videoError.value = false
videoErrorMessage.value = '' videoErrorMessage.value = ''
@@ -1178,12 +1390,6 @@ function refreshVideo() {
}, 1500) }, 1500)
} }
// MJPEG URL with cache-busting timestamp (reactive)
// Only return valid URL when in MJPEG mode and the backend reports a
// healthy stream. When the backend goes offline (no_signal / device_lost
// / device_busy) we deliberately return an empty string so the `<img>`
// tag has no `src` and the 4-state overlay fully owns the video area —
// no more fake placeholder JPEG peeking through.
const mjpegTimestamp = ref(0) const mjpegTimestamp = ref(0)
const mjpegUrl = computed(() => { const mjpegUrl = computed(() => {
if (videoMode.value !== 'mjpeg') { if (videoMode.value !== 'mjpeg') {
@@ -1199,6 +1405,12 @@ const mjpegUrl = computed(() => {
}) })
async function connectWebRTCOnly(codec: VideoMode = 'h264') { async function connectWebRTCOnly(codec: VideoMode = 'h264') {
videoDebugLog('Connecting WebRTC without mode switch', {
codec,
currentMode: videoMode.value,
webrtcState: webrtc.state.value,
sessionId: webrtc.sessionId.value,
})
if (retryTimeoutId !== null) { if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId) clearTimeout(retryTimeoutId)
retryTimeoutId = null retryTimeoutId = null
@@ -1222,9 +1434,13 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
try { try {
const success = await connectWebRTCSerial('connectWebRTCOnly') const success = await connectWebRTCSerial('connectWebRTCOnly')
videoDebugLog('WebRTC-only connect result', {
codec,
success,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
if (success) { if (success) {
// Force video rebind even when the track already exists
// This fixes missing video after returning to the page
await rebindWebRTCVideo() await rebindWebRTCVideo()
videoLoading.value = false videoLoading.value = false
@@ -1239,6 +1455,12 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
} }
async function rebindWebRTCVideo() { async function rebindWebRTCVideo() {
videoDebugLog('Rebinding WebRTC video element', {
hasVideoElement: Boolean(webrtcVideoRef.value),
hasVideoTrack: Boolean(webrtc.videoTrack.value),
hasAudioTrack: Boolean(webrtc.audioTrack.value),
sessionId: webrtc.sessionId.value,
})
if (!webrtcVideoRef.value) return if (!webrtcVideoRef.value) return
webrtcVideoRef.value.srcObject = null webrtcVideoRef.value.srcObject = null
@@ -1259,6 +1481,12 @@ async function rebindWebRTCVideo() {
} }
async function switchToWebRTC(codec: VideoMode = 'h264') { async function switchToWebRTC(codec: VideoMode = 'h264') {
videoDebugLog('Switching to WebRTC mode', {
codec,
currentMode: videoMode.value,
webrtcState: webrtc.state.value,
sessionId: webrtc.sessionId.value,
})
if (retryTimeoutId !== null) { if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId) clearTimeout(retryTimeoutId)
retryTimeoutId = null retryTimeoutId = null
@@ -1281,18 +1509,27 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
pendingWebRTCReadyGate = true pendingWebRTCReadyGate = true
try { try {
// Disconnect first so ICE candidates are not sent to stale sessions during backend codec switch.
if (webrtc.isConnected.value || webrtc.sessionId.value) { if (webrtc.isConnected.value || webrtc.sessionId.value) {
await webrtc.disconnect() await webrtc.disconnect()
} }
const modeResp = await streamApi.setMode(codec) const modeResp = await streamApi.setMode(codec)
videoDebugLog('Backend setMode response for WebRTC', {
codec,
response: modeResp,
})
if (modeResp.transition_id) { if (modeResp.transition_id) {
videoSession.registerTransition(modeResp.transition_id) videoSession.registerTransition(modeResp.transition_id)
const [mode, webrtcReady] = await Promise.all([ const [mode, webrtcReady] = await Promise.all([
videoSession.waitForModeReady(modeResp.transition_id, 5000), videoSession.waitForModeReady(modeResp.transition_id, 5000),
videoSession.waitForWebRTCReady(modeResp.transition_id, 3000), videoSession.waitForWebRTCReady(modeResp.transition_id, 3000),
]) ])
videoDebugLog('Backend WebRTC mode transition wait finished', {
codec,
transitionId: modeResp.transition_id,
mode,
webrtcReady,
})
if (mode && mode !== codec && mode !== 'webrtc') { if (mode && mode !== codec && mode !== 'webrtc') {
console.warn(`[WebRTC] Backend mode_ready returned '${mode}', expected '${codec}', falling back`) console.warn(`[WebRTC] Backend mode_ready returned '${mode}', expected '${codec}', falling back`)
@@ -1310,12 +1547,26 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
const RETRY_DELAYS = [200, 800] const RETRY_DELAYS = [200, 800]
let success = false let success = false
for (let attempt = 0; attempt < MAX_ATTEMPTS && !success; attempt++) { for (let attempt = 0; attempt < MAX_ATTEMPTS && !success; attempt++) {
videoDebugLog('WebRTC connect attempt for mode switch', {
codec,
attempt: attempt + 1,
maxAttempts: MAX_ATTEMPTS,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
if (attempt > 0) { if (attempt > 0) {
const delay = RETRY_DELAYS[attempt - 1] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1] const delay = RETRY_DELAYS[attempt - 1] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1]
console.log(`[WebRTC] Connection failed, retrying in ${delay}ms (${MAX_ATTEMPTS - attempt} attempts left)`) console.log(`[WebRTC] Connection failed, retrying in ${delay}ms (${MAX_ATTEMPTS - attempt} attempts left)`)
await new Promise(resolve => setTimeout(resolve, delay)) await new Promise(resolve => setTimeout(resolve, delay))
} }
success = await connectWebRTCSerial('switchToWebRTC') success = await connectWebRTCSerial('switchToWebRTC')
videoDebugLog('WebRTC connect attempt finished for mode switch', {
codec,
attempt: attempt + 1,
success,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
} }
if (success) { if (success) {
await rebindWebRTCVideo() await rebindWebRTCVideo()
@@ -1332,6 +1583,11 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
} }
async function switchToMJPEG() { async function switchToMJPEG() {
videoDebugLog('Switching to MJPEG mode', {
currentMode: videoMode.value,
webrtcState: webrtc.state.value,
sessionId: webrtc.sessionId.value,
})
videoLoading.value = true videoLoading.value = true
videoError.value = false videoError.value = false
videoErrorMessage.value = '' videoErrorMessage.value = ''
@@ -1339,6 +1595,7 @@ async function switchToMJPEG() {
try { try {
const modeResp = await streamApi.setMode('mjpeg') const modeResp = await streamApi.setMode('mjpeg')
videoDebugLog('Backend setMode response for MJPEG', modeResp)
if (modeResp.transition_id) { if (modeResp.transition_id) {
videoSession.registerTransition(modeResp.transition_id) videoSession.registerTransition(modeResp.transition_id)
const mode = await videoSession.waitForModeReady(modeResp.transition_id, 5000) const mode = await videoSession.waitForModeReady(modeResp.transition_id, 5000)
@@ -1364,6 +1621,12 @@ async function switchToMJPEG() {
} }
function syncToServerMode(mode: VideoMode) { function syncToServerMode(mode: VideoMode) {
videoDebugLog('Syncing frontend video mode to backend mode', {
mode,
currentMode: videoMode.value,
localSwitching: videoSession.localSwitching.value,
backendSwitching: videoSession.backendSwitching.value,
})
if (videoSession.localSwitching.value || videoSession.backendSwitching.value) return if (videoSession.localSwitching.value || videoSession.backendSwitching.value) return
if (mode === videoMode.value) return if (mode === videoMode.value) return
@@ -1378,6 +1641,12 @@ function syncToServerMode(mode: VideoMode) {
} }
async function handleVideoModeChange(mode: VideoMode) { async function handleVideoModeChange(mode: VideoMode) {
videoDebugLog('User requested video mode change', {
requestedMode: mode,
currentMode: videoMode.value,
webrtcState: webrtc.state.value,
sessionId: webrtc.sessionId.value,
})
if (mode === videoMode.value) return if (mode === videoMode.value) return
if (!videoSession.tryStartLocalSwitch()) { if (!videoSession.tryStartLocalSwitch()) {
console.log('[VideoMode] Switch throttled or in progress, ignoring') console.log('[VideoMode] Switch throttled or in progress, ignoring')
@@ -1410,12 +1679,26 @@ async function handleVideoModeChange(mode: VideoMode) {
} }
watch(() => webrtc.videoTrack.value, async (track) => { watch(() => webrtc.videoTrack.value, async (track) => {
videoDebugLog('WebRTC video track ref changed', {
hasTrack: Boolean(track),
trackId: track?.id,
readyState: track?.readyState,
muted: track?.muted,
videoMode: videoMode.value,
})
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') { if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
await rebindWebRTCVideo() await rebindWebRTCVideo()
} }
}) })
watch(() => webrtc.audioTrack.value, async (track) => { watch(() => webrtc.audioTrack.value, async (track) => {
videoDebugLog('WebRTC audio track ref changed', {
hasTrack: Boolean(track),
trackId: track?.id,
readyState: track?.readyState,
muted: track?.muted,
videoMode: videoMode.value,
})
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') { if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null
if (currentStream && currentStream.getAudioTracks().length === 0) { if (currentStream && currentStream.getAudioTracks().length === 0) {
@@ -1429,6 +1712,19 @@ watch(webrtcVideoRef, (el) => {
}, { immediate: true }) }, { immediate: true })
watch(webrtc.stats, (stats) => { watch(webrtc.stats, (stats) => {
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
videoDebugLog('WebRTC video stats updated with active frames', {
fps: stats.framesPerSecond,
frameWidth: stats.frameWidth,
frameHeight: stats.frameHeight,
packetsReceived: stats.packetsReceived,
packetsLost: stats.packetsLost,
localCandidateType: stats.localCandidateType,
remoteCandidateType: stats.remoteCandidateType,
transportProtocol: stats.transportProtocol,
isRelay: stats.isRelay,
})
}
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) { if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
backendFps.value = Math.round(stats.framesPerSecond) backendFps.value = Math.round(stats.framesPerSecond)
systemStore.setStreamOnline(true) systemStore.setStreamOnline(true)
@@ -1442,14 +1738,21 @@ let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
let webrtcReconnectFailures = 0 let webrtcReconnectFailures = 0
watch(() => webrtc.state.value, (newState, oldState) => { watch(() => webrtc.state.value, (newState, oldState) => {
console.log('[WebRTC] State changed:', oldState, '->', newState) console.log('[WebRTC] State changed:', oldState, '->', newState)
videoDebugLog('WebRTC state watcher observed change', {
oldState,
newState,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
videoMode: videoMode.value,
videoLoading: videoLoading.value,
suppressAutoReconnect: shouldSuppressAutoReconnect(),
})
if (webrtcReconnectTimeout) { if (webrtcReconnectTimeout) {
clearTimeout(webrtcReconnectTimeout) clearTimeout(webrtcReconnectTimeout)
webrtcReconnectTimeout = null webrtcReconnectTimeout = null
} }
// Run before `shouldSuppressAutoReconnect()` so `device_busy` / `videoRestarting`
// never blocks clearing the loading overlay when ICE becomes connected.
if (videoMode.value !== 'mjpeg') { if (videoMode.value !== 'mjpeg') {
if (newState === 'connected') { if (newState === 'connected') {
systemStore.setStreamOnline(true) systemStore.setStreamOnline(true)
@@ -1467,6 +1770,10 @@ watch(() => webrtc.state.value, (newState, oldState) => {
} }
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') { if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
videoDebugLog('Scheduling WebRTC auto reconnect after disconnect', {
failures: webrtcReconnectFailures,
sessionId: webrtc.sessionId.value,
})
webrtcReconnectTimeout = setTimeout(async () => { webrtcReconnectTimeout = setTimeout(async () => {
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') { if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
try { try {
@@ -2112,7 +2419,6 @@ async function activateConsoleView() {
isConsoleActive.value = true isConsoleActive.value = true
registerInteractionListeners() registerInteractionListeners()
// REST snapshot: returning from Settings (or other routes) may have missed WS device_info
void systemStore.fetchAllStates() void systemStore.fetchAllStates()
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {}) void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
@@ -2405,14 +2711,8 @@ onUnmounted(() => {
<div <div
ref="videoContainerRef" ref="videoContainerRef"
class="relative bg-black overflow-hidden flex items-center justify-center" class="relative bg-black overflow-hidden flex items-center justify-center"
:style="{ :style="videoContainerStyle"
aspectRatio: videoAspectRatio ?? '16/9',
maxWidth: '100%',
maxHeight: '100%',
minHeight: '120px',
}"
:class="{ :class="{
'opacity-60': videoLoading || videoError,
'cursor-none': true, 'cursor-none': true,
}" }"
tabindex="0" tabindex="0"
@@ -2506,9 +2806,36 @@ onUnmounted(() => {
</div> </div>
<Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" /> <Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" />
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4"> <p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4 flex items-baseline justify-center gap-2 flex-wrap">
{{ webrtcLoadingMessage }} <span>{{ webrtcLoadingMessage }}</span>
<span
v-if="connectProgress"
class="text-white/55 text-xs sm:text-sm font-normal tabular-nums"
:aria-label="t('console.stepProgress', { current: connectProgress.current, total: connectProgress.total })"
>
{{ connectProgress.current }}/{{ connectProgress.total }}
</span>
</p> </p>
<div
v-if="connectProgress"
class="mt-2 sm:mt-3 flex items-center gap-1"
role="progressbar"
:aria-valuenow="connectProgress.current"
:aria-valuemin="0"
:aria-valuemax="connectProgress.total"
:aria-valuetext="t('console.stepProgress', { current: connectProgress.current, total: connectProgress.total })"
>
<span
v-for="i in connectProgress.total"
:key="i"
:class="cn(
'h-1 w-4 sm:w-6 rounded-full transition-colors duration-300',
i <= connectProgress.current
? 'bg-primary'
: 'bg-white/15',
)"
/>
</div>
<p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2"> <p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2">
{{ t('console.pleaseWait') }} {{ t('console.pleaseWait') }}
</p> </p>

View File

@@ -44,7 +44,7 @@ import { getVideoFormatState } from '@/lib/video-format-support'
import AppLayout from '@/components/AppLayout.vue' import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue' import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
@@ -99,6 +99,7 @@ import {
Radio, Radio,
Globe, Globe,
Loader2, Loader2,
AlertTriangle,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const { t, te } = useI18n() const { t, te } = useI18n()
@@ -163,6 +164,33 @@ const navGroups = computed(() => [
} }
]) ])
const sectionMeta = computed(() => {
const fallback = { icon: Info, title: t('settings.title'), description: '' }
for (const group of navGroups.value) {
for (const item of group.items) {
if (item.id === activeSection.value) {
const subtitleKey = `settings.${sectionSubtitleKey(item.id)}`
return {
icon: item.icon,
title: item.label,
description: te(subtitleKey) ? t(subtitleKey) : '',
}
}
}
}
return fallback
})
function sectionSubtitleKey(id: string): string {
switch (id) {
case 'ext-ttyd': return 'extTtydSubtitle'
case 'ext-rustdesk': return 'extRustdeskSubtitle'
case 'ext-rtsp': return 'extRtspSubtitle'
case 'ext-remote-access': return 'extRemoteAccessSubtitle'
default: return `${id}Subtitle`
}
}
function selectSection(id: string) { function selectSection(id: string) {
activeSection.value = id activeSection.value = id
mobileMenuOpen.value = false mobileMenuOpen.value = false
@@ -327,6 +355,23 @@ const previewAccessUrl = computed(() => {
return `${scheme}://${host}:${port}` return `${scheme}://${host}:${port}`
}) })
const previewUrlCopied = ref(false)
let previewUrlCopiedTimer: ReturnType<typeof setTimeout> | null = null
async function copyPreviewUrl() {
const ok = await clipboardCopy(previewAccessUrl.value)
if (!ok) return
previewUrlCopied.value = true
if (previewUrlCopiedTimer) clearTimeout(previewUrlCopiedTimer)
previewUrlCopiedTimer = setTimeout(() => {
previewUrlCopied.value = false
}, 1500)
}
function openPreviewUrl() {
window.open(previewAccessUrl.value, '_blank', 'noopener,noreferrer')
}
interface DeviceConfig { interface DeviceConfig {
video: Array<{ video: Array<{
path: string path: string
@@ -792,14 +837,14 @@ const atxConfig = ref({
power: { power: {
driver: 'none' as AtxDriverType, driver: 'none' as AtxDriverType,
device: '', device: '',
pin: 0, pin: 1,
active_level: 'high' as ActiveLevel, active_level: 'high' as ActiveLevel,
baud_rate: 9600, baud_rate: 9600,
}, },
reset: { reset: {
driver: 'none' as AtxDriverType, driver: 'none' as AtxDriverType,
device: '', device: '',
pin: 0, pin: 1,
active_level: 'high' as ActiveLevel, active_level: 'high' as ActiveLevel,
baud_rate: 9600, baud_rate: 9600,
}, },
@@ -1038,9 +1083,18 @@ async function saveConfig() {
saved.value = false saved.value = false
try { try {
// Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH
if (activeSection.value === 'video') { if (activeSection.value === 'video') {
const turnUrl = config.value.turn_server.trim()
await configStore.updateStream({
encoder: config.value.encoder_backend as any,
stun_server: config.value.stun_server.trim(),
turn_server: turnUrl,
turn_username: config.value.turn_username.trim(),
turn_password:
turnUrl === ''
? ''
: config.value.turn_password || undefined,
})
await configStore.updateVideo({ await configStore.updateVideo({
device: config.value.video_device || undefined, device: config.value.video_device || undefined,
format: config.value.video_format || undefined, format: config.value.video_format || undefined,
@@ -1048,16 +1102,8 @@ async function saveConfig() {
height: config.value.video_height, height: config.value.video_height,
fps: toConfigFps(config.value.video_fps), fps: toConfigFps(config.value.video_fps),
}) })
await configStore.updateStream({
encoder: config.value.encoder_backend as any,
stun_server: config.value.stun_server || undefined,
turn_server: config.value.turn_server || undefined,
turn_username: config.value.turn_username || undefined,
turn_password: config.value.turn_password || undefined,
})
} }
// HID config (includes MSD enable — same gadget; must not race with updateHid)
if (activeSection.value === 'hid') { if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) { if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
return return
@@ -1327,6 +1373,7 @@ async function loadAtxConfig() {
wol_interface: config.wol_interface || '', wol_interface: config.wol_interface || '',
} }
clearAtxSerialDeviceConflicts() clearAtxSerialDeviceConflicts()
normalizeAtxRelayChannels()
syncSharedAtxSerialBaudRate() syncSharedAtxSerialBaudRate()
} catch (e) { } catch (e) {
console.error('Failed to load ATX config:', e) console.error('Failed to load ATX config:', e)
@@ -1345,6 +1392,7 @@ async function saveAtxConfig() {
loading.value = true loading.value = true
saved.value = false saved.value = false
try { try {
normalizeAtxRelayChannels()
syncSharedAtxSerialBaudRate() syncSharedAtxSerialBaudRate()
await configStore.updateAtx({ await configStore.updateAtx({
enabled: atxConfig.value.enabled, enabled: atxConfig.value.enabled,
@@ -1421,6 +1469,14 @@ function syncSharedAtxSerialBaudRate() {
atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate atxConfig.value.reset.baud_rate = atxConfig.value.power.baud_rate
} }
function normalizeAtxRelayChannels() {
for (const key of [atxConfig.value.power, atxConfig.value.reset]) {
if (['usbrelay', 'serial'].includes(key.driver) && key.pin < 1) {
key.pin = 1
}
}
}
watch( watch(
() => [config.value.hid_backend, config.value.hid_serial_device], () => [config.value.hid_backend, config.value.hid_serial_device],
() => { () => {
@@ -1428,6 +1484,13 @@ watch(
}, },
) )
watch(
() => [atxConfig.value.power.driver, atxConfig.value.reset.driver],
() => {
normalizeAtxRelayChannels()
},
)
watch( watch(
() => [ () => [
atxConfig.value.power.driver, atxConfig.value.power.driver,
@@ -2056,7 +2119,7 @@ watch(() => route.query.tab, (tab) => {
<AppLayout> <AppLayout>
<div class="flex h-full overflow-hidden"> <div class="flex h-full overflow-hidden">
<!-- Mobile Header --> <!-- Mobile Header -->
<div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background"> <div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<Sheet v-model:open="mobileMenuOpen"> <Sheet v-model:open="mobileMenuOpen">
<SheetTrigger as-child> <SheetTrigger as-child>
<Button variant="ghost" size="icon" class="mr-1.5 sm:mr-2 h-8 w-8 sm:h-9 sm:w-9"> <Button variant="ghost" size="icon" class="mr-1.5 sm:mr-2 h-8 w-8 sm:h-9 sm:w-9">
@@ -2091,16 +2154,22 @@ watch(() => route.query.tab, (tab) => {
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<h1 class="text-base sm:text-lg font-semibold">{{ t('settings.title') }}</h1> <div class="flex items-center gap-2 min-w-0">
<component :is="sectionMeta.icon" class="h-4 w-4 text-muted-foreground shrink-0" />
<h1 class="text-sm sm:text-base font-semibold truncate">{{ sectionMeta.title }}</h1>
</div>
</div> </div>
<!-- Desktop Sidebar --> <!-- Desktop Sidebar -->
<aside class="hidden lg:block w-64 shrink-0 border-r bg-muted/30"> <aside class="hidden lg:block w-64 shrink-0 border-r bg-muted/30">
<div class="sticky top-0 p-6 space-y-6"> <div class="sticky top-0 p-6 space-y-6 max-h-screen overflow-y-auto">
<h1 class="text-xl font-semibold">{{ t('settings.title') }}</h1> <div class="space-y-1">
<nav class="space-y-6"> <h1 class="text-xl font-semibold tracking-tight">{{ t('settings.title') }}</h1>
<p class="text-xs text-muted-foreground">{{ t('settings.sidebarSubtitle') }}</p>
</div>
<nav class="space-y-5">
<div v-for="group in navGroups" :key="group.title" class="space-y-1"> <div v-for="group in navGroups" :key="group.title" class="space-y-1">
<h3 class="px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">{{ group.title }}</h3> <h3 class="px-3 text-[11px] font-semibold text-muted-foreground/80 uppercase tracking-wider mb-1.5">{{ group.title }}</h3>
<button <button
type="button" type="button"
v-for="item in group.items" v-for="item in group.items"
@@ -2109,13 +2178,13 @@ watch(() => route.query.tab, (tab) => {
:class="[ :class="[
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors', 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors',
activeSection === item.id activeSection === item.id
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground shadow-sm'
: 'hover:bg-muted' : 'text-foreground/80 hover:text-foreground hover:bg-muted'
]" ]"
> >
<component :is="item.icon" class="h-4 w-4" /> <component :is="item.icon" class="h-4 w-4 shrink-0" />
<span>{{ item.label }}</span> <span class="truncate">{{ item.label }}</span>
<Badge v-if="item.status" variant="outline" :class="['ml-auto text-xs', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge> <Badge v-if="item.status" variant="outline" :class="['ml-auto text-[10px] px-1.5 py-0 h-4', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge>
</button> </button>
</div> </div>
</nav> </nav>
@@ -2124,7 +2193,18 @@ watch(() => route.query.tab, (tab) => {
<!-- Main Content --> <!-- Main Content -->
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="max-w-2xl mx-auto p-3 sm:p-6 lg:p-8 pt-16 sm:pt-20 lg:pt-8 space-y-4 sm:space-y-6"> <div class="mx-auto w-full max-w-3xl px-3 sm:px-6 lg:px-8 pt-16 sm:pt-20 lg:pt-10 pb-10 space-y-6">
<!-- Section Header -->
<header class="space-y-1.5 pb-2 border-b">
<div class="flex items-center gap-2.5">
<component :is="sectionMeta.icon" class="h-5 w-5 text-muted-foreground" />
<h1 class="text-xl sm:text-2xl font-semibold tracking-tight">{{ sectionMeta.title }}</h1>
</div>
<p v-if="sectionMeta.description" class="text-sm text-muted-foreground">
{{ sectionMeta.description }}
</p>
</header>
<!-- Appearance Section --> <!-- Appearance Section -->
<div v-show="activeSection === 'appearance'" class="space-y-6"> <div v-show="activeSection === 'appearance'" class="space-y-6">
@@ -2134,14 +2214,14 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription> <CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="flex flex-wrap gap-2"> <div class="grid grid-cols-3 gap-2 sm:max-w-md">
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')"> <Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" class="justify-center" @click="setTheme('light')">
<Sun class="h-4 w-4 mr-1.5" />{{ t('settings.lightMode') }} <Sun class="h-4 w-4 mr-1.5" />{{ t('settings.lightMode') }}
</Button> </Button>
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')"> <Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" class="justify-center" @click="setTheme('dark')">
<Moon class="h-4 w-4 mr-1.5" />{{ t('settings.darkMode') }} <Moon class="h-4 w-4 mr-1.5" />{{ t('settings.darkMode') }}
</Button> </Button>
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')"> <Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" class="justify-center" @click="setTheme('system')">
<Monitor class="h-4 w-4 mr-1.5" />{{ t('settings.systemMode') }} <Monitor class="h-4 w-4 mr-1.5" />{{ t('settings.systemMode') }}
</Button> </Button>
</div> </div>
@@ -2154,9 +2234,7 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.languageDesc') }}</CardDescription> <CardDescription>{{ t('settings.languageDesc') }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div class="flex">
<LanguageToggleButton variant="outline" size="sm" label-mode="current" /> <LanguageToggleButton variant="outline" size="sm" label-mode="current" />
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -2171,21 +2249,22 @@ watch(() => route.query.tab, (tab) => {
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="account-username">{{ t('settings.username') }}</Label> <Label for="account-username">{{ t('settings.username') }}</Label>
<Input id="account-username" v-model="usernameInput" /> <Input id="account-username" v-model="usernameInput" autocomplete="username" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="account-username-password">{{ t('settings.currentPassword') }}</Label> <Label for="account-username-password">{{ t('settings.currentPassword') }}</Label>
<Input id="account-username-password" v-model="usernamePassword" type="password" /> <Input id="account-username-password" v-model="usernamePassword" type="password" autocomplete="current-password" />
</div> </div>
<p v-if="usernameError" class="text-xs text-destructive">{{ usernameError }}</p> <p v-if="usernameError" class="text-xs text-destructive">{{ usernameError }}</p>
<p v-else-if="usernameSaved" class="text-xs text-emerald-600">{{ t('common.success') }}</p> <p v-else-if="usernameSaved" class="text-xs text-emerald-600 flex items-center gap-1.5"><Check class="h-3.5 w-3.5" />{{ t('common.success') }}</p>
<div class="flex justify-end"> </CardContent>
<CardFooter class="border-t pt-4 justify-end">
<Button @click="changeUsername" :disabled="usernameSaving"> <Button @click="changeUsername" :disabled="usernameSaving">
<Save class="h-4 w-4 mr-2" /> <Loader2 v-if="usernameSaving" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ t('common.save') }} {{ t('common.save') }}
</Button> </Button>
</div> </CardFooter>
</CardContent>
</Card> </Card>
<Card> <Card>
@@ -2196,25 +2275,26 @@ watch(() => route.query.tab, (tab) => {
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="account-current-password">{{ t('settings.currentPassword') }}</Label> <Label for="account-current-password">{{ t('settings.currentPassword') }}</Label>
<Input id="account-current-password" v-model="currentPassword" type="password" /> <Input id="account-current-password" v-model="currentPassword" type="password" autocomplete="current-password" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="account-new-password">{{ t('settings.newPassword') }}</Label> <Label for="account-new-password">{{ t('settings.newPassword') }}</Label>
<Input id="account-new-password" v-model="newPassword" type="password" /> <Input id="account-new-password" v-model="newPassword" type="password" autocomplete="new-password" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="account-confirm-password">{{ t('auth.confirmPassword') }}</Label> <Label for="account-confirm-password">{{ t('auth.confirmPassword') }}</Label>
<Input id="account-confirm-password" v-model="confirmPassword" type="password" /> <Input id="account-confirm-password" v-model="confirmPassword" type="password" autocomplete="new-password" />
</div> </div>
<p v-if="passwordError" class="text-xs text-destructive">{{ passwordError }}</p> <p v-if="passwordError" class="text-xs text-destructive">{{ passwordError }}</p>
<p v-else-if="passwordSaved" class="text-xs text-emerald-600">{{ t('common.success') }}</p> <p v-else-if="passwordSaved" class="text-xs text-emerald-600 flex items-center gap-1.5"><Check class="h-3.5 w-3.5" />{{ t('common.success') }}</p>
<div class="flex justify-end"> </CardContent>
<CardFooter class="border-t pt-4 justify-end">
<Button @click="changePassword" :disabled="passwordSaving"> <Button @click="changePassword" :disabled="passwordSaving">
<Save class="h-4 w-4 mr-2" /> <Loader2 v-if="passwordSaving" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ t('common.save') }} {{ t('common.save') }}
</Button> </Button>
</div> </CardFooter>
</CardContent>
</Card> </Card>
<Card> <Card>
@@ -2223,7 +2303,7 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription> <CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-start justify-between gap-4">
<div class="space-y-0.5"> <div class="space-y-0.5">
<Label>{{ t('settings.allowMultipleSessions') }}</Label> <Label>{{ t('settings.allowMultipleSessions') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p> <p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
@@ -2233,13 +2313,14 @@ watch(() => route.query.tab, (tab) => {
:disabled="authConfigLoading" :disabled="authConfigLoading"
/> />
</div> </div>
<div class="flex justify-end pt-2"> </CardContent>
<CardFooter class="border-t pt-4 justify-end">
<Button @click="saveAuthConfig" :disabled="authConfigLoading"> <Button @click="saveAuthConfig" :disabled="authConfigLoading">
<Save class="h-4 w-4 mr-2" /> <Loader2 v-if="authConfigLoading" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ t('common.save') }} {{ t('common.save') }}
</Button> </Button>
</div> </CardFooter>
</CardContent>
</Card> </Card>
</div> </div>
@@ -2903,24 +2984,27 @@ watch(() => route.query.tab, (tab) => {
<CardTitle>{{ t('settings.portConfig') }}</CardTitle> <CardTitle>{{ t('settings.portConfig') }}</CardTitle>
<CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription> <CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-5">
<!-- HTTPS toggle --> <!-- HTTPS toggle -->
<div class="flex items-center justify-between"> <div class="flex items-start justify-between gap-4">
<div class="space-y-0.5"> <div class="space-y-0.5">
<Label>{{ t('settings.httpsEnabled') }}</Label> <Label>{{ t('settings.httpsEnabled') }}</Label>
<p class="text-sm text-muted-foreground">{{ t('settings.httpsEnabledDesc') }}</p> <p class="text-xs text-muted-foreground">{{ t('settings.httpsEnabledDesc') }}</p>
</div> </div>
<Switch v-model="webServerConfig.https_enabled" /> <Switch v-model="webServerConfig.https_enabled" />
</div> </div>
<Separator /> <Separator />
<!-- Single active-port input, label follows the HTTPS toggle --> <!-- Active port (primary) -->
<div class="flex items-end gap-3"> <div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2 flex-1 max-w-[180px]"> <div class="space-y-2">
<Label> <div class="flex items-center gap-2">
<Label class="text-sm font-medium">
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }} {{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
</Label> </Label>
<Badge variant="default" class="h-4 text-[10px] px-1.5">{{ t('settings.portActive') }}</Badge>
</div>
<Input <Input
v-if="webServerConfig.https_enabled" v-if="webServerConfig.https_enabled"
v-model.number="webServerConfig.https_port" v-model.number="webServerConfig.https_port"
@@ -2932,42 +3016,65 @@ watch(() => route.query.tab, (tab) => {
type="number" min="1" max="65535" type="number" min="1" max="65535"
/> />
</div> </div>
<!-- Inactive-port reference (read-only hint) --> <div class="space-y-2">
<div class="space-y-2 flex-1 max-w-[180px]"> <div class="flex items-center gap-2">
<Label class="text-muted-foreground text-xs"> <Label class="text-sm text-muted-foreground">
{{ webServerConfig.https_enabled ? t('settings.httpPortReserved') : t('settings.httpsPortReserved') }} {{ webServerConfig.https_enabled ? t('settings.httpPort') : t('settings.httpsPort') }}
</Label> </Label>
<Badge variant="secondary" class="h-4 text-[10px] px-1.5 font-normal">{{ t('settings.portReserved') }}</Badge>
</div>
<Input <Input
v-if="webServerConfig.https_enabled" v-if="webServerConfig.https_enabled"
v-model.number="webServerConfig.http_port" v-model.number="webServerConfig.http_port"
type="number" min="1" max="65535" type="number" min="1" max="65535"
class="opacity-50" class="opacity-60"
/> />
<Input <Input
v-else v-else
v-model.number="webServerConfig.https_port" v-model.number="webServerConfig.https_port"
type="number" min="1" max="65535" type="number" min="1" max="65535"
class="opacity-50" class="opacity-60"
/> />
</div> </div>
</div> </div>
<p class="text-xs text-muted-foreground -mt-2">{{ t('settings.portReservedHint') }}</p>
<!-- Preview URL --> <!-- Preview URL -->
<div class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm"> <div class="rounded-md border bg-muted/40 p-3 space-y-1.5">
<span class="text-muted-foreground shrink-0">{{ t('settings.previewUrl') }}:</span> <p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.previewUrl') }}</p>
<span class="font-mono text-xs break-all">{{ previewAccessUrl }}</span> <div class="flex items-center gap-2">
<code class="font-mono text-xs sm:text-sm break-all flex-1 min-w-0">{{ previewAccessUrl }}</code>
<Button
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
:title="t('settings.copyUrl')"
:aria-label="t('settings.copyUrl')"
@click="copyPreviewUrl"
>
<Check v-if="previewUrlCopied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" class="h-7 w-7 shrink-0"
:title="t('settings.openInBrowser')"
:aria-label="t('settings.openInBrowser')"
@click="openPreviewUrl"
>
<ExternalLink class="h-3.5 w-3.5" />
</Button>
</div> </div>
</div>
<!-- Save row --> </CardContent>
<div class="flex items-center justify-between pt-2"> <CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p> <p class="text-xs text-muted-foreground flex items-center gap-1.5">
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
{{ t('settings.restartRequiredHint') }}
</p>
<Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting"> <Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" /> <RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" /> <Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }} {{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button> </Button>
</div> </CardFooter>
</CardContent>
</Card> </Card>
<!-- Listen Address Card --> <!-- Listen Address Card -->
@@ -3038,20 +3145,22 @@ watch(() => route.query.tab, (tab) => {
</RadioGroup> </RadioGroup>
<!-- Effective addresses preview --> <!-- Effective addresses preview -->
<div v-if="effectiveBindAddresses.length > 0" class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm"> <div v-if="effectiveBindAddresses.length > 0" class="rounded-md border bg-muted/40 p-3 space-y-1.5">
<span class="text-muted-foreground shrink-0">{{ t('settings.effectiveAddresses') }}:</span> <p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.effectiveAddresses') }}</p>
<span class="font-mono text-xs break-all">{{ effectiveBindAddresses.join(', ') }}</span> <code class="font-mono text-xs sm:text-sm break-all block">{{ effectiveBindAddresses.join(', ') }}</code>
</div> </div>
</CardContent>
<div class="flex items-center justify-between pt-2"> <CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p> <p class="text-xs text-muted-foreground flex items-center gap-1.5">
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
{{ t('settings.restartRequiredHint') }}
</p>
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting"> <Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" /> <RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" /> <Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }} {{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button> </Button>
</div> </CardFooter>
</CardContent>
</Card> </Card>
<!-- SSL Certificate Card --> <!-- SSL Certificate Card -->
@@ -3112,8 +3221,12 @@ watch(() => route.query.tab, (tab) => {
/> />
</div> </div>
<div class="flex items-center justify-between pt-1"> </CardContent>
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p> <CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
{{ t('settings.restartRequiredHint') }}
</p>
<Button <Button
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()" :disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
@click="saveCertificate" @click="saveCertificate"
@@ -3122,8 +3235,7 @@ watch(() => route.query.tab, (tab) => {
<Save v-else class="h-4 w-4 mr-2" /> <Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }} {{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
</Button> </Button>
</div> </CardFooter>
</CardContent>
</Card> </Card>
</div> </div>
@@ -3229,7 +3341,7 @@ watch(() => route.query.tab, (tab) => {
id="power-pin" id="power-pin"
type="number" type="number"
v-model.number="atxConfig.power.pin" v-model.number="atxConfig.power.pin"
:min="atxConfig.power.driver === 'serial' ? 1 : 0" :min="['usbrelay', 'serial'].includes(atxConfig.power.driver) ? 1 : 0"
:disabled="atxConfig.power.driver === 'none'" :disabled="atxConfig.power.driver === 'none'"
/> />
</div> </div>
@@ -3293,7 +3405,7 @@ watch(() => route.query.tab, (tab) => {
id="reset-pin" id="reset-pin"
type="number" type="number"
v-model.number="atxConfig.reset.pin" v-model.number="atxConfig.reset.pin"
:min="atxConfig.reset.driver === 'serial' ? 1 : 0" :min="['usbrelay', 'serial'].includes(atxConfig.reset.driver) ? 1 : 0"
:disabled="atxConfig.reset.driver === 'none'" :disabled="atxConfig.reset.driver === 'none'"
/> />
</div> </div>
@@ -4109,12 +4221,14 @@ watch(() => route.query.tab, (tab) => {
</div> </div>
<!-- Save Button (sticky) --> <!-- Save Button (sticky) -->
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-2 bg-background border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> <div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div class="flex items-center justify-between sm:justify-end gap-2 sm:gap-3"> <div class="flex items-center justify-between gap-2 sm:gap-3">
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0"> <p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 min-w-0">
{{ t('settings.otgFunctionMinWarning') }} <AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ t('settings.otgFunctionMinWarning') }}</span>
</p> </p>
<Button class="shrink-0" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig"> <p v-else class="text-xs text-muted-foreground hidden sm:block">{{ t('settings.unsavedChangesHint') }}</p>
<Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }} <Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
</Button> </Button>
</div> </div>