mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
fix:改进atx usb 继电器适配;修复 webrtc 无法建立连接问题;网页样式优化
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ pub async fn update_stream_config(
|
||||
&state,
|
||||
&old_stream_config,
|
||||
&new_stream_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
10
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
15
web/src/lib/debugLog.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
11
web/src/lib/streamSignal.ts
Normal file
11
web/src/lib/streamSignal.ts
Normal 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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user