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 std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -18,6 +19,10 @@ use crate::error::{AppError, Result};
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
pub mod timing {
use std::time::Duration;
@@ -129,12 +134,23 @@ impl AtxKeyExecutor {
}
}
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 {
return Err(AppError::Config(format!(
"USB relay channel must be <= {}",
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 => {}
}
@@ -292,26 +308,64 @@ impl AtxKeyExecutor {
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 = if on {
[0x00, channel + 1, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00]
} else {
[0x00, channel + 1, 0xFD, 0x00, 0x00, 0x00, 0x00, 0x00]
};
let cmd = Self::build_usb_relay_command(channel, on);
let mut guard = self.usb_relay_handle.lock().unwrap();
let device = guard
.as_mut()
.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
.write_all(&cmd)
.map_err(|e| AppError::Internal(format!("USB relay write failed: {}", e)))?;
.flush()
.map_err(|e| AppError::Internal(format!("USB relay flush failed: {}", e)))?;
}
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
async fn pulse_serial(&self, duration: Duration) -> Result<()> {
info!(
@@ -367,6 +421,8 @@ impl AtxKeyExecutor {
port.write_all(&cmd)
.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(())
}
@@ -453,7 +509,7 @@ mod tests {
let config = AtxKeyConfig {
driver: AtxDriverType::UsbRelay,
device: "/dev/hidraw0".to_string(),
pin: 0,
pin: 1,
active_level: ActiveLevel::High, // Ignored for USB relay
baud_rate: 9600,
};
@@ -481,6 +537,18 @@ mod tests {
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]
async fn test_executor_init_rejects_serial_channel_zero() {
let config = AtxKeyConfig {
@@ -495,6 +563,34 @@ mod tests {
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]
async fn test_executor_init_rejects_serial_channel_overflow() {
let config = AtxKeyConfig {

View File

@@ -15,6 +15,7 @@
//!
//! - **GPIO**: Uses Linux GPIO character device (/dev/gpiochipX) for direct hardware control
//! - **USB Relay**: Uses HID USB relay modules for isolated switching
//! - **Serial Relay**: Uses LCUS-style serial relay modules
//!
//! # Example
//!
@@ -59,9 +60,25 @@ pub use types::{
};
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
///
/// 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 {
let mut devices = AtxDevices::default();
@@ -72,7 +89,7 @@ pub fn discover_devices() -> AtxDevices {
let name_str = name.to_string_lossy();
if name_str.starts_with("gpiochip") {
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));
} else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") {
devices.serial_ports.push(format!("/dev/{}", name_str));
@@ -96,6 +113,20 @@ mod tests {
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]
fn test_module_exports() {
// Verify all public exports are accessible

View File

@@ -61,7 +61,7 @@ pub struct AtxKeyConfig {
pub device: String,
/// Pin or channel 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)
pub pin: u32,
/// 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::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
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);
@@ -165,6 +165,7 @@ impl AudioController {
) {
if let Some(ref bus) = *event_bus.read().await {
bus.publish(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Audio,
device: device.to_string(),
reason: reason.to_string(),
});

View File

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

View File

@@ -79,6 +79,14 @@ pub struct ClientStats {
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": { ... }}`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "event", content = "data")]
@@ -119,7 +127,11 @@ pub enum SystemEvent {
},
#[serde(rename = "stream.device_lost")]
StreamDeviceLost { device: String, reason: String },
StreamDeviceLost {
kind: StreamDeviceLostKind,
device: String,
reason: String,
},
#[serde(rename = "stream.reconnecting")]
StreamReconnecting { device: String, attempt: u32 },
@@ -255,6 +267,19 @@ mod tests {
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]
fn exact_topics_covers_all_variants() {
use std::collections::HashSet;
@@ -283,6 +308,7 @@ mod tests {
fps: 0,
},
SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
device: String::new(),
reason: String::new(),
},

View File

@@ -8,6 +8,9 @@ use std::time::{Duration, Instant};
use tokio::sync::broadcast;
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::JpegEncoder;
use crate::video::format::PixelFormat;
@@ -18,6 +21,7 @@ pub type ClientId = String;
#[derive(Debug, Clone)]
pub struct ClientSession {
pub id: ClientId,
pub generation: ClientGeneration,
pub connected_at: Instant,
pub last_activity: Instant,
pub frames_sent: u64,
@@ -25,10 +29,11 @@ pub struct ClientSession {
}
impl ClientSession {
pub fn new(id: ClientId) -> Self {
pub fn new(id: ClientId, generation: ClientGeneration) -> Self {
let now = Instant::now();
Self {
id,
generation,
connected_at: now,
last_activity: now,
frames_sent: 0,
@@ -45,7 +50,6 @@ impl ClientSession {
pub struct FpsCalculator {
frame_times: VecDeque<Instant>,
window: Duration,
count_in_window: usize,
}
impl FpsCalculator {
@@ -53,29 +57,27 @@ impl FpsCalculator {
Self {
frame_times: VecDeque::with_capacity(120),
window: Duration::from_secs(1),
count_in_window: 0,
}
}
pub fn record_frame(&mut self) {
let now = Instant::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;
while let Some(&oldest) = self.frame_times.front() {
if oldest < cutoff {
while matches!(self.frame_times.front(), Some(&t) if t < cutoff) {
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)]
@@ -101,6 +103,7 @@ pub struct MjpegStreamHandler {
online: AtomicBool,
sequence: AtomicU64,
clients: ParkingRwLock<HashMap<ClientId, ClientSession>>,
next_generation: AtomicU64,
auto_pause_config: ParkingRwLock<AutoPauseConfig>,
last_frame_ts: ParkingRwLock<Option<Instant>>,
dropped_same_frames: AtomicU64,
@@ -122,6 +125,7 @@ impl MjpegStreamHandler {
online: AtomicBool::new(false),
sequence: AtomicU64::new(0),
clients: ParkingRwLock::new(HashMap::new()),
next_generation: AtomicU64::new(1),
jpeg_encoder: ParkingMutex::new(None),
auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()),
last_frame_ts: ParkingRwLock::new(None),
@@ -292,18 +296,26 @@ impl MjpegStreamHandler {
self.clients.read().len() as u64
}
pub fn register_client(&self, client_id: ClientId) {
let session = ClientSession::new(client_id.clone());
/// Connects `client_id`; return value must be passed to [`unregister_client`].
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);
info!(
"Client {} connected (total: {})",
client_id,
self.client_count()
);
generation
}
pub fn unregister_client(&self, client_id: &str) {
if let Some(session) = self.clients.write().remove(client_id) {
pub fn unregister_client(&self, client_id: &str, expected_generation: ClientGeneration) {
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_secs = duration.as_secs_f32();
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> {
// 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
.read()
.iter()
.write()
.iter_mut()
.map(|(id, session)| {
(
id.clone(),
@@ -379,13 +394,18 @@ impl MjpegStreamHandler {
pub struct ClientGuard {
client_id: ClientId,
generation: ClientGeneration,
handler: Arc<MjpegStreamHandler>,
}
impl ClientGuard {
pub fn new(client_id: ClientId, handler: Arc<MjpegStreamHandler>) -> Self {
handler.register_client(client_id.clone());
Self { client_id, handler }
let generation = handler.register_client(client_id.clone());
Self {
client_id,
generation,
handler,
}
}
pub fn id(&self) -> &ClientId {
@@ -395,7 +415,8 @@ impl ClientGuard {
impl Drop for ClientGuard {
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();
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::is_csi_hdmi_bridge;
use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
use crate::stream::MjpegStreamHandler;
use crate::utils::LogThrottler;
use crate::video::capture_limits::{should_validate_jpeg_frame, MIN_CAPTURE_FRAME_SIZE};
@@ -1417,6 +1417,7 @@ impl Streamer {
// Publish device lost event
self.publish_event(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
device: device.clone(),
reason: reason.clone(),
})

View File

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

View File

@@ -509,6 +509,12 @@ impl AtxConfigUpdate {
}
crate::atx::AtxDriverType::UsbRelay => {
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 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}",
@@ -516,6 +522,12 @@ impl AtxConfigUpdate {
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 => {}
@@ -551,6 +563,12 @@ impl AtxConfigUpdate {
}
}
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 {
return Err(AppError::BadRequest(format!(
"{} USB relay channel must be <= {}",
@@ -558,6 +576,12 @@ impl AtxConfigUpdate {
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 => {}
}

View File

@@ -1493,12 +1493,8 @@ pub async fn mjpeg_stream(
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);
// Spawn background task to send frames to channel
let guard_clone = guard.clone();
let handler_clone = handler.clone();
tokio::spawn(async move {
@@ -1593,20 +1589,19 @@ pub async fn mjpeg_stream(
}
}
// Guard is automatically dropped here
});
// Create stream that receives from channel
// Record FPS after yield - this is closer to actual TCP send than tx.send()
// Create stream that receives from channel and forwards to the HTTP
// 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 guard_for_stream = guard.clone();
let body_stream = async_stream::stream! {
// Consume from channel - this drives the backpressure
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());
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::ice::mdns::MulticastDnsMode;
use webrtc::ice_transport::ice_candidate::RTCIceCandidate;
use webrtc::ice_transport::ice_connection_state::RTCIceConnectionState;
use webrtc::ice_transport::ice_server::RTCIceServer;
use webrtc::interceptor::registry::Registry;
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 crate::audio::OpusFrame;
use crate::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::hid::datachannel::{parse_hid_message, HidChannelEvent};
use crate::hid::HidController;
use crate::video::types::{
BitratePreset, EncodedVideoFrame, PixelFormat, Resolution, VideoEncoderType,
};
use std::sync::atomic::AtomicBool;
use webrtc::ice_transport::ice_gatherer_state::RTCIceGathererState;
const MIME_TYPE_H265: &str = "video/H265";
@@ -140,7 +139,6 @@ pub struct UniversalSession {
state_rx: watch::Receiver<ConnectionState>,
ice_candidates: Arc<Mutex<Vec<IceCandidate>>>,
hid_controller: Option<Arc<HidController>>,
event_bus: Option<Arc<EventBus>>,
video_receiver_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
audio_receiver_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
fps: u32,
@@ -150,7 +148,7 @@ impl UniversalSession {
pub async fn new(
config: UniversalSessionConfig,
session_id: String,
event_bus: Option<Arc<EventBus>>,
_event_bus: Option<Arc<crate::events::EventBus>>,
) -> Result<Self> {
info!(
"Creating {} session: {} @ {}x{} (audio={})",
@@ -338,7 +336,6 @@ impl UniversalSession {
state_rx,
ice_candidates: Arc::new(Mutex::new(vec![])),
hid_controller: None,
event_bus,
video_receiver_handle: Mutex::new(None),
audio_receiver_handle: Mutex::new(None),
fps: config.fps,
@@ -353,8 +350,6 @@ impl UniversalSession {
let state = self.state.clone();
let session_id = self.session_id.clone();
let codec = self.codec;
let event_bus = self.event_bus.clone();
self.pc
.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
let state = state.clone();
@@ -372,42 +367,49 @@ impl UniversalSession {
};
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_for_ice = self.state.clone();
let session_id_ice = self.session_id.clone();
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();
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 event_bus_gather = event_bus.clone();
self.pc
.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 });
}
let new_state = match ice_state {
RTCIceConnectionState::Connected | RTCIceConnectionState::Completed => {
ConnectionState::Connected
}
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 session_id_candidate = self.session_id.clone();
let event_bus_candidate = event_bus.clone();
self.pc
.on_ice_candidate(Box::new(move |candidate: Option<RTCIceCandidate>| {
let ice_candidates = ice_candidates.clone();
let session_id = session_id_candidate.clone();
let event_bus = event_bus_candidate.clone();
Box::pin(async move {
if let Some(c) = candidate {
@@ -430,14 +432,6 @@ impl UniversalSession {
let mut candidates = ice_candidates.lock().await;
candidates.push(candidate.clone());
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;
let _ = send_in_flight;
if send_result.is_ok() {
match send_result {
Ok(()) => {
frames_sent += 1;
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
.set_local_description(answer.clone())
.await
.map_err(|e| AppError::VideoError(format!("Failed to set local description: {}", e)))?;
const ICE_GATHER_TIMEOUT: Duration = Duration::from_millis(2500);
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
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
let candidates = self.ice_candidates.lock().await.clone();
Ok(SdpAnswer::with_candidates(answer.sdp, candidates))
}
@@ -842,10 +837,16 @@ impl UniversalSession {
username_fragment: candidate.username_fragment,
};
self.pc
.add_ice_candidate(init)
.await
.map_err(|e| AppError::VideoError(format!("Failed to add ICE candidate: {}", e)))?;
if let Err(e) = self.pc.add_ice_candidate(init).await {
warn!(
"[ICE] Session {} failed to add remote candidate: {}",
self.session_id, e
);
return Err(AppError::VideoError(format!(
"Failed to add ICE candidate: {}",
e
)));
}
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`).
use super::h265_payloader::H265Payloader;
use crate::error::Result;
use crate::error::{AppError, Result};
use crate::video::types::Resolution;
const RTP_MTU: usize = 1200;
@@ -250,6 +250,10 @@ impl UniversalVideoTrack {
TrackType::Sample(track) => {
if let Err(e) = track.write_sample(&sample).await {
debug!("H264 write_sample failed: {}", e);
return Err(AppError::WebRtcError(format!(
"H264 write_sample failed: {}",
e
)));
}
}
TrackType::Rtp(_) => {
@@ -276,6 +280,10 @@ impl UniversalVideoTrack {
TrackType::Sample(track) => {
if let Err(e) = track.write_sample(&sample).await {
debug!("VP8 write_sample failed: {}", e);
return Err(AppError::WebRtcError(format!(
"VP8 write_sample failed: {}",
e
)));
}
}
TrackType::Rtp(_) => {
@@ -298,6 +306,10 @@ impl UniversalVideoTrack {
TrackType::Sample(track) => {
if let Err(e) = track.write_sample(&sample).await {
debug!("VP9 write_sample failed: {}", e);
return Err(AppError::WebRtcError(format!(
"VP9 write_sample failed: {}",
e
)));
}
}
TrackType::Rtp(_) => {
@@ -366,6 +378,10 @@ impl UniversalVideoTrack {
if let Err(e) = rtp_track.write_rtp(&packet).await {
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::error::{AppError, Result};
use crate::events::{EventBus, SystemEvent};
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
use crate::hid::HidController;
use crate::video::device::{
enumerate_devices, select_recovery_device, VideoDevice, VideoDeviceRecoveryHint,
@@ -352,6 +352,7 @@ impl WebRtcStreamer {
);
streamer
.publish_stream_event(SystemEvent::StreamDeviceLost {
kind: StreamDeviceLostKind::Video,
device: original_device.clone(),
reason: reason.clone(),
})

10
web/package-lock.json generated
View File

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

View File

@@ -72,12 +72,12 @@ watch(
<Toaster
rich-colors
close-button
expand
position="top-center"
close-button-position="top-right"
theme="system"
:duration="4000"
:gap="14"
:visible-toasts="3"
: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' }"
:toast-options="toasterToastOptions"

View File

@@ -42,8 +42,6 @@ const isAttached = ref(props.attached ?? true)
const selectedOs = ref<KeyboardOsType>('windows')
const mainKeyboard = ref<Keyboard | null>(null)
const controlKeyboard = ref<Keyboard | null>(null)
const arrowsKeyboard = ref<Keyboard | null>(null)
const pressedModifiers = ref<number>(0)
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 getBottomRow = () => osBottomRows[selectedOs.value].join(' ')
const getBottomRow = () =>
[...osBottomRows[selectedOs.value], 'ArrowLeft', 'ArrowDown', 'ArrowRight'].join(' ')
const keyboardLayout = {
main: {
default: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'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',
'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 Insert Home PageUp',
'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',
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
'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: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'(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)',
'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 Insert Home PageUp',
'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',
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
],
},
control: {
default: [
'PrintScreen ScrollLock Pause',
'Insert Home PageUp',
'Delete End PageDown',
],
},
arrows: {
default: [
'ArrowUp',
'ArrowLeft ArrowDown ArrowRight',
'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 compactMainLayout = {
default: keyboardLayout.main.default.slice(2),
shift: keyboardLayout.main.shift.slice(2),
default: [
'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)
@@ -286,8 +286,6 @@ function updateKeyboardLayout() {
],
}
mainKeyboard.value?.setOptions({ layout: newLayout, display: keyDisplayMap.value })
controlKeyboard.value?.setOptions({ display: keyDisplayMap.value })
arrowsKeyboard.value?.setOptions({ display: keyDisplayMap.value })
updateKeyboardButtonTheme()
}
@@ -431,8 +429,6 @@ function updateKeyboardButtonTheme() {
]
mainKeyboard.value?.setOptions({ buttonTheme })
controlKeyboard.value?.setOptions({ buttonTheme })
arrowsKeyboard.value?.setOptions({ buttonTheme })
}
watch([layoutName, () => props.capsLock], ([name]) => {
@@ -447,10 +443,8 @@ function initKeyboards() {
const id = keyboardId.value
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)
setTimeout(initKeyboards, 50)
return
@@ -476,45 +470,13 @@ function initKeyboards() {
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()
console.log('[VirtualKeyboard] Keyboards initialized:', id)
}
function destroyKeyboards() {
mainKeyboard.value?.destroy()
controlKeyboard.value?.destroy()
arrowsKeyboard.value?.destroy()
mainKeyboard.value = null
controlKeyboard.value = null
arrowsKeyboard.value = null
}
function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null {
@@ -682,10 +644,6 @@ onUnmounted(() => {
</div>
<div class="vkb-keyboards">
<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>
@@ -818,10 +776,9 @@ onUnmounted(() => {
min-width: 90px;
}
/* Right Shift - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
flex-grow: 2.75;
min-width: 110px;
flex-grow: 1.75;
min-width: 70px;
}
/* Bottom row modifiers */
@@ -852,21 +809,33 @@ onUnmounted(() => {
min-width: 55px;
}
/* Control keyboard */
.kb-control-container .hg-button {
min-width: 54px !important;
justify-content: center;
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.vkb .simple-keyboard .hg-button[data-skbtn="ScrollLock"],
.vkb .simple-keyboard .hg-button[data-skbtn="Pause"],
.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 */
.kb-arrows-container .hg-button {
min-width: 44px !important;
width: 44px !important;
justify-content: center;
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] {
margin-left: 6px;
}
.kb-arrows-container .hg-row {
justify-content: center;
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
.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 */
@@ -1127,31 +1096,7 @@ html.dark .hg-theme-default .hg-button.down-key,
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 */
@media (max-width: 900px) {
.vkb-keyboards {
flex-direction: column;
}
.vkb-side {
flex-direction: row;
justify-content: center;
gap: 16px;
}
}
@media (max-width: 640px) {
.vkb-body {
padding: 4px;
@@ -1159,11 +1104,11 @@ html.dark .hg-theme-default .hg-button.down-key,
}
.vkb .simple-keyboard .hg-button {
height: 28px;
font-size: 10px;
padding: 0 3px;
height: 30px;
font-size: 11px;
padding: 0 2px;
margin: 0 1px 2px 0;
min-width: 24px;
min-width: 0;
}
.vkb .simple-keyboard .hg-button.combination-key {
@@ -1172,35 +1117,15 @@ html.dark .hg-theme-default .hg-button.down-key,
padding: 0 6px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
min-width: 60px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
min-width: 52px;
}
.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)"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 60px;
}
.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="(Backslash)"],
.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="ShiftRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="Space"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.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="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 46px;
min-width: 0;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 140px;
.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"],
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
font-size: 11px;
flex-grow: 1;
}
.kb-control-container .hg-button {
min-width: 44px !important;
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.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 {
min-width: 36px !important;
width: 36px !important;
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"] {
margin-left: 4px;
}
.vkb-media-btn {
@@ -1233,55 +1167,27 @@ html.dark .hg-theme-default .hg-button.down-key,
@media (max-width: 400px) {
.vkb .simple-keyboard .hg-button {
height: 26px;
font-size: 9px;
padding: 0 2px;
height: 28px;
font-size: 10px;
padding: 0 1px;
margin: 0 1px 2px 0;
min-width: 20px;
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 {
font-size: 8px;
height: 22px;
padding: 0 4px;
}
.kb-control-container .hg-button {
min-width: 34px !important;
}
.kb-arrows-container .hg-button {
min-width: 30px !important;
width: 30px !important;
.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"],
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
font-size: 10px;
}
.vkb-media-btn {
@@ -1325,15 +1231,6 @@ html.dark .hg-theme-default .hg-button.down-key,
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 */
.keyboard-fade-enter-active,
.keyboard-fade-leave-active {

View File

@@ -1,7 +1,9 @@
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
import type { StreamDeviceLostEventData } from '@/types/websocket'
import { useWebSocket } from '@/composables/useWebSocket'
import { isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
export interface ConsoleEventHandlers {
onStreamConfigChanging?: (data: { reason?: string }) => void
@@ -14,12 +16,10 @@ export interface ConsoleEventHandlers {
onStreamStateChanged?: (data: {
state: string
device?: string | null
/** Optional fine-grained diagnostic tag (e.g. `no_cable`, `out_of_range`, `recovering`). */
reason?: string | null
/** Optional countdown (ms) until the next backend self-recovery attempt. */
next_retry_ms?: number | null
}) => void
onStreamDeviceLost?: (data: { device: string; reason: string }) => void
onStreamDeviceLost?: (data: StreamDeviceLostEventData) => void
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
onStreamRecovered?: (data: { device: string }) => void
onDeviceInfo?: (data: any) => void
@@ -31,12 +31,16 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
const { on, off, connect } = useWebSocket()
const noop = () => {}
function handleStreamDeviceLost(data: { device: string; reason: string }) {
if (systemStore.stream) {
function handleStreamDeviceLost(data: StreamDeviceLostEventData) {
const audioLost = isAudioStreamDeviceLostPayload(data)
if (systemStore.stream && !audioLost) {
systemStore.stream.online = false
}
toast.error(t('console.deviceLost'), {
description: t('console.deviceLostDesc', { device: data.device, reason: data.reason }),
toast.error(t(audioLost ? 'audio.deviceLost' : 'console.deviceLost'), {
description: t(audioLost ? 'audio.deviceLostDesc' : 'console.deviceLostDesc', {
device: data.device,
reason: data.reason,
}),
duration: 5000,
})
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 { webrtcApi, type IceCandidate } from '@/api'
import {
@@ -9,7 +6,7 @@ import {
encodeKeyboardEvent,
encodeMouseEvent,
} from '@/types/hid'
import { useWebSocket } from '@/composables/useWebSocket'
import { videoDebugLog } from '@/lib/debugLog'
export type { HidKeyboardEvent, HidMouseEvent }
@@ -28,7 +25,6 @@ export type WebRTCConnectStage =
| 'disconnected'
| 'failed'
// ICE candidate type: host=P2P local, srflx=P2P STUN, relay=TURN relay
export type IceCandidateType = 'host' | 'srflx' | 'prflx' | 'relay' | 'unknown'
export interface WebRTCStats {
@@ -42,26 +38,14 @@ export interface WebRTCStats {
framesPerSecond: number
jitter: number
roundTripTime: number
// ICE connection info
localCandidateType: IceCandidateType
remoteCandidateType: IceCandidateType
transportProtocol: string // 'udp' | 'tcp'
isRelay: boolean // true if using TURN relay
transportProtocol: string
isRelay: boolean
}
// Cached ICE servers from backend API
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[]> {
try {
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)
}
// Fallback: for local connections, use no ICE servers (host candidates only)
const isLocalConnection = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
@@ -107,20 +90,16 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
let peerConnection: RTCPeerConnection | null = null
let dataChannel: RTCDataChannel | null = null
let sessionId: string | null = null
const sessionIdRef = ref<string | 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 pendingIceCandidates: RTCIceCandidate[] = [] // Queue for ICE candidates before sessionId is set
let pendingRemoteCandidates: WebRTCIceCandidateEvent[] = [] // Queue for server ICE candidates
let pendingRemoteIceComplete = new Set<string>() // Session IDs waiting for end-of-candidates
let seenRemoteCandidates = new Set<string>() // Deduplicate server ICE candidates
let cachedMediaStream: MediaStream | null = null // Cached MediaStream to avoid recreating
let pendingIceCandidates: RTCIceCandidate[] = []
let seenRemoteCandidates = new Set<string>()
let cachedMediaStream: MediaStream | null = null
let allowMdnsHostCandidates = false
let wsHandlersRegistered = false
const { on: wsOn } = useWebSocket()
const state = ref<WebRTCState>('disconnected')
const videoTrack = 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 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 {
const config: RTCConfiguration = {
iceServers,
iceCandidatePoolSize: 10,
iceCandidatePoolSize: getIceCandidatePoolSize(),
}
const pc = new RTCPeerConnection(config)
@@ -159,38 +172,35 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
break
case 'connected':
state.value = 'connected'
connectStage.value = 'connected'
setConnectStage('connected')
error.value = null
startStatsCollection()
break
case 'disconnected':
case 'closed':
state.value = 'disconnected'
connectStage.value = 'disconnected'
setConnectStage('disconnected')
stopStatsCollection()
break
case 'failed':
state.value = 'failed'
connectStage.value = 'failed'
setConnectStage('failed')
error.value = 'Connection failed'
stopStatsCollection()
break
}
}
// Handle ICE connection state
pc.oniceconnectionstatechange = () => {
// ICE state changes handled silently
}
// Handle ICE candidates
pc.onicecandidate = async (event) => {
if (!event.candidate) return
if (shouldSkipLocalCandidate(event.candidate)) return
if (!event.candidate) {
return
}
if (shouldSkipLocalCandidate(event.candidate)) {
return
}
const currentSessionId = sessionId
if (currentSessionId && pc.connectionState !== 'closed') {
// Session ready, send immediately
try {
await webrtcApi.addIceCandidate(currentSessionId, {
candidate: event.candidate.candidate,
@@ -198,11 +208,14 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
sdpMLineIndex: event.candidate.sdpMLineIndex ?? undefined,
usernameFragment: event.candidate.usernameFragment ?? undefined,
})
} catch {
// ICE candidate send failures are non-fatal
} catch (err) {
videoDebugLog('Failed to send local ICE candidate', {
sessionId: currentSessionId,
candidate: summarizeIceCandidate(event.candidate),
error: err,
})
}
} else if (!currentSessionId) {
// Queue candidate until sessionId is set
pendingIceCandidates.push(event.candidate)
}
}
@@ -235,7 +248,13 @@ function setupDataChannel(channel: RTCDataChannel) {
dataChannelReady.value = false
}
channel.onerror = () => {
channel.onerror = (event) => {
videoDebugLog('WebRTC data channel error', {
label: channel.label,
readyState: channel.readyState,
event,
sessionId,
})
}
channel.onmessage = () => {
@@ -251,59 +270,18 @@ function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
return channel
}
function registerWebSocketHandlers() {
if (wsHandlersRegistered) return
wsHandlersRegistered = true
wsOn('webrtc.ice_candidate', handleRemoteIceCandidate)
wsOn('webrtc.ice_complete', handleRemoteIceComplete)
}
function shouldSkipLocalCandidate(candidate: RTCIceCandidate): boolean {
if (allowMdnsHostCandidates) return false
const value = candidate.candidate || ''
return value.includes(' typ host') && value.includes('.local')
}
async function handleRemoteIceCandidate(data: WebRTCIceCandidateEvent) {
if (!data || !data.candidate) return
// Queue until session is ready and remote description is set
if (!sessionId) {
pendingRemoteCandidates.push(data)
return
async function addRemoteIceCandidate(candidate: IceCandidate): Promise<boolean> {
if (!peerConnection) return false
if (!candidate.candidate) return false
if (seenRemoteCandidates.has(candidate.candidate)) {
return false
}
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)
const iceCandidate: RTCIceCandidateInit = {
@@ -315,33 +293,17 @@ async function addRemoteIceCandidate(candidate: IceCandidate) {
try {
await peerConnection.addIceCandidate(iceCandidate)
} catch {
// ICE candidate add failures are non-fatal
return true
} 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() {
if (statsInterval) return
@@ -369,7 +331,6 @@ function startStatsCollection() {
(stat.state === 'succeeded' && stat.selected === true) ||
(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)
if ((isActive || (stat.state === 'succeeded' && hasData)) && !foundActivePair) {
@@ -382,7 +343,6 @@ function startStatsCollection() {
}
}
// Update video stats
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
stats.value.bytesReceived = stat.bytesReceived || 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 remoteCandidate = selectedPairRemoteId ? candidates[selectedPairRemoteId] : undefined
@@ -410,12 +369,10 @@ function startStatsCollection() {
stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay'
} catch {
// Stats collection errors are non-fatal
}
}, 1000)
}
// Stop collecting stats
function stopStatsCollection() {
if (statsInterval) {
clearInterval(statsInterval)
@@ -423,37 +380,42 @@ function stopStatsCollection() {
}
}
// Send queued ICE candidates after sessionId is set
async function flushPendingIceCandidates() {
if (!sessionId || pendingIceCandidates.length === 0) return
const currentSessionId = sessionId
const candidates = [...pendingIceCandidates]
pendingIceCandidates = []
for (const candidate of candidates) {
if (shouldSkipLocalCandidate(candidate)) continue
const sendTasks = candidates.map(async (candidate) => {
if (shouldSkipLocalCandidate(candidate)) {
return
}
try {
await webrtcApi.addIceCandidate(sessionId, {
await webrtcApi.addIceCandidate(currentSessionId, {
candidate: candidate.candidate,
sdpMid: candidate.sdpMid ?? undefined,
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
usernameFragment: candidate.usernameFragment ?? undefined,
})
} catch {
// ICE candidate send failures are non-fatal
}
} catch (err) {
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> {
if (connectInFlight) {
return connectInFlight
}
connectInFlight = (async () => {
registerWebSocketHandlers()
if (isConnecting) {
return state.value === 'connected'
}
@@ -468,67 +430,85 @@ async function connect(): Promise<boolean> {
await disconnect()
}
// Clear pending ICE candidates from previous attempt
pendingIceCandidates = []
seenRemoteCandidates.clear()
try {
state.value = 'connecting'
error.value = null
connectStage.value = 'fetching_ice_servers'
setConnectStage('fetching_ice_servers')
// Fetch ICE servers from backend API
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)
connectStage.value = 'creating_data_channel'
setConnectStage('creating_data_channel')
createDataChannel(peerConnection)
peerConnection.addTransceiver('video', { direction: 'recvonly' })
peerConnection.addTransceiver('audio', { direction: 'recvonly' })
connectStage.value = 'creating_offer'
setConnectStage('creating_offer')
const offer = await peerConnection.createOffer()
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.
const response = await webrtcApi.offer(offer.sdp!)
sessionId = response.session_id
// Send any ICE candidates that were queued while waiting for sessionId
await flushPendingIceCandidates()
sessionIdRef.value = response.session_id
const answer: RTCSessionDescriptionInit = {
type: 'answer',
sdp: response.sdp,
}
connectStage.value = 'setting_remote_description'
setConnectStage('setting_remote_description', { sessionId })
await peerConnection.setRemoteDescription(answer)
// Flush any pending server ICE candidates once remote description is set
connectStage.value = 'applying_ice_candidates'
await flushPendingRemoteIce()
// Add any ICE candidates from the response
if (response.ice_candidates && response.ice_candidates.length > 0) {
for (const candidateObj of response.ice_candidates) {
await addRemoteIceCandidate(candidateObj)
setConnectStage('applying_ice_candidates', {
sessionId,
answerCandidates: response.ice_candidates?.length ?? 0,
})
let appliedAnswerCandidates = 0
for (const candidate of response.ice_candidates ?? []) {
if (await addRemoteIceCandidate(candidate)) {
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 iceConnectedTimeout = 12000
const pollInterval = 100
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 iceState = peerConnection.iceConnectionState
const timeoutForState = iceState === 'connected' || iceState === 'completed'
? iceConnectedTimeout
: connectionTimeout
if (waited >= timeoutForState) break
if (pcState === 'connected') {
connectStage.value = 'connected'
setConnectStage('connected', { sessionId, waited })
isConnecting = false
return true
}
@@ -539,10 +519,25 @@ async function connect(): Promise<boolean> {
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')
} catch (err) {
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'
isConnecting = false
await disconnect()
@@ -557,17 +552,15 @@ async function connect(): Promise<boolean> {
}
}
// Disconnect from WebRTC server
async function disconnect() {
stopStatsCollection()
// Clear state FIRST to prevent ICE candidates from being sent
const oldSessionId = sessionId
sessionId = null
sessionIdRef.value = null
isConnecting = false
pendingIceCandidates = []
pendingRemoteCandidates = []
pendingRemoteIceComplete.clear()
seenRemoteCandidates.clear()
if (dataChannel) {
@@ -584,18 +577,21 @@ async function disconnect() {
if (oldSessionId) {
try {
await webrtcApi.close(oldSessionId)
} catch {
} catch (err) {
videoDebugLog('Failed to close backend WebRTC session', {
sessionId: oldSessionId,
error: err,
})
}
}
videoTrack.value = null
audioTrack.value = null
cachedMediaStream = null // Clear cached stream on disconnect
cachedMediaStream = null
state.value = 'disconnected'
connectStage.value = 'disconnected'
setConnectStage('disconnected', { previousSessionId: oldSessionId })
error.value = null
// Reset stats
stats.value = {
bytesReceived: 0,
packetsReceived: 0,
@@ -614,7 +610,6 @@ async function disconnect() {
}
}
// Send keyboard event via DataChannel (binary format)
function sendKeyboard(event: HidKeyboardEvent): boolean {
if (!dataChannel || dataChannel.readyState !== 'open') {
return false
@@ -629,7 +624,6 @@ function sendKeyboard(event: HidKeyboardEvent): boolean {
}
}
// Send mouse event via DataChannel (binary format)
function sendMouse(event: HidMouseEvent): boolean {
if (!dataChannel || dataChannel.readyState !== 'open') {
return false
@@ -695,7 +689,7 @@ export function useWebRTC() {
error,
dataChannelReady,
connectStage,
sessionId: computed(() => sessionId),
sessionId: sessionIdRef,
connect,
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])
}
function syncSubscriptions() {
function syncSubscriptions(force = false) {
const topics = getSubscribedTopics()
if (arraysEqual(topics, subscribedTopics)) {
if (!force && arraysEqual(topics, subscribedTopics)) {
return
}
subscribedTopics = topics
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
subscribe(topics)
subscribedTopics = topics
}
}
@@ -59,7 +58,7 @@ function connect() {
networkErrorMessage.value = null
reconnectAttempts.value = 0
syncSubscriptions()
syncSubscriptions(true)
}
wsInstance.onmessage = (e) => {

View File

@@ -314,6 +314,10 @@ export default {
title: 'Video device offline',
detail: 'Capture card is not responding, attempting to re-detect…',
},
audioDeviceLost: {
title: 'Audio device offline',
detail: 'Reconnecting the audio capture device…',
},
deviceBusy: {
title: 'Video channel busy',
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',
config_changing: 'Applying new configuration',
mode_switching: 'Switching video mode',
audio_device_lost: 'Audio capture is unavailable; recovery in progress',
audio_reconnecting: 'Retrying audio device connection',
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.',
uvc_capture_stall: '',
@@ -352,6 +358,10 @@ export default {
webrtcPhaseSetRemote: 'Applying remote description...',
webrtcPhaseApplyIce: 'Applying ICE candidates...',
webrtcPhaseNegotiating: 'Negotiating secure connection...',
mjpegPhaseWebsocket: 'Connecting control channel...',
mjpegPhaseStream: 'Requesting video stream...',
mjpegPhaseFirstFrame: 'Waiting for first frame...',
stepProgress: 'Step {current}/{total}',
pointerLocked: 'Pointer Locked',
pointerLockedDesc: 'Press Escape to release the pointer',
pointerLockFailed: 'Failed to lock pointer',
@@ -476,10 +486,11 @@ export default {
},
settings: {
title: 'Settings',
sidebarSubtitle: 'Manage device, network and extensions',
basic: 'Basic',
general: 'General',
appearance: 'Appearance',
account: 'User',
account: 'Account',
access: 'Access',
video: 'Video',
encoder: 'Encoder',
@@ -496,6 +507,19 @@ export default {
configured: 'Configured',
security: 'Security',
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',
deviceInfo: 'Device Info',
deviceInfoDesc: 'Host system information',
@@ -512,8 +536,8 @@ export default {
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
usernameDesc: 'Change your login username',
passwordDesc: 'Change your login password',
usernameDesc: 'Change the console login username',
passwordDesc: 'Change the console login password',
version: 'Version',
buildInfo: 'Build Info',
detectDevices: 'Detect Devices',
@@ -542,20 +566,25 @@ export default {
addBindAddress: 'Add address',
bindAddressListEmpty: 'Add at least one IP address.',
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',
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)',
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',
copyUrl: 'Copy access URL',
openInBrowser: 'Open in browser',
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',
bindModeLocalDesc: '127.0.0.1 — Allow local access only',
bindModeCustomDesc: 'Specify a list of IP addresses',
effectiveAddresses: 'Listen address preview',
bindModeLocalDesc: '127.0.0.1 — Loopback only (local access)',
bindModeCustomDesc: 'Bind to a specific list of IP addresses',
effectiveAddresses: 'Effective listen addresses',
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',
sslCertSelfSigned: 'Self-Signed',
sslCertActive: 'Custom certificate is active',
@@ -568,6 +597,8 @@ export default {
sslCertSaved: 'Certificate saved, restart to apply',
sslCertCleared: 'Reverted to self-signed certificate, restart to apply',
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.',
restarting: 'Restarting...',
autoRestarting: 'Restarting automatically',
@@ -600,10 +631,10 @@ export default {
updateMsgInstalling: 'Replacing binary',
updateMsgRestarting: 'Restarting service',
auth: 'Access',
authSettings: 'Access Settings',
authSettingsDesc: 'Single-user access and session behavior',
allowMultipleSessions: 'Allow multiple web sessions',
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
authSettings: 'Session Policy',
authSettingsDesc: 'Configure single-user login and concurrent session behavior',
allowMultipleSessions: 'Allow concurrent web sessions',
allowMultipleSessionsDesc: 'When disabled, a new login automatically signs out the previous session',
userManagement: 'User Management',
userManagementDesc: 'Manage user accounts and permissions',
addUser: 'Add User',
@@ -665,10 +696,10 @@ export default {
atxWolInterface: 'Network Interface',
atxWolInterfacePlaceholder: 'e.g. eth0, enp0s3',
atxWolInterfaceHint: 'Specify network interface for WOL packets, leave empty for default routing',
themeDesc: 'Choose your preferred color scheme',
languageDesc: 'Select your preferred language',
videoSettings: 'Video Settings',
videoSettingsDesc: 'Configure video capture device',
themeDesc: 'Choose the interface color scheme',
languageDesc: 'Choose the interface display language',
videoSettings: 'Video Capture',
videoSettingsDesc: 'Configure capture device format, resolution and frame rate',
videoDevice: 'Video Device',
selectDevice: 'Select device...',
videoFormat: 'Video Format',
@@ -676,13 +707,13 @@ export default {
driver: 'Driver',
resolution: 'Resolution',
frameRate: 'Frame Rate',
encoderBackend: 'Encoder Backend',
encoderBackendDesc: 'Select encoder backend for WebRTC streaming',
encoderBackend: 'Video Encoder',
encoderBackendDesc: 'Select the encoder backend used for WebRTC streaming',
backend: 'Backend',
autoRecommended: 'Auto (Recommended)',
software: 'Software',
supportedFormats: 'Supported Formats',
encoderHint: 'Hardware encoders provide better performance with lower CPU usage. Software encoders are more compatible but require more CPU resources.',
supportedFormats: 'Supported Codecs',
encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.',
hidSettings: 'HID Settings',
hidSettingsDesc: 'Configure keyboard and mouse control',
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?',
resetAction: 'Reset Device',
},
webrtcSettings: 'WebRTC Settings',
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
publicIceServersHint: 'Empty uses Google public STUN, configure your own TURN for production',
webrtcSettings: 'WebRTC Signaling',
webrtcSettingsDesc: 'Configure STUN/TURN servers to assist NAT traversal',
publicIceServersHint: 'Leave empty to use Google\u2019s public STUN servers; TURN must be self-hosted',
stunServer: 'STUN Server',
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',
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',
turnPassword: 'TURN Password',
turnPasswordConfigured: 'Password already configured. Leave empty to keep current password.',
turnCredentialsHint: 'Credentials for TURN server authentication',
iceConfigNote: 'Note: Changes require reconnecting the WebRTC session to take effect.',
turnPasswordConfigured: 'A password is already saved. Leave empty to keep the current password.',
turnCredentialsHint: 'Credentials used for TURN server authentication',
iceConfigNote: 'Changes apply to the next WebRTC session',
},
virtualKeyboard: {
title: 'Virtual Keyboard',
@@ -872,6 +903,10 @@ export default {
format: 'Format',
resolution: 'Resolution',
fps: 'FPS',
fpsTarget: 'Target FPS',
fpsActual: 'Actual FPS',
fpsStaticHint: 'Frame rate drops automatically while the image is static',
paused: 'Paused',
clients: 'Clients',
backend: 'Backend',
mouse: 'Mouse',

View File

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

View File

@@ -16,8 +16,11 @@ import { CanonicalKey, HidBackend } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
import { cn, generateUUID } from '@/lib/utils'
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 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 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 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.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 (systemStore.stream?.online) return 'connected'
return 'disconnected'
@@ -201,10 +199,18 @@ function getResolutionShortName(width: number, height: number): string {
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 stream = systemStore.stream
if (!stream?.resolution) return ''
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
if (isMjpegPaused.value) return `${resShort} ${t('statusCard.paused')}`
return `${resShort} ${formatFpsValue(backendFps.value)}fps`
})
@@ -212,20 +218,36 @@ const videoDetails = computed<StatusDetail[]>(() => {
const stream = systemStore.stream
if (!stream) return []
const receivedFps = backendFps.value
const paused = isMjpegPaused.value
const inputFmt = stream.format || 'MJPEG'
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt}${outputFmt}`
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
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.format'), value: formatDisplay },
{ 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'>(() => {
@@ -418,7 +440,7 @@ const audioDetails = computed<StatusDetail[]>(() => {
return [
{ label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') },
{ 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
})
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(() => {
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) {
@@ -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(() => {
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 {
return videoMode.value === 'mjpeg'
|| !isConsoleActive.value
@@ -613,6 +711,14 @@ function shouldSuppressAutoReconnect(): boolean {
}
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
videoError.value = true
videoErrorMessage.value = reason
@@ -627,25 +733,45 @@ function markWebRTCFailure(reason: string, description?: string) {
async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise<void> {
if (!pendingWebRTCReadyGate) return
videoDebugLog('Waiting for WebRTC backend ready gate', { reason, timeoutMs })
const ready = await videoSession.waitForWebRTCReadyAny(timeoutMs)
if (!ready) {
console.warn(`[WebRTC] Ready gate timeout (${reason}), attempting connection anyway`)
}
videoDebugLog('WebRTC backend ready gate completed', { reason, ready })
pendingWebRTCReadyGate = false
}
async function connectWebRTCSerial(reason: string): Promise<boolean> {
if (webrtcConnectTask) {
videoDebugLog('Reusing serialized WebRTC connect task', {
reason,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
return webrtcConnectTask
}
videoDebugLog('Starting serialized WebRTC connect task', {
reason,
videoMode: videoMode.value,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
webrtcConnectTask = (async () => {
await waitForWebRTCReadyGate(reason)
return webrtc.connect()
})()
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 {
webrtcConnectTask = null
}
@@ -740,7 +866,11 @@ function handleVideoError() {
}, 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
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
@@ -750,6 +880,12 @@ function handleStreamDeviceLost(data: { device: string; reason: string }) {
}
function scheduleWebRTCRecovery() {
videoDebugLog('Scheduling WebRTC recovery check', {
attempts: webrtcRecoveryAttempts,
videoMode: videoMode.value,
videoError: videoError.value,
sessionId: webrtc.sessionId.value,
})
if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null
@@ -798,6 +934,10 @@ function scheduleWebRTCRecovery() {
}
function cancelWebRTCRecovery() {
videoDebugLog('Cancelling WebRTC recovery', {
attempts: webrtcRecoveryAttempts,
hadTimer: webrtcRecoveryTimerId !== null,
})
if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null
@@ -806,6 +946,7 @@ function cancelWebRTCRecovery() {
}
function handleStreamRecovered(_data: { device: string }) {
videoDebugLog('Stream recovered event', _data)
cancelWebRTCRecovery()
videoError.value = false
@@ -836,6 +977,13 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
}
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) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
@@ -856,6 +1004,13 @@ function handleStreamConfigChanging(_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
gracePeriodTimeoutId = window.setTimeout(() => {
@@ -881,11 +1036,23 @@ async function handleStreamConfigApplied(_data: any) {
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 || '-'}`)
videoDebugLog('WebRTC backend ready event', {
...data,
pendingWebRTCReadyGate,
activeTransitionId: videoSession.activeTransitionId.value,
expectedTransitionId: videoSession.expectedTransitionId.value,
})
pendingWebRTCReadyGate = false
videoSession.onWebRTCReady(data)
}
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)
if (data.mode === 'mjpeg') {
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 }) {
videoDebugLog('Stream mode switching event', {
data,
videoMode: videoMode.value,
localSwitching: videoSession.localSwitching.value,
backendSwitching: videoSession.backendSwitching.value,
})
if (!isModeSwitching.value) {
videoRestarting.value = true
videoLoading.value = true
@@ -904,6 +1077,13 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin
}
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 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
@@ -950,7 +1130,11 @@ function handleStreamStateChanged(data: any) {
captureFrameOverlay().catch(() => {})
}
} else if (state === 'device_lost' && videoMode.value !== 'mjpeg') {
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
if (
!isAudioDeviceLostStateReason(reason)
&& webrtcRecoveryTimerId === null
&& webrtcRecoveryAttempts === 0
) {
scheduleWebRTCRecovery()
}
} else if (state === 'streaming') {
@@ -1014,6 +1198,14 @@ const signalOverlayInfo = computed(() => {
tone: 'info' as const,
}
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 {
title: t('console.signal.deviceLost.title'),
detail: t('console.signal.deviceLost.detail'),
@@ -1076,6 +1268,10 @@ function normalizeServerMode(mode: string | undefined): VideoMode | null {
async function restoreInitialMode(serverMode: VideoMode) {
if (initialModeRestoreDone || initialModeRestoreInProgress) return
initialModeRestoreInProgress = true
videoDebugLog('Restoring initial video mode from backend', {
serverMode,
currentMode: videoMode.value,
})
try {
initialDeviceInfoReceived = true
@@ -1097,6 +1293,14 @@ async function restoreInitialMode(serverMode: VideoMode) {
}
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 prevAudioDevice = systemStore.audio?.device ?? null
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) {
return
}
@@ -1139,10 +1342,14 @@ function handleDeviceInfo(data: any) {
}
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)
if (!newMode) return
// Ignore this during a local mode switch because it was triggered by our own request
if (isModeSwitching.value) {
console.log('[StreamModeChanged] Mode switch in progress, ignoring event')
return
@@ -1161,6 +1368,11 @@ function reloadPage() {
}
function refreshVideo() {
videoDebugLog('Refreshing MJPEG video', {
videoMode: videoMode.value,
previousTimestamp: mjpegTimestamp.value,
streamSignalState: streamSignalState.value,
})
backendFps.value = 0
videoError.value = false
videoErrorMessage.value = ''
@@ -1178,12 +1390,6 @@ function refreshVideo() {
}, 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 mjpegUrl = computed(() => {
if (videoMode.value !== 'mjpeg') {
@@ -1199,6 +1405,12 @@ const mjpegUrl = computed(() => {
})
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) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
@@ -1222,9 +1434,13 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
try {
const success = await connectWebRTCSerial('connectWebRTCOnly')
videoDebugLog('WebRTC-only connect result', {
codec,
success,
stage: webrtc.connectStage.value,
sessionId: webrtc.sessionId.value,
})
if (success) {
// Force video rebind even when the track already exists
// This fixes missing video after returning to the page
await rebindWebRTCVideo()
videoLoading.value = false
@@ -1239,6 +1455,12 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
}
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
webrtcVideoRef.value.srcObject = null
@@ -1259,6 +1481,12 @@ async function rebindWebRTCVideo() {
}
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) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
@@ -1281,18 +1509,27 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
pendingWebRTCReadyGate = true
try {
// Disconnect first so ICE candidates are not sent to stale sessions during backend codec switch.
if (webrtc.isConnected.value || webrtc.sessionId.value) {
await webrtc.disconnect()
}
const modeResp = await streamApi.setMode(codec)
videoDebugLog('Backend setMode response for WebRTC', {
codec,
response: modeResp,
})
if (modeResp.transition_id) {
videoSession.registerTransition(modeResp.transition_id)
const [mode, webrtcReady] = await Promise.all([
videoSession.waitForModeReady(modeResp.transition_id, 5000),
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') {
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]
let success = false
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) {
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)`)
await new Promise(resolve => setTimeout(resolve, delay))
}
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) {
await rebindWebRTCVideo()
@@ -1332,6 +1583,11 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
}
async function switchToMJPEG() {
videoDebugLog('Switching to MJPEG mode', {
currentMode: videoMode.value,
webrtcState: webrtc.state.value,
sessionId: webrtc.sessionId.value,
})
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
@@ -1339,6 +1595,7 @@ async function switchToMJPEG() {
try {
const modeResp = await streamApi.setMode('mjpeg')
videoDebugLog('Backend setMode response for MJPEG', modeResp)
if (modeResp.transition_id) {
videoSession.registerTransition(modeResp.transition_id)
const mode = await videoSession.waitForModeReady(modeResp.transition_id, 5000)
@@ -1364,6 +1621,12 @@ async function switchToMJPEG() {
}
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 (mode === videoMode.value) return
@@ -1378,6 +1641,12 @@ function syncToServerMode(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 (!videoSession.tryStartLocalSwitch()) {
console.log('[VideoMode] Switch throttled or in progress, ignoring')
@@ -1410,12 +1679,26 @@ async function handleVideoModeChange(mode: VideoMode) {
}
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') {
await rebindWebRTCVideo()
}
})
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') {
const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null
if (currentStream && currentStream.getAudioTracks().length === 0) {
@@ -1429,6 +1712,19 @@ watch(webrtcVideoRef, (el) => {
}, { immediate: true })
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) {
backendFps.value = Math.round(stats.framesPerSecond)
systemStore.setStreamOnline(true)
@@ -1442,14 +1738,21 @@ let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
let webrtcReconnectFailures = 0
watch(() => webrtc.state.value, (newState, oldState) => {
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) {
clearTimeout(webrtcReconnectTimeout)
webrtcReconnectTimeout = null
}
// Run before `shouldSuppressAutoReconnect()` so `device_busy` / `videoRestarting`
// never blocks clearing the loading overlay when ICE becomes connected.
if (videoMode.value !== 'mjpeg') {
if (newState === 'connected') {
systemStore.setStreamOnline(true)
@@ -1467,6 +1770,10 @@ watch(() => webrtc.state.value, (newState, oldState) => {
}
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
videoDebugLog('Scheduling WebRTC auto reconnect after disconnect', {
failures: webrtcReconnectFailures,
sessionId: webrtc.sessionId.value,
})
webrtcReconnectTimeout = setTimeout(async () => {
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
try {
@@ -2112,7 +2419,6 @@ async function activateConsoleView() {
isConsoleActive.value = true
registerInteractionListeners()
// REST snapshot: returning from Settings (or other routes) may have missed WS device_info
void systemStore.fetchAllStates()
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
@@ -2405,14 +2711,8 @@ onUnmounted(() => {
<div
ref="videoContainerRef"
class="relative bg-black overflow-hidden flex items-center justify-center"
:style="{
aspectRatio: videoAspectRatio ?? '16/9',
maxWidth: '100%',
maxHeight: '100%',
minHeight: '120px',
}"
:style="videoContainerStyle"
:class="{
'opacity-60': videoLoading || videoError,
'cursor-none': true,
}"
tabindex="0"
@@ -2506,9 +2806,36 @@ onUnmounted(() => {
</div>
<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">
{{ webrtcLoadingMessage }}
<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">
<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>
<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">
{{ t('console.pleaseWait') }}
</p>

View File

@@ -44,7 +44,7 @@ import { getVideoFormatState } from '@/lib/video-format-support'
import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
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 { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
@@ -99,6 +99,7 @@ import {
Radio,
Globe,
Loader2,
AlertTriangle,
} from 'lucide-vue-next'
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) {
activeSection.value = id
mobileMenuOpen.value = false
@@ -327,6 +355,23 @@ const previewAccessUrl = computed(() => {
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 {
video: Array<{
path: string
@@ -792,14 +837,14 @@ const atxConfig = ref({
power: {
driver: 'none' as AtxDriverType,
device: '',
pin: 0,
pin: 1,
active_level: 'high' as ActiveLevel,
baud_rate: 9600,
},
reset: {
driver: 'none' as AtxDriverType,
device: '',
pin: 0,
pin: 1,
active_level: 'high' as ActiveLevel,
baud_rate: 9600,
},
@@ -1038,9 +1083,18 @@ async function saveConfig() {
saved.value = false
try {
// Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH
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({
device: config.value.video_device || undefined,
format: config.value.video_format || undefined,
@@ -1048,16 +1102,8 @@ async function saveConfig() {
height: config.value.video_height,
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 (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
return
@@ -1327,6 +1373,7 @@ async function loadAtxConfig() {
wol_interface: config.wol_interface || '',
}
clearAtxSerialDeviceConflicts()
normalizeAtxRelayChannels()
syncSharedAtxSerialBaudRate()
} catch (e) {
console.error('Failed to load ATX config:', e)
@@ -1345,6 +1392,7 @@ async function saveAtxConfig() {
loading.value = true
saved.value = false
try {
normalizeAtxRelayChannels()
syncSharedAtxSerialBaudRate()
await configStore.updateAtx({
enabled: atxConfig.value.enabled,
@@ -1421,6 +1469,14 @@ function syncSharedAtxSerialBaudRate() {
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(
() => [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(
() => [
atxConfig.value.power.driver,
@@ -2056,7 +2119,7 @@ watch(() => route.query.tab, (tab) => {
<AppLayout>
<div class="flex h-full overflow-hidden">
<!-- 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">
<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">
@@ -2091,16 +2154,22 @@ watch(() => route.query.tab, (tab) => {
</div>
</SheetContent>
</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>
<!-- Desktop Sidebar -->
<aside class="hidden lg:block w-64 shrink-0 border-r bg-muted/30">
<div class="sticky top-0 p-6 space-y-6">
<h1 class="text-xl font-semibold">{{ t('settings.title') }}</h1>
<nav class="space-y-6">
<div class="sticky top-0 p-6 space-y-6 max-h-screen overflow-y-auto">
<div class="space-y-1">
<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">
<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
type="button"
v-for="item in group.items"
@@ -2109,13 +2178,13 @@ watch(() => route.query.tab, (tab) => {
:class="[
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors',
activeSection === item.id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-foreground/80 hover:text-foreground hover:bg-muted'
]"
>
<component :is="item.icon" class="h-4 w-4" />
<span>{{ 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>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
<span class="truncate">{{ item.label }}</span>
<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>
</div>
</nav>
@@ -2124,7 +2193,18 @@ watch(() => route.query.tab, (tab) => {
<!-- Main Content -->
<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 -->
<div v-show="activeSection === 'appearance'" class="space-y-6">
@@ -2134,14 +2214,14 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="flex flex-wrap gap-2">
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
<div class="grid grid-cols-3 gap-2 sm:max-w-md">
<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') }}
</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') }}
</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') }}
</Button>
</div>
@@ -2154,9 +2234,7 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.languageDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="flex">
<LanguageToggleButton variant="outline" size="sm" label-mode="current" />
</div>
</CardContent>
</Card>
</div>
@@ -2171,21 +2249,22 @@ watch(() => route.query.tab, (tab) => {
<CardContent class="space-y-4">
<div class="space-y-2">
<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 class="space-y-2">
<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>
<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>
<div class="flex justify-end">
<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>
</CardContent>
<CardFooter class="border-t pt-4 justify-end">
<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') }}
</Button>
</div>
</CardContent>
</CardFooter>
</Card>
<Card>
@@ -2196,25 +2275,26 @@ watch(() => route.query.tab, (tab) => {
<CardContent class="space-y-4">
<div class="space-y-2">
<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 class="space-y-2">
<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 class="space-y-2">
<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>
<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>
<div class="flex justify-end">
<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>
</CardContent>
<CardFooter class="border-t pt-4 justify-end">
<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') }}
</Button>
</div>
</CardContent>
</CardFooter>
</Card>
<Card>
@@ -2223,7 +2303,7 @@ watch(() => route.query.tab, (tab) => {
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
</CardHeader>
<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">
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
@@ -2233,13 +2313,14 @@ watch(() => route.query.tab, (tab) => {
:disabled="authConfigLoading"
/>
</div>
<div class="flex justify-end pt-2">
</CardContent>
<CardFooter class="border-t pt-4 justify-end">
<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') }}
</Button>
</div>
</CardContent>
</CardFooter>
</Card>
</div>
@@ -2903,24 +2984,27 @@ watch(() => route.query.tab, (tab) => {
<CardTitle>{{ t('settings.portConfig') }}</CardTitle>
<CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<CardContent class="space-y-5">
<!-- HTTPS toggle -->
<div class="flex items-center justify-between">
<div class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<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>
<Switch v-model="webServerConfig.https_enabled" />
</div>
<Separator />
<!-- Single active-port input, label follows the HTTPS toggle -->
<div class="flex items-end gap-3">
<div class="space-y-2 flex-1 max-w-[180px]">
<Label>
<!-- Active port (primary) -->
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<div class="flex items-center gap-2">
<Label class="text-sm font-medium">
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
</Label>
<Badge variant="default" class="h-4 text-[10px] px-1.5">{{ t('settings.portActive') }}</Badge>
</div>
<Input
v-if="webServerConfig.https_enabled"
v-model.number="webServerConfig.https_port"
@@ -2932,42 +3016,65 @@ watch(() => route.query.tab, (tab) => {
type="number" min="1" max="65535"
/>
</div>
<!-- Inactive-port reference (read-only hint) -->
<div class="space-y-2 flex-1 max-w-[180px]">
<Label class="text-muted-foreground text-xs">
{{ webServerConfig.https_enabled ? t('settings.httpPortReserved') : t('settings.httpsPortReserved') }}
<div class="space-y-2">
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">
{{ webServerConfig.https_enabled ? t('settings.httpPort') : t('settings.httpsPort') }}
</Label>
<Badge variant="secondary" class="h-4 text-[10px] px-1.5 font-normal">{{ t('settings.portReserved') }}</Badge>
</div>
<Input
v-if="webServerConfig.https_enabled"
v-model.number="webServerConfig.http_port"
type="number" min="1" max="65535"
class="opacity-50"
class="opacity-60"
/>
<Input
v-else
v-model.number="webServerConfig.https_port"
type="number" min="1" max="65535"
class="opacity-50"
class="opacity-60"
/>
</div>
</div>
<p class="text-xs text-muted-foreground -mt-2">{{ t('settings.portReservedHint') }}</p>
<!-- Preview URL -->
<div class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
<span class="text-muted-foreground shrink-0">{{ t('settings.previewUrl') }}:</span>
<span class="font-mono text-xs break-all">{{ previewAccessUrl }}</span>
<div class="rounded-md border bg-muted/40 p-3 space-y-1.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.previewUrl') }}</p>
<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>
<!-- Save row -->
<div class="flex items-center justify-between pt-2">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p>
</div>
</CardContent>
<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 @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button>
</div>
</CardContent>
</CardFooter>
</Card>
<!-- Listen Address Card -->
@@ -3038,20 +3145,22 @@ watch(() => route.query.tab, (tab) => {
</RadioGroup>
<!-- 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">
<span class="text-muted-foreground shrink-0">{{ t('settings.effectiveAddresses') }}:</span>
<span class="font-mono text-xs break-all">{{ effectiveBindAddresses.join(', ') }}</span>
<div v-if="effectiveBindAddresses.length > 0" class="rounded-md border bg-muted/40 p-3 space-y-1.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{{ t('settings.effectiveAddresses') }}</p>
<code class="font-mono text-xs sm:text-sm break-all block">{{ effectiveBindAddresses.join(', ') }}</code>
</div>
<div class="flex items-center justify-between pt-2">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p>
</CardContent>
<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 @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button>
</div>
</CardContent>
</CardFooter>
</Card>
<!-- SSL Certificate Card -->
@@ -3112,8 +3221,12 @@ watch(() => route.query.tab, (tab) => {
/>
</div>
<div class="flex items-center justify-between pt-1">
<p class="text-xs text-muted-foreground"> {{ t('settings.restartRequired') }}</p>
</CardContent>
<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
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
@click="saveCertificate"
@@ -3122,8 +3235,7 @@ watch(() => route.query.tab, (tab) => {
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
</Button>
</div>
</CardContent>
</CardFooter>
</Card>
</div>
@@ -3229,7 +3341,7 @@ watch(() => route.query.tab, (tab) => {
id="power-pin"
type="number"
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'"
/>
</div>
@@ -3293,7 +3405,7 @@ watch(() => route.query.tab, (tab) => {
id="reset-pin"
type="number"
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'"
/>
</div>
@@ -4109,12 +4221,14 @@ watch(() => route.query.tab, (tab) => {
</div>
<!-- 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 class="flex items-center justify-between sm:justify-end 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">
{{ t('settings.otgFunctionMinWarning') }}
<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 gap-2 sm:gap-3">
<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">
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ t('settings.otgFunctionMinWarning') }}</span>
</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') }}
</Button>
</div>