mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
refactor: 修改为同步请求
This commit is contained in:
29
src/state.rs
29
src/state.rs
@@ -1,5 +1,5 @@
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
use tokio::sync::{broadcast, watch, RwLock};
|
||||
use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
||||
|
||||
use crate::atx::AtxController;
|
||||
use crate::audio::AudioController;
|
||||
@@ -20,6 +20,31 @@ use crate::update::UpdateService;
|
||||
use crate::video::VideoStreamManager;
|
||||
use crate::webrtc::WebRtcStreamer;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigApplyLocks {
|
||||
pub video: Arc<Mutex<()>>,
|
||||
pub stream: Arc<Mutex<()>>,
|
||||
pub otg: Arc<Mutex<()>>,
|
||||
pub audio: Arc<Mutex<()>>,
|
||||
pub atx: Arc<Mutex<()>>,
|
||||
pub rustdesk: Arc<Mutex<()>>,
|
||||
pub rtsp: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl ConfigApplyLocks {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
video: Arc::new(Mutex::new(())),
|
||||
stream: Arc::new(Mutex::new(())),
|
||||
otg: Arc::new(Mutex::new(())),
|
||||
audio: Arc::new(Mutex::new(())),
|
||||
atx: Arc::new(Mutex::new(())),
|
||||
rustdesk: Arc::new(Mutex::new(())),
|
||||
rtsp: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared Axum/App state: video flows through [`VideoStreamManager`]; WebRTC SDP/ICE/sessions on [`WebRtcStreamer`].
|
||||
pub struct AppState {
|
||||
pub db: DatabasePool,
|
||||
@@ -41,6 +66,7 @@ pub struct AppState {
|
||||
pub update: Arc<UpdateService>,
|
||||
pub shutdown_tx: broadcast::Sender<()>,
|
||||
pub revoked_sessions: Arc<RwLock<VecDeque<String>>>,
|
||||
pub config_apply_locks: ConfigApplyLocks,
|
||||
data_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
@@ -88,6 +114,7 @@ impl AppState {
|
||||
update,
|
||||
shutdown_tx,
|
||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||
config_apply_locks: ConfigApplyLocks::new(),
|
||||
data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,24 @@ use crate::stream_encoder::encoder_type_to_backend;
|
||||
use crate::video::codec_constraints::{
|
||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
||||
};
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ConfigApplyOptions {
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
impl ConfigApplyOptions {
|
||||
pub const fn forced() -> Self {
|
||||
Self { force: true }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_apply_lock(lock: &Arc<Mutex<()>>, domain: &str) -> Result<OwnedMutexGuard<()>> {
|
||||
lock.clone().try_lock_owned().map_err(|_| {
|
||||
AppError::ServiceUnavailable(format!("{domain} configuration is already applying"))
|
||||
})
|
||||
}
|
||||
|
||||
fn hid_backend_type(config: &HidConfig) -> crate::hid::HidBackendType {
|
||||
match config.backend {
|
||||
@@ -33,8 +51,9 @@ pub async fn apply_video_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &VideoConfig,
|
||||
new_config: &VideoConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
if old_config == new_config {
|
||||
if old_config == new_config && !options.force {
|
||||
tracing::info!("Video config unchanged, skipping reload");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -73,10 +92,11 @@ pub async fn apply_stream_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &StreamConfig,
|
||||
new_config: &StreamConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying stream config changes...");
|
||||
|
||||
if old_config.encoder != new_config.encoder {
|
||||
if options.force || old_config.encoder != new_config.encoder {
|
||||
let encoder_backend = encoder_type_to_backend(new_config.encoder.clone());
|
||||
tracing::info!(
|
||||
"Updating encoder backend to: {:?} (from config: {:?})",
|
||||
@@ -86,12 +106,11 @@ pub async fn apply_stream_config(
|
||||
state.webrtc.update_encoder_backend(encoder_backend).await;
|
||||
}
|
||||
|
||||
if old_config.bitrate_preset != new_config.bitrate_preset {
|
||||
if options.force || old_config.bitrate_preset != new_config.bitrate_preset {
|
||||
state
|
||||
.stream_manager
|
||||
.set_bitrate_preset(new_config.bitrate_preset)
|
||||
.await
|
||||
.ok(); // Ignore error if no active stream
|
||||
.await?;
|
||||
}
|
||||
|
||||
let ice_changed = old_config.stun_server != new_config.stun_server
|
||||
@@ -99,7 +118,7 @@ pub async fn apply_stream_config(
|
||||
|| old_config.turn_username != new_config.turn_username
|
||||
|| old_config.turn_password != new_config.turn_password;
|
||||
|
||||
if ice_changed {
|
||||
if options.force || ice_changed {
|
||||
tracing::info!(
|
||||
"Updating ICE config: STUN={:?}, TURN={:?}",
|
||||
new_config.stun_server,
|
||||
@@ -128,6 +147,7 @@ pub async fn apply_hid_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &HidConfig,
|
||||
new_config: &HidConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
let current_msd_enabled = state.config.get().msd.enabled;
|
||||
new_config.validate_otg_endpoint_budget(current_msd_enabled)?;
|
||||
@@ -149,6 +169,7 @@ pub async fn apply_hid_config(
|
||||
&& !hid_functions_changed
|
||||
&& !keyboard_leds_changed
|
||||
&& !endpoint_budget_changed
|
||||
&& !options.force
|
||||
{
|
||||
tracing::info!("HID config unchanged, skipping reload");
|
||||
return Ok(());
|
||||
@@ -190,6 +211,7 @@ pub async fn apply_msd_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &MsdConfig,
|
||||
new_config: &MsdConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
state
|
||||
.config
|
||||
@@ -222,7 +244,7 @@ pub async fn apply_msd_config(
|
||||
tracing::warn!("Failed to create MSD ventoy directory: {}", e);
|
||||
}
|
||||
|
||||
let needs_reload = old_msd_enabled != new_msd_enabled || msd_dir_changed;
|
||||
let needs_reload = options.force || old_msd_enabled != new_msd_enabled || msd_dir_changed;
|
||||
if !needs_reload {
|
||||
tracing::info!(
|
||||
"MSD enabled state unchanged ({}) and directory unchanged, no reload needed",
|
||||
@@ -272,7 +294,9 @@ pub async fn apply_msd_config(
|
||||
}
|
||||
|
||||
let current_config = state.config.get();
|
||||
if current_config.hid.backend == HidBackend::Otg && old_msd_enabled != new_msd_enabled {
|
||||
if current_config.hid.backend == HidBackend::Otg
|
||||
&& (options.force || old_msd_enabled != new_msd_enabled)
|
||||
{
|
||||
state
|
||||
.hid
|
||||
.reload(crate::hid::HidBackendType::Otg)
|
||||
@@ -306,12 +330,11 @@ pub async fn apply_atx_config(
|
||||
tracing::info!("ATX enabled in config, initializing...");
|
||||
|
||||
let atx = crate::atx::AtxController::new(controller_config);
|
||||
if let Err(e) = atx.init().await {
|
||||
tracing::warn!("ATX initialization failed: {}", e);
|
||||
} else {
|
||||
*state.atx.write().await = Some(atx);
|
||||
tracing::info!("ATX controller initialized successfully");
|
||||
}
|
||||
atx.init()
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("ATX initialization failed: {}", e)))?;
|
||||
*state.atx.write().await = Some(atx);
|
||||
tracing::info!("ATX controller initialized successfully");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,25 +354,18 @@ pub async fn apply_audio_config(
|
||||
quality: new_config.quality.parse::<crate::audio::AudioQuality>()?,
|
||||
};
|
||||
|
||||
if let Err(e) = state.audio.update_config(audio_config).await {
|
||||
tracing::error!("Audio config update failed: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Audio config applied: enabled={}, device={}",
|
||||
new_config.enabled,
|
||||
new_config.device
|
||||
);
|
||||
}
|
||||
state.audio.update_config(audio_config).await?;
|
||||
tracing::info!(
|
||||
"Audio config applied: enabled={}, device={}",
|
||||
new_config.enabled,
|
||||
new_config.device
|
||||
);
|
||||
|
||||
if let Err(e) = state
|
||||
state
|
||||
.stream_manager
|
||||
.set_webrtc_audio_enabled(new_config.enabled)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to update WebRTC audio state: {}", e);
|
||||
} else {
|
||||
tracing::info!("WebRTC audio enabled: {}", new_config.enabled);
|
||||
}
|
||||
.await?;
|
||||
tracing::info!("WebRTC audio enabled: {}", new_config.enabled);
|
||||
|
||||
if new_config.enabled {
|
||||
state.stream_manager.reconnect_webrtc_audio_sources().await;
|
||||
@@ -370,6 +386,7 @@ pub async fn apply_rustdesk_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &crate::rustdesk::config::RustDeskConfig,
|
||||
new_config: &crate::rustdesk::config::RustDeskConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying RustDesk config changes...");
|
||||
|
||||
@@ -378,16 +395,18 @@ pub async fn apply_rustdesk_config(
|
||||
|
||||
if old_config.enabled && !new_config.enabled {
|
||||
if let Some(ref service) = *rustdesk_guard {
|
||||
if let Err(e) = service.stop().await {
|
||||
tracing::error!("Failed to stop RustDesk service: {}", e);
|
||||
}
|
||||
service
|
||||
.stop()
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("Failed to stop RustDesk service: {}", e)))?;
|
||||
tracing::info!("RustDesk service stopped");
|
||||
}
|
||||
*rustdesk_guard = None;
|
||||
}
|
||||
|
||||
if new_config.enabled {
|
||||
let need_restart = old_config.rendezvous_server != new_config.rendezvous_server
|
||||
let need_restart = options.force
|
||||
|| old_config.rendezvous_server != new_config.rendezvous_server
|
||||
|| old_config.device_id != new_config.device_id
|
||||
|| old_config.device_password != new_config.device_password;
|
||||
|
||||
@@ -399,24 +418,22 @@ pub async fn apply_rustdesk_config(
|
||||
state.hid.clone(),
|
||||
state.audio.clone(),
|
||||
);
|
||||
if let Err(e) = service.start().await {
|
||||
tracing::error!("Failed to start RustDesk service: {}", e);
|
||||
} else {
|
||||
tracing::info!("RustDesk service started with ID: {}", new_config.device_id);
|
||||
credentials_to_save = service.save_credentials();
|
||||
}
|
||||
service.start().await.map_err(|e| {
|
||||
AppError::Config(format!("Failed to start RustDesk service: {}", e))
|
||||
})?;
|
||||
tracing::info!("RustDesk service started with ID: {}", new_config.device_id);
|
||||
credentials_to_save = service.save_credentials();
|
||||
*rustdesk_guard = Some(std::sync::Arc::new(service));
|
||||
} else if need_restart {
|
||||
if let Some(ref service) = *rustdesk_guard {
|
||||
if let Err(e) = service.restart(new_config.clone()).await {
|
||||
tracing::error!("Failed to restart RustDesk service: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"RustDesk service restarted with ID: {}",
|
||||
new_config.device_id
|
||||
);
|
||||
credentials_to_save = service.save_credentials();
|
||||
}
|
||||
service.restart(new_config.clone()).await.map_err(|e| {
|
||||
AppError::Config(format!("Failed to restart RustDesk service: {}", e))
|
||||
})?;
|
||||
tracing::info!(
|
||||
"RustDesk service restarted with ID: {}",
|
||||
new_config.device_id
|
||||
);
|
||||
credentials_to_save = service.save_credentials();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +441,7 @@ pub async fn apply_rustdesk_config(
|
||||
drop(rustdesk_guard);
|
||||
if let Some(updated_config) = credentials_to_save {
|
||||
tracing::info!("Saving RustDesk credentials to config store...");
|
||||
if let Err(e) = state
|
||||
state
|
||||
.config
|
||||
.update(|cfg| {
|
||||
cfg.rustdesk.public_key = updated_config.public_key.clone();
|
||||
@@ -433,12 +450,8 @@ pub async fn apply_rustdesk_config(
|
||||
cfg.rustdesk.signing_private_key = updated_config.signing_private_key.clone();
|
||||
cfg.rustdesk.uuid = updated_config.uuid.clone();
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to save RustDesk credentials: {}", e);
|
||||
} else {
|
||||
tracing::info!("RustDesk credentials saved successfully");
|
||||
}
|
||||
.await?;
|
||||
tracing::info!("RustDesk credentials saved successfully");
|
||||
}
|
||||
|
||||
if let Some(message) = enforce_stream_codec_constraints(state).await? {
|
||||
@@ -452,6 +465,7 @@ pub async fn apply_rtsp_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &RtspConfig,
|
||||
new_config: &RtspConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying RTSP config changes...");
|
||||
|
||||
@@ -459,15 +473,17 @@ pub async fn apply_rtsp_config(
|
||||
|
||||
if old_config.enabled && !new_config.enabled {
|
||||
if let Some(ref service) = *rtsp_guard {
|
||||
if let Err(e) = service.stop().await {
|
||||
tracing::error!("Failed to stop RTSP service: {}", e);
|
||||
}
|
||||
service
|
||||
.stop()
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("Failed to stop RTSP service: {}", e)))?;
|
||||
}
|
||||
*rtsp_guard = None;
|
||||
}
|
||||
|
||||
if new_config.enabled {
|
||||
let need_restart = old_config.bind != new_config.bind
|
||||
let need_restart = options.force
|
||||
|| old_config.bind != new_config.bind
|
||||
|| old_config.port != new_config.port
|
||||
|| old_config.path != new_config.path
|
||||
|| old_config.codec != new_config.codec
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::config::{AtxConfig, HidBackend, HidConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_atx_config;
|
||||
use super::apply::{apply_atx_config, try_apply_lock};
|
||||
use super::types::AtxConfigUpdate;
|
||||
|
||||
pub async fn get_atx_config(State(state): State<Arc<AppState>>) -> Json<AtxConfig> {
|
||||
@@ -22,6 +22,7 @@ pub async fn update_atx_config(
|
||||
|
||||
req.validate_with_current(&old_atx_config)?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.atx, "atx")?;
|
||||
let mut merged_atx_config = old_atx_config.clone();
|
||||
req.apply_to(&mut merged_atx_config);
|
||||
validate_serial_device_conflict(&merged_atx_config, ¤t_config.hid)?;
|
||||
@@ -35,9 +36,7 @@ pub async fn update_atx_config(
|
||||
|
||||
let new_atx_config = state.config.get().atx.clone();
|
||||
|
||||
if let Err(e) = apply_atx_config(&state, &old_atx_config, &new_atx_config).await {
|
||||
tracing::error!("Failed to apply ATX config: {}", e);
|
||||
}
|
||||
apply_atx_config(&state, &old_atx_config, &new_atx_config).await?;
|
||||
|
||||
Ok(Json(new_atx_config))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::config::AudioConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_audio_config;
|
||||
use super::apply::{apply_audio_config, try_apply_lock};
|
||||
use super::types::AudioConfigUpdate;
|
||||
|
||||
pub async fn get_audio_config(State(state): State<Arc<AppState>>) -> Json<AudioConfig> {
|
||||
@@ -18,6 +18,7 @@ pub async fn update_audio_config(
|
||||
) -> Result<Json<AudioConfig>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.audio, "audio")?;
|
||||
let old_audio_config = state.config.get().audio.clone();
|
||||
|
||||
state
|
||||
@@ -29,9 +30,7 @@ pub async fn update_audio_config(
|
||||
|
||||
let new_audio_config = state.config.get().audio.clone();
|
||||
|
||||
if let Err(e) = apply_audio_config(&state, &old_audio_config, &new_audio_config).await {
|
||||
tracing::error!("Failed to apply audio config: {}", e);
|
||||
}
|
||||
apply_audio_config(&state, &old_audio_config, &new_audio_config).await?;
|
||||
|
||||
Ok(Json(new_audio_config))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::config::HidConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_hid_config;
|
||||
use super::apply::{apply_hid_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::HidConfigUpdate;
|
||||
|
||||
pub async fn get_hid_config(State(state): State<Arc<AppState>>) -> Json<HidConfig> {
|
||||
@@ -18,6 +18,7 @@ pub async fn update_hid_config(
|
||||
) -> Result<Json<HidConfig>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
|
||||
let old_hid_config = state.config.get().hid.clone();
|
||||
|
||||
state
|
||||
@@ -29,9 +30,13 @@ pub async fn update_hid_config(
|
||||
|
||||
let new_hid_config = state.config.get().hid.clone();
|
||||
|
||||
if let Err(e) = apply_hid_config(&state, &old_hid_config, &new_hid_config).await {
|
||||
tracing::error!("Failed to apply HID config: {}", e);
|
||||
}
|
||||
apply_hid_config(
|
||||
&state,
|
||||
&old_hid_config,
|
||||
&new_hid_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(new_hid_config))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::config::MsdConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_msd_config;
|
||||
use super::apply::{apply_msd_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::MsdConfigUpdate;
|
||||
|
||||
pub async fn get_msd_config(State(state): State<Arc<AppState>>) -> Json<MsdConfig> {
|
||||
@@ -18,6 +18,7 @@ pub async fn update_msd_config(
|
||||
) -> Result<Json<MsdConfig>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
|
||||
let old_msd_config = state.config.get().msd.clone();
|
||||
|
||||
state
|
||||
@@ -29,9 +30,13 @@ pub async fn update_msd_config(
|
||||
|
||||
let new_msd_config = state.config.get().msd.clone();
|
||||
|
||||
if let Err(e) = apply_msd_config(&state, &old_msd_config, &new_msd_config).await {
|
||||
tracing::error!("Failed to apply MSD config: {}", e);
|
||||
}
|
||||
apply_msd_config(
|
||||
&state,
|
||||
&old_msd_config,
|
||||
&new_msd_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(new_msd_config))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_rtsp_config;
|
||||
use super::apply::{apply_rtsp_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::{RtspConfigResponse, RtspConfigUpdate, RtspStatusResponse};
|
||||
|
||||
pub async fn get_rtsp_config(State(state): State<Arc<AppState>>) -> Json<RtspConfigResponse> {
|
||||
@@ -32,6 +32,7 @@ pub async fn update_rtsp_config(
|
||||
) -> Result<Json<RtspConfigResponse>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
|
||||
let old_config = state.config.get().rtsp.clone();
|
||||
|
||||
state
|
||||
@@ -42,26 +43,13 @@ pub async fn update_rtsp_config(
|
||||
.await?;
|
||||
|
||||
let new_config = state.config.get().rtsp.clone();
|
||||
if let Err(err) = apply_rtsp_config(&state, &old_config, &new_config).await {
|
||||
tracing::error!("Failed to apply RTSP config: {}", err);
|
||||
if let Err(rollback_err) = state
|
||||
.config
|
||||
.update(|config| {
|
||||
config.rtsp = old_config.clone();
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to rollback RTSP config after apply failure: {}",
|
||||
rollback_err
|
||||
);
|
||||
return Err(AppError::ServiceUnavailable(format!(
|
||||
"RTSP apply failed: {}; rollback failed: {}",
|
||||
err, rollback_err
|
||||
)));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
apply_rtsp_config(
|
||||
&state,
|
||||
&old_config,
|
||||
&new_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(RtspConfigResponse::from(&new_config)))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::error::Result;
|
||||
use crate::rustdesk::config::RustDeskConfig;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_rustdesk_config;
|
||||
use super::apply::{apply_rustdesk_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::RustDeskConfigUpdate;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -75,6 +75,7 @@ pub async fn update_rustdesk_config(
|
||||
) -> Result<Json<RustDeskConfigResponse>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?;
|
||||
let old_config = state.config.get().rustdesk.clone();
|
||||
|
||||
state
|
||||
@@ -86,9 +87,13 @@ pub async fn update_rustdesk_config(
|
||||
|
||||
let new_config = state.config.get().rustdesk.clone();
|
||||
|
||||
if let Err(e) = apply_rustdesk_config(&state, &old_config, &new_config).await {
|
||||
tracing::error!("Failed to apply RustDesk config: {}", e);
|
||||
}
|
||||
apply_rustdesk_config(
|
||||
&state,
|
||||
&old_config,
|
||||
&new_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let constraints = state.stream_manager.codec_constraints().await;
|
||||
if constraints.rustdesk_enabled || constraints.rtsp_enabled {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_stream_config;
|
||||
use super::apply::{apply_stream_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::{StreamConfigResponse, StreamConfigUpdate};
|
||||
|
||||
pub async fn get_stream_config(State(state): State<Arc<AppState>>) -> Json<StreamConfigResponse> {
|
||||
@@ -18,6 +18,7 @@ pub async fn update_stream_config(
|
||||
) -> Result<Json<StreamConfigResponse>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.stream, "stream")?;
|
||||
let old_stream_config = state.config.get().stream.clone();
|
||||
|
||||
state
|
||||
@@ -29,13 +30,15 @@ pub async fn update_stream_config(
|
||||
|
||||
let new_stream_config = state.config.get().stream.clone();
|
||||
|
||||
if let Err(e) = apply_stream_config(&state, &old_stream_config, &new_stream_config).await {
|
||||
tracing::error!("Failed to apply stream config: {}", e);
|
||||
}
|
||||
apply_stream_config(
|
||||
&state,
|
||||
&old_stream_config,
|
||||
&new_stream_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(e) = super::apply::enforce_stream_codec_constraints(&state).await {
|
||||
tracing::error!("Failed to enforce stream codec constraints: {}", e);
|
||||
}
|
||||
super::apply::enforce_stream_codec_constraints(&state).await?;
|
||||
|
||||
Ok(Json(StreamConfigResponse::from(&new_stream_config)))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::config::VideoConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::apply_video_config;
|
||||
use super::apply::{apply_video_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::VideoConfigUpdate;
|
||||
|
||||
pub async fn get_video_config(State(state): State<Arc<AppState>>) -> Json<VideoConfig> {
|
||||
@@ -18,6 +18,7 @@ pub async fn update_video_config(
|
||||
) -> Result<Json<VideoConfig>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.video, "video")?;
|
||||
let old_video_config = state.config.get().video.clone();
|
||||
|
||||
state
|
||||
@@ -29,10 +30,13 @@ pub async fn update_video_config(
|
||||
|
||||
let new_video_config = state.config.get().video.clone();
|
||||
|
||||
if let Err(e) = apply_video_config(&state, &old_video_config, &new_video_config).await {
|
||||
tracing::error!("Failed to apply video config: {}", e);
|
||||
// 根据用户选择,仅记录错误,不回滚
|
||||
}
|
||||
apply_video_config(
|
||||
&state,
|
||||
&old_video_config,
|
||||
&new_video_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(new_video_config))
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use self::config::apply::ConfigApplyOptions;
|
||||
use crate::auth::{Session, SESSION_COOKIE};
|
||||
use crate::config::{AppConfig, StreamMode};
|
||||
use crate::config::StreamMode;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
use crate::update::{UpdateChannel, UpdateOverviewResponse, UpdateStatusResponse, UpgradeRequest};
|
||||
@@ -739,8 +740,13 @@ pub async fn setup_init(
|
||||
// Start RustDesk if enabled
|
||||
if new_config.rustdesk.enabled {
|
||||
let empty_config = crate::rustdesk::config::RustDeskConfig::default();
|
||||
if let Err(e) =
|
||||
config::apply::apply_rustdesk_config(&state, &empty_config, &new_config.rustdesk).await
|
||||
if let Err(e) = config::apply::apply_rustdesk_config(
|
||||
&state,
|
||||
&empty_config,
|
||||
&new_config.rustdesk,
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to start RustDesk during setup: {}", e);
|
||||
} else {
|
||||
@@ -751,8 +757,13 @@ pub async fn setup_init(
|
||||
// Start RTSP if enabled
|
||||
if new_config.rtsp.enabled {
|
||||
let empty_config = crate::config::RtspConfig::default();
|
||||
if let Err(e) =
|
||||
config::apply::apply_rtsp_config(&state, &empty_config, &new_config.rtsp).await
|
||||
if let Err(e) = config::apply::apply_rtsp_config(
|
||||
&state,
|
||||
&empty_config,
|
||||
&new_config.rtsp,
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to start RTSP during setup: {}", e);
|
||||
} else {
|
||||
@@ -792,160 +803,6 @@ pub async fn setup_init(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateConfigRequest {
|
||||
#[serde(flatten)]
|
||||
pub updates: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn update_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UpdateConfigRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
// Keep old config for rollback
|
||||
let old_config = state.config.get();
|
||||
|
||||
tracing::info!("Received config update request");
|
||||
|
||||
// Validate and merge config first (outside the update closure)
|
||||
let config_json = serde_json::to_value(&old_config)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to serialize config: {}", e)))?;
|
||||
|
||||
let merged = merge_json(config_json, req.updates.clone())
|
||||
.map_err(|_| AppError::Internal("Failed to merge config".to_string()))?;
|
||||
|
||||
let new_config: AppConfig = serde_json::from_value(merged)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid config format: {}", e)))?;
|
||||
|
||||
let new_config = new_config;
|
||||
new_config
|
||||
.hid
|
||||
.validate_otg_endpoint_budget(new_config.msd.enabled)?;
|
||||
|
||||
// Apply the validated config
|
||||
state.config.set(new_config.clone()).await?;
|
||||
|
||||
tracing::info!("Config updated successfully");
|
||||
|
||||
// Detect which config sections were sent in the request
|
||||
let has_video = req.updates.get("video").is_some();
|
||||
let has_stream = req.updates.get("stream").is_some();
|
||||
let has_hid = req.updates.get("hid").is_some();
|
||||
let has_msd = req.updates.get("msd").is_some();
|
||||
let has_atx = req.updates.get("atx").is_some();
|
||||
let has_audio = req.updates.get("audio").is_some();
|
||||
|
||||
tracing::info!(
|
||||
"Config sections sent: video={}, stream={}, hid={}, msd={}, atx={}, audio={}",
|
||||
has_video,
|
||||
has_stream,
|
||||
has_hid,
|
||||
has_msd,
|
||||
has_atx,
|
||||
has_audio
|
||||
);
|
||||
|
||||
// Get new config for device reloading
|
||||
let new_config = state.config.get();
|
||||
|
||||
if has_video {
|
||||
if let Err(e) =
|
||||
config::apply::apply_video_config(&state, &old_config.video, &new_config.video).await
|
||||
{
|
||||
tracing::error!("Failed to apply video config: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("Video configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if has_stream {
|
||||
if let Err(e) =
|
||||
config::apply::apply_stream_config(&state, &old_config.stream, &new_config.stream).await
|
||||
{
|
||||
tracing::error!("Failed to apply stream config: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("Stream configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if has_hid {
|
||||
if let Err(e) =
|
||||
config::apply::apply_hid_config(&state, &old_config.hid, &new_config.hid).await
|
||||
{
|
||||
tracing::error!("HID reload failed: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("HID configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if has_audio {
|
||||
if let Err(e) =
|
||||
config::apply::apply_audio_config(&state, &old_config.audio, &new_config.audio).await
|
||||
{
|
||||
tracing::warn!("Audio config update failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if has_msd {
|
||||
if let Err(e) =
|
||||
config::apply::apply_msd_config(&state, &old_config.msd, &new_config.msd).await
|
||||
{
|
||||
tracing::error!("MSD initialization failed: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("MSD initialization failed: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if has_atx {
|
||||
if let Err(e) =
|
||||
config::apply::apply_atx_config(&state, &old_config.atx, &new_config.atx).await
|
||||
{
|
||||
tracing::error!("ATX configuration invalid: {}", e);
|
||||
state.config.set((*old_config).clone()).await?;
|
||||
return Ok(Json(LoginResponse {
|
||||
success: false,
|
||||
message: Some(format!("ATX configuration invalid: {}", e)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Configuration updated".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn merge_json(
|
||||
base: serde_json::Value,
|
||||
updates: serde_json::Value,
|
||||
) -> std::result::Result<serde_json::Value, ()> {
|
||||
match (base, updates) {
|
||||
(serde_json::Value::Object(mut base), serde_json::Value::Object(updates)) => {
|
||||
for (key, value) in updates {
|
||||
if let Some(base_value) = base.get(&key).cloned() {
|
||||
base.insert(key, merge_json(base_value, value)?);
|
||||
} else {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
Ok(serde_json::Value::Object(base))
|
||||
}
|
||||
(_, updates) => Ok(updates),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeviceList {
|
||||
pub video: Vec<VideoDevice>,
|
||||
|
||||
@@ -78,7 +78,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/ws/audio", any(audio_ws_handler))
|
||||
// Configuration management (domain-separated endpoints)
|
||||
.route("/config", get(handlers::config::get_all_config))
|
||||
.route("/config", post(handlers::update_config))
|
||||
.route("/config/video", get(handlers::config::get_video_config))
|
||||
.route(
|
||||
"/config/video",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-sonner/style.css'
|
||||
import { KeepAlive, onMounted, watch } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -54,4 +56,5 @@ watch(
|
||||
</KeepAlive>
|
||||
<component :is="Component" v-if="route.name !== 'Console' || !authStore.isAuthenticated" />
|
||||
</RouterView>
|
||||
<Toaster rich-colors close-button position="top-center" />
|
||||
</template>
|
||||
|
||||
@@ -613,17 +613,7 @@ function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOpt
|
||||
})
|
||||
}
|
||||
|
||||
/** @deprecated 使用域特定 API(videoConfigApi, hidConfigApi 等)替代 */
|
||||
export const configApi = {
|
||||
get: () => request<Record<string, unknown>>('/config'),
|
||||
|
||||
/** @deprecated 使用域特定 API 的 update 方法替代 */
|
||||
update: (updates: Record<string, unknown>) =>
|
||||
request<{ success: boolean }>('/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(updates),
|
||||
}),
|
||||
|
||||
listDevices: async () => {
|
||||
const result = await request<{
|
||||
video: Array<{
|
||||
|
||||
@@ -92,8 +92,8 @@ const isFullscreen = ref(false)
|
||||
const videoLoading = ref(true)
|
||||
const videoError = ref(false)
|
||||
const videoErrorMessage = ref('')
|
||||
const videoRestarting = ref(false) // Track if video is restarting due to config change
|
||||
const mjpegFrameReceived = ref(false) // Whether MJPEG stream has received at least one frame
|
||||
const videoRestarting = ref(false)
|
||||
const mjpegFrameReceived = ref(false)
|
||||
|
||||
/** From `stream.state_changed`: ok | no_signal | device_lost | device_busy */
|
||||
type StreamSignalState = 'ok' | 'no_signal' | 'device_lost' | 'device_busy'
|
||||
@@ -101,22 +101,17 @@ const streamSignalState = ref<StreamSignalState>('ok')
|
||||
const streamSignalReason = ref<string | null>(null)
|
||||
const streamNextRetryMs = ref<number | null>(null)
|
||||
|
||||
// Using string format "width/height" to let browser handle the ratio calculation
|
||||
const videoAspectRatio = ref<string | null>(null)
|
||||
|
||||
// Backend-provided FPS (received from WebSocket stream.stats_update events)
|
||||
const backendFps = ref(0)
|
||||
|
||||
// Per-client statistics from backend
|
||||
interface ClientStat {
|
||||
id: string
|
||||
fps: number // Integer: frames sent in last second
|
||||
fps: number
|
||||
connected_secs: number
|
||||
}
|
||||
const clientsStats = ref<Record<string, ClientStat>>({})
|
||||
|
||||
// Generate a unique client ID for this browser session
|
||||
// This allows us to identify our own stats in the clients_stat map
|
||||
const myClientId = generateUUID()
|
||||
|
||||
const mouseMode = ref<'absolute' | 'relative'>('absolute')
|
||||
@@ -129,8 +124,8 @@ const keyboardLed = computed(() => ({
|
||||
const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false)
|
||||
const activeModifierMask = ref(0)
|
||||
const mousePosition = ref({ x: 0, y: 0 })
|
||||
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
|
||||
const isPointerLocked = ref(false) // Track pointer lock state
|
||||
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)
|
||||
@@ -140,7 +135,7 @@ let mouseMoveSendIntervalMs = DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
let mouseFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastMouseMoveSendTime = 0
|
||||
let pendingMouseMove: { type: 'move' | 'move_abs'; x: number; y: number } | null = null
|
||||
let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas between sends
|
||||
let accumulatedDelta = { x: 0, y: 0 }
|
||||
|
||||
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
|
||||
let interactionListenersBound = false
|
||||
@@ -176,9 +171,7 @@ const showTerminalDialog = ref(false)
|
||||
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
// Status computed (Device status removed - now only Video, Audio, HID, MSD)
|
||||
const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
// If WebSocket has network error, video is also affected (same network dependency)
|
||||
if (wsNetworkError.value) return 'connecting'
|
||||
|
||||
if (videoError.value) return 'error'
|
||||
@@ -240,24 +233,16 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
|
||||
if (hid?.error) return 'error'
|
||||
|
||||
// In WebRTC mode, check DataChannel status first
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
// DataChannel is ready - HID is connected via WebRTC
|
||||
if (webrtc.dataChannelReady.value) return 'connected'
|
||||
// WebRTC is connecting - HID is also connecting
|
||||
if (webrtc.isConnecting.value) return 'connecting'
|
||||
// WebRTC is connected but DataChannel not ready - still connecting
|
||||
if (webrtc.isConnected.value) return 'connecting'
|
||||
// WebRTC not connected - fall through to WebSocket check as fallback
|
||||
}
|
||||
|
||||
// MJPEG mode or WebRTC fallback: check WebSocket HID status
|
||||
if (hidWs.networkError.value) return 'connecting'
|
||||
|
||||
// If HID WebSocket is not connected (disconnected without error), show disconnected
|
||||
if (!hidWs.connected.value) return 'disconnected'
|
||||
|
||||
// If HID backend is unavailable (business error), show disconnected (gray)
|
||||
if (hidWs.hidUnavailable.value) return 'disconnected'
|
||||
|
||||
if (hid?.available && hid.online) return 'connected'
|
||||
@@ -346,12 +331,10 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
||||
|
||||
const details: StatusDetail[] = []
|
||||
|
||||
// Backend + device combined
|
||||
const backendStr = hid.backend || t('common.unknown')
|
||||
const deviceStr = hid.device ? ` @ ${hid.device}` : ''
|
||||
details.push({ label: t('statusCard.backend'), value: `${backendStr}${deviceStr}` })
|
||||
|
||||
// Error message (with error code as suffix when present) OR normal-state info
|
||||
if (errorMessage) {
|
||||
const codeSuffix = hid.errorCode ? ` (${hid.errorCode})` : ''
|
||||
details.push({ label: t('common.error'), value: `${errorMessage}${codeSuffix}`, status: hidErrorStatus })
|
||||
@@ -414,7 +397,7 @@ function translateAudioQuality(quality: string | undefined): string {
|
||||
if (qualityLower === 'voice') return t('actionbar.qualityVoice')
|
||||
if (qualityLower === 'balanced') return t('actionbar.qualityBalanced')
|
||||
if (qualityLower === 'high') return t('actionbar.qualityHigh')
|
||||
return quality // fallback to original value
|
||||
return quality
|
||||
}
|
||||
|
||||
const audioQuickInfo = computed(() => {
|
||||
@@ -466,7 +449,6 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
||||
|
||||
const details: StatusDetail[] = []
|
||||
|
||||
// 状态:待机 / 已连接
|
||||
if (msd.mode === 'none') {
|
||||
details.push({
|
||||
label: t('statusCard.msdStatus'),
|
||||
@@ -481,7 +463,6 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// 模式
|
||||
const modeDisplay = msd.mode === 'none'
|
||||
? '-'
|
||||
: msd.mode === 'image'
|
||||
@@ -493,7 +474,6 @@ const msdDetails = computed<StatusDetail[]>(() => {
|
||||
status: msd.mode !== 'none' ? 'ok' : undefined
|
||||
})
|
||||
|
||||
// 当前镜像(仅在 image 模式下显示)
|
||||
if (msd.mode === 'image') {
|
||||
details.push({
|
||||
label: t('statusCard.msdCurrentImage'),
|
||||
@@ -548,18 +528,16 @@ let retryCount = 0
|
||||
let gracePeriodTimeoutId: number | null = null
|
||||
let consecutiveErrors = 0
|
||||
const BASE_RETRY_DELAY = 2000
|
||||
const GRACE_PERIOD = 2000 // Ignore errors for 2s after config change (reduced from 3s)
|
||||
const MAX_CONSECUTIVE_ERRORS = 2 // If 2+ errors in grace period, it's a real problem
|
||||
const GRACE_PERIOD = 2000
|
||||
const MAX_CONSECUTIVE_ERRORS = 2
|
||||
let pendingWebRTCReadyGate = false
|
||||
let webrtcConnectTask: Promise<boolean> | null = null
|
||||
|
||||
// WebRTC auto-reconnect on device-lost/recovery
|
||||
let webrtcRecoveryTimerId: number | null = null
|
||||
let webrtcRecoveryAttempts = 0
|
||||
const MAX_WEBRTC_RECOVERY_ATTEMPTS = 8
|
||||
const WEBRTC_RECOVERY_BASE_DELAY = 2000
|
||||
|
||||
// Last-frame overlay (prevents black flash during mode switches)
|
||||
const frameOverlayUrl = ref<string | null>(null)
|
||||
|
||||
function clearFrameOverlay() {
|
||||
@@ -674,12 +652,9 @@ async function connectWebRTCSerial(reason: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
function handleVideoLoad() {
|
||||
// MJPEG video frame loaded successfully - update stream online status
|
||||
// This fixes the timing issue where device_info event may arrive before stream is fully active
|
||||
if (videoMode.value === 'mjpeg') {
|
||||
mjpegFrameReceived.value = true
|
||||
systemStore.setStreamOnline(true)
|
||||
// Update aspect ratio from MJPEG image dimensions
|
||||
const img = videoRef.value
|
||||
if (img && img.naturalWidth && img.naturalHeight) {
|
||||
videoAspectRatio.value = `${img.naturalWidth}/${img.naturalHeight}`
|
||||
@@ -687,11 +662,9 @@ function handleVideoLoad() {
|
||||
}
|
||||
|
||||
if (!videoLoading.value) {
|
||||
// 非首帧只做计数
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any pending retries and grace period
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -716,22 +689,18 @@ function handleVideoLoad() {
|
||||
}
|
||||
|
||||
function handleVideoError() {
|
||||
// 如果当前是 WebRTC 模式,忽略 MJPEG 错误(因为我们主动清空了 src)
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在切换模式,忽略错误(可能是 503 错误,因为后端已切换模式)
|
||||
if (isModeSwitching.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在刷新视频,忽略清空 src 时触发的错误
|
||||
if (isRefreshingVideo) {
|
||||
return
|
||||
}
|
||||
|
||||
// Expected <img> error while overlay shows no_signal / device_* — do not retry.
|
||||
if (streamSignalState.value !== 'ok') {
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
@@ -742,17 +711,14 @@ function handleVideoError() {
|
||||
return
|
||||
}
|
||||
|
||||
// Count consecutive errors even in grace period
|
||||
consecutiveErrors++
|
||||
|
||||
// If too many errors even in grace period, it's a real failure
|
||||
if (consecutiveErrors > MAX_CONSECUTIVE_ERRORS && gracePeriodTimeoutId !== null) {
|
||||
clearTimeout(gracePeriodTimeoutId)
|
||||
gracePeriodTimeoutId = null
|
||||
videoRestarting.value = false
|
||||
}
|
||||
|
||||
// If in grace period and not too many errors, ignore
|
||||
if (videoRestarting.value || gracePeriodTimeoutId !== null) {
|
||||
return
|
||||
}
|
||||
@@ -765,7 +731,6 @@ function handleVideoError() {
|
||||
videoLoading.value = true
|
||||
mjpegFrameReceived.value = false
|
||||
|
||||
// Auto-retry with exponential backoff (infinite retry, capped delay)
|
||||
retryCount++
|
||||
const delay = BASE_RETRY_DELAY * Math.pow(1.5, Math.min(retryCount - 1, 5))
|
||||
|
||||
@@ -775,14 +740,10 @@ function handleVideoError() {
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Stream device monitoring handlers (UI-only; notifications/state are handled by useConsoleEvents)
|
||||
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
|
||||
|
||||
// In WebRTC mode, the pipeline will attempt to restart itself.
|
||||
// Start an exponential-backoff reconnect loop so the session is
|
||||
// re-established automatically once the backend is ready again.
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
scheduleWebRTCRecovery()
|
||||
}
|
||||
@@ -813,7 +774,6 @@ function scheduleWebRTCRecovery() {
|
||||
webrtcRecoveryTimerId = null
|
||||
webrtcRecoveryAttempts++
|
||||
|
||||
// Only reconnect if we are still in a WebRTC mode and error state
|
||||
if (videoMode.value === 'mjpeg' || !videoError.value) {
|
||||
webrtcRecoveryAttempts = 0
|
||||
return
|
||||
@@ -846,7 +806,6 @@ function cancelWebRTCRecovery() {
|
||||
}
|
||||
|
||||
function handleStreamRecovered(_data: { device: string }) {
|
||||
// Cancel any pending recovery timer – backend is back
|
||||
cancelWebRTCRecovery()
|
||||
|
||||
videoError.value = false
|
||||
@@ -861,14 +820,10 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
|
||||
}
|
||||
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.isConnected.value) {
|
||||
// WebRTC mode: check if we have an audio track
|
||||
if (!webrtc.audioTrack.value) {
|
||||
// No audio track - need to reconnect WebRTC to get one
|
||||
// This happens when audio was enabled after WebRTC session was created
|
||||
await webrtc.disconnect()
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
await connectWebRTCSerial('audio track refresh')
|
||||
// After reconnect, the new session will have audio track
|
||||
} else {
|
||||
const currentStream = webrtcVideoRef.value?.srcObject as MediaStream | null
|
||||
if (currentStream && currentStream.getAudioTracks().length === 0) {
|
||||
@@ -877,14 +832,10 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
|
||||
}
|
||||
}
|
||||
|
||||
// Connect unified audio when streaming starts (works for both MJPEG and WebRTC modes)
|
||||
// In MJPEG mode, this connects the WebSocket audio player
|
||||
// In WebRTC mode, this unmutes the video element
|
||||
await unifiedAudio.connect()
|
||||
}
|
||||
|
||||
function handleStreamConfigChanging(data: any) {
|
||||
// Clear any existing retries and grace periods
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -901,7 +852,6 @@ function handleStreamConfigChanging(data: any) {
|
||||
retryCount = 0
|
||||
consecutiveErrors = 0
|
||||
|
||||
// Reset FPS when config changes (backend will send new FPS via WebSocket)
|
||||
backendFps.value = 0
|
||||
|
||||
toast.info(t('console.videoRestarting'), {
|
||||
@@ -913,26 +863,21 @@ function handleStreamConfigChanging(data: any) {
|
||||
async function handleStreamConfigApplied(data: any) {
|
||||
consecutiveErrors = 0
|
||||
|
||||
// Start grace period to ignore transient errors
|
||||
gracePeriodTimeoutId = window.setTimeout(() => {
|
||||
gracePeriodTimeoutId = null
|
||||
consecutiveErrors = 0 // Also reset when grace period ends
|
||||
consecutiveErrors = 0
|
||||
}, GRACE_PERIOD)
|
||||
|
||||
videoRestarting.value = true
|
||||
|
||||
// 如果正在进行模式切换,不需要在这里处理(WebRTCReady 事件会处理)
|
||||
if (isModeSwitching.value) {
|
||||
console.log('[StreamConfigApplied] Mode switch in progress, waiting for WebRTCReady')
|
||||
return
|
||||
}
|
||||
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
// In WebRTC mode, reconnect WebRTC (session was closed due to config change)
|
||||
// connectWebRTCSerial() will wait on stream.webrtc_ready when gate is enabled.
|
||||
await switchToWebRTC(videoMode.value)
|
||||
} else {
|
||||
// In MJPEG mode, refresh the MJPEG stream
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
@@ -944,7 +889,6 @@ async function handleStreamConfigApplied(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理 WebRTC 就绪事件 - 这是后端真正准备好接受 WebRTC 连接的信号
|
||||
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 || '-'}`)
|
||||
pendingWebRTCReadyGate = false
|
||||
@@ -1058,7 +1002,6 @@ const signalOverlayInfo = computed(() => {
|
||||
const reasonHintKey = reason ? `console.signal.reason.${reason}` : ''
|
||||
const hint = reasonHintKey && te(reasonHintKey) ? t(reasonHintKey) : ''
|
||||
|
||||
// UVC-specific overlay when we have the detailed reason
|
||||
if (streamSignalState.value === 'no_signal' && reason) {
|
||||
const titleKey = `console.signal.${reason}.title`
|
||||
const detailKey = `console.signal.${reason}.detail`
|
||||
@@ -1100,15 +1043,11 @@ const signalOverlayInfo = computed(() => {
|
||||
})
|
||||
|
||||
function handleStreamStatsUpdate(data: any) {
|
||||
// Always update clients count in store (for MJPEG mode display)
|
||||
if (typeof data.clients === 'number') {
|
||||
systemStore.updateStreamClients(data.clients)
|
||||
}
|
||||
|
||||
// Only update FPS from MJPEG stats when in MJPEG mode
|
||||
// In WebRTC mode, FPS is updated from WebRTC stats
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
// Still update clientsStats for display purposes, but don't touch backendFps
|
||||
if (data.clients_stat && typeof data.clients_stat === 'object') {
|
||||
clientsStats.value = data.clients_stat
|
||||
}
|
||||
@@ -1131,7 +1070,6 @@ function handleStreamStatsUpdate(data: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// Track if we've received the initial device_info
|
||||
let initialDeviceInfoReceived = false
|
||||
let initialModeRestoreDone = false
|
||||
let initialModeRestoreInProgress = false
|
||||
@@ -1214,7 +1152,7 @@ function handleStreamModeChanged(data: { mode: string; previous_mode: string })
|
||||
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
|
||||
@@ -1225,15 +1163,12 @@ function handleStreamModeChanged(data: { mode: string; previous_mode: string })
|
||||
duration: 5000,
|
||||
})
|
||||
|
||||
// Switch to new mode (external sync handled by device_info after mode_ready)
|
||||
if (newMode !== videoMode.value) {
|
||||
syncToServerMode(newMode)
|
||||
}
|
||||
}
|
||||
|
||||
// 标记是否正在刷新视频(用于忽略清空 src 时触发的 error 事件)
|
||||
let isRefreshingVideo = false
|
||||
// 标记是否正在切换模式(防止竞态条件和 503 错误)
|
||||
const isModeSwitching = videoSession.localSwitching
|
||||
|
||||
function reloadPage() {
|
||||
@@ -1246,15 +1181,12 @@ function refreshVideo() {
|
||||
videoErrorMessage.value = ''
|
||||
mjpegFrameReceived.value = false
|
||||
|
||||
// Update timestamp to force MJPEG reconnection via reactive URL
|
||||
isRefreshingVideo = true
|
||||
videoLoading.value = true
|
||||
mjpegTimestamp.value = Date.now()
|
||||
|
||||
// For MJPEG streams, the 'load' event fires when first frame arrives
|
||||
setTimeout(() => {
|
||||
isRefreshingVideo = false
|
||||
// Clear loading state after timeout - if stream failed, error handler will show error
|
||||
if (videoLoading.value) {
|
||||
videoLoading.value = false
|
||||
}
|
||||
@@ -1270,20 +1202,18 @@ function refreshVideo() {
|
||||
const mjpegTimestamp = ref(0)
|
||||
const mjpegUrl = computed(() => {
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
return '' // Don't load MJPEG when in H264 mode
|
||||
return ''
|
||||
}
|
||||
if (mjpegTimestamp.value === 0) {
|
||||
return '' // Don't load until refreshVideo() is called
|
||||
return ''
|
||||
}
|
||||
if (streamSignalState.value !== 'ok') {
|
||||
return '' // Backend is offline; let the overlay own the viewport
|
||||
return ''
|
||||
}
|
||||
return `${streamApi.getMjpegUrl(myClientId)}&t=${mjpegTimestamp.value}`
|
||||
})
|
||||
|
||||
// Connect to WebRTC without changing server mode (for new clients joining existing session)
|
||||
async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
// 清除 MJPEG 相关的定时器
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -1295,7 +1225,6 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
retryCount = 0
|
||||
consecutiveErrors = 0
|
||||
|
||||
// 停止 MJPEG 流 - 重置 timestamp 以停止请求
|
||||
mjpegTimestamp.value = 0
|
||||
if (videoRef.value) {
|
||||
videoRef.value.src = ''
|
||||
@@ -1314,8 +1243,8 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
duration: 3000,
|
||||
})
|
||||
|
||||
// 强制重新绑定视频(即使 track 已存在)
|
||||
// 这解决了页面返回时视频不显示的问题
|
||||
// Force video rebind even when the track already exists
|
||||
// This fixes missing video after returning to the page
|
||||
await rebindWebRTCVideo()
|
||||
|
||||
videoLoading.value = false
|
||||
@@ -1329,12 +1258,9 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
}
|
||||
}
|
||||
|
||||
// 强制重新绑定 WebRTC 视频到视频元素
|
||||
// 解决页面切换后视频不显示的问题
|
||||
async function rebindWebRTCVideo() {
|
||||
if (!webrtcVideoRef.value) return
|
||||
|
||||
// 先清空再重新绑定,确保浏览器重新渲染
|
||||
webrtcVideoRef.value.srcObject = null
|
||||
await nextTick()
|
||||
|
||||
@@ -1345,7 +1271,6 @@ async function rebindWebRTCVideo() {
|
||||
try {
|
||||
await webrtcVideoRef.value.play()
|
||||
} catch {
|
||||
// AbortError is expected when switching modes quickly, ignore it
|
||||
}
|
||||
await waitForVideoFirstFrame(webrtcVideoRef.value, 2000)
|
||||
clearFrameOverlay()
|
||||
@@ -1353,9 +1278,7 @@ async function rebindWebRTCVideo() {
|
||||
}
|
||||
}
|
||||
|
||||
// WebRTC video mode handling (switches server mode)
|
||||
async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
// 清除 MJPEG 相关的定时器,防止切换后重新加载 MJPEG
|
||||
if (retryTimeoutId !== null) {
|
||||
clearTimeout(retryTimeoutId)
|
||||
retryTimeoutId = null
|
||||
@@ -1367,7 +1290,6 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
retryCount = 0
|
||||
consecutiveErrors = 0
|
||||
|
||||
// 停止 MJPEG 流 - 重置 timestamp 以停止请求
|
||||
mjpegTimestamp.value = 0
|
||||
if (videoRef.value) {
|
||||
videoRef.value.src = ''
|
||||
@@ -1379,14 +1301,11 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
pendingWebRTCReadyGate = true
|
||||
|
||||
try {
|
||||
// Step 1: Disconnect existing WebRTC connection FIRST
|
||||
// This prevents ICE candidates from being sent to stale sessions
|
||||
// when backend closes sessions during codec switch
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Step 2: Call backend API to switch mode with specific codec
|
||||
const modeResp = await streamApi.setMode(codec)
|
||||
if (modeResp.transition_id) {
|
||||
videoSession.registerTransition(modeResp.transition_id)
|
||||
@@ -1407,7 +1326,6 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Connect WebRTC with retry (backoff between retries)
|
||||
const MAX_ATTEMPTS = 3
|
||||
const RETRY_DELAYS = [200, 800]
|
||||
let success = false
|
||||
@@ -1425,12 +1343,10 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
duration: 3000,
|
||||
})
|
||||
|
||||
// 强制重新绑定视频
|
||||
await rebindWebRTCVideo()
|
||||
|
||||
videoLoading.value = false
|
||||
|
||||
// Step 4: Switch audio to WebRTC mode
|
||||
unifiedAudio.switchMode('webrtc')
|
||||
} else {
|
||||
throw new Error('WebRTC connection failed')
|
||||
@@ -1446,8 +1362,6 @@ async function switchToMJPEG() {
|
||||
videoErrorMessage.value = ''
|
||||
pendingWebRTCReadyGate = false
|
||||
|
||||
// Step 1: Call backend API to switch mode FIRST
|
||||
// This ensures the MJPEG endpoint will accept our request
|
||||
try {
|
||||
const modeResp = await streamApi.setMode('mjpeg')
|
||||
if (modeResp.transition_id) {
|
||||
@@ -1461,20 +1375,16 @@ async function switchToMJPEG() {
|
||||
console.error('Failed to switch to MJPEG mode:', e)
|
||||
}
|
||||
|
||||
// Step 2: Disconnect WebRTC if connected or session still exists
|
||||
if (webrtc.isConnected.value || webrtc.sessionId.value) {
|
||||
await webrtc.disconnect()
|
||||
}
|
||||
|
||||
// Clear WebRTC video
|
||||
if (webrtcVideoRef.value) {
|
||||
webrtcVideoRef.value.srcObject = null
|
||||
}
|
||||
|
||||
// Step 3: Switch audio to WebSocket mode
|
||||
unifiedAudio.switchMode('ws')
|
||||
|
||||
// Refresh MJPEG stream
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
@@ -1493,7 +1403,6 @@ function syncToServerMode(mode: VideoMode) {
|
||||
}
|
||||
|
||||
async function handleVideoModeChange(mode: VideoMode) {
|
||||
// 防止重复切换和竞态条件
|
||||
if (mode === videoMode.value) return
|
||||
if (!videoSession.tryStartLocalSwitch()) {
|
||||
console.log('[VideoMode] Switch throttled or in progress, ignoring')
|
||||
@@ -1503,23 +1412,18 @@ async function handleVideoModeChange(mode: VideoMode) {
|
||||
try {
|
||||
await captureFrameOverlay()
|
||||
|
||||
// Reset mjpegTimestamp to 0 when switching away from MJPEG
|
||||
// This prevents mjpegUrl from returning a valid URL and stops MJPEG requests
|
||||
if (mode !== 'mjpeg') {
|
||||
mjpegTimestamp.value = 0
|
||||
// 完全清理 MJPEG 图片元素
|
||||
if (videoRef.value) {
|
||||
videoRef.value.src = ''
|
||||
videoRef.value.removeAttribute('src')
|
||||
}
|
||||
// 等待一小段时间确保浏览器取消 pending 请求
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
videoMode.value = mode
|
||||
localStorage.setItem('videoMode', mode)
|
||||
|
||||
// All WebRTC modes: h264, h265, vp8, vp9
|
||||
if (mode !== 'mjpeg') {
|
||||
await switchToWebRTC(mode)
|
||||
} else {
|
||||
@@ -1530,15 +1434,12 @@ async function handleVideoModeChange(mode: VideoMode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for WebRTC video track changes
|
||||
watch(() => webrtc.videoTrack.value, async (track) => {
|
||||
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
|
||||
// 使用统一的重新绑定函数
|
||||
await rebindWebRTCVideo()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for WebRTC audio track changes - update MediaStream when audio arrives
|
||||
watch(() => webrtc.audioTrack.value, async (track) => {
|
||||
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
|
||||
const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null
|
||||
@@ -1548,25 +1449,20 @@ watch(() => webrtc.audioTrack.value, async (track) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for WebRTC video element ref changes - set unified audio element
|
||||
watch(webrtcVideoRef, (el) => {
|
||||
unifiedAudio.setWebRTCElement(el)
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for WebRTC stats to update FPS display
|
||||
watch(webrtc.stats, (stats) => {
|
||||
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
||||
backendFps.value = Math.round(stats.framesPerSecond)
|
||||
// WebRTC is receiving frames, set stream online
|
||||
systemStore.setStreamOnline(true)
|
||||
// Update aspect ratio from WebRTC video dimensions
|
||||
if (stats.frameWidth && stats.frameHeight) {
|
||||
videoAspectRatio.value = `${stats.frameWidth}/${stats.frameHeight}`
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for WebRTC connection state changes - auto-reconnect on disconnect
|
||||
let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let webrtcReconnectFailures = 0
|
||||
watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
@@ -1588,8 +1484,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
videoLoading.value = false
|
||||
})
|
||||
}
|
||||
} else if (newState === 'disconnected' || newState === 'failed') {
|
||||
// The device_info event will eventually sync the correct state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1618,7 +1512,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// Handle direct 'failed' state (ICE or DTLS failure)
|
||||
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
|
||||
webrtcReconnectFailures += 1
|
||||
if (webrtcReconnectFailures >= 2) {
|
||||
@@ -1954,7 +1847,6 @@ function handleMouseMove(e: MouseEvent) {
|
||||
const { x, y } = absolutePosition
|
||||
|
||||
mousePosition.value = { x, y }
|
||||
// Queue for throttled sending (absolute mode: just update pending position)
|
||||
pendingMouseMove = { type: 'move_abs', x, y }
|
||||
requestMouseMoveFlush()
|
||||
} else {
|
||||
@@ -2323,7 +2215,6 @@ function handleToggleMouseMode() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 先订阅 WebSocket 事件,再连接(内部会 connect)
|
||||
consoleEvents.subscribe()
|
||||
|
||||
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
|
||||
@@ -2338,7 +2229,6 @@ onMounted(async () => {
|
||||
systemStore.updateHidWsConnection(connected, netError)
|
||||
}, { immediate: true })
|
||||
|
||||
// 4. 其他初始化
|
||||
await systemStore.startStream().catch(() => {})
|
||||
await systemStore.fetchAllStates()
|
||||
await configStore.refreshHid().then(() => {
|
||||
@@ -2357,8 +2247,6 @@ onMounted(async () => {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
// Note: Video mode is now synced from server via device_info event
|
||||
// The handleDeviceInfo function will automatically switch to the server's mode
|
||||
try {
|
||||
const modeResp = await streamApi.getMode()
|
||||
const serverMode = normalizeServerMode(modeResp?.mode)
|
||||
@@ -2381,7 +2269,6 @@ onDeactivated(() => {
|
||||
onUnmounted(() => {
|
||||
deactivateConsoleView()
|
||||
|
||||
// Reset initial device info flag
|
||||
initialDeviceInfoReceived = false
|
||||
initialModeRestoreDone = false
|
||||
initialModeRestoreInProgress = false
|
||||
@@ -2407,7 +2294,6 @@ onUnmounted(() => {
|
||||
consoleEvents.unsubscribe()
|
||||
consecutiveErrors = 0
|
||||
|
||||
// Disconnect WebRTC if connected or session still exists
|
||||
if (webrtc.isConnected.value || webrtc.sessionId.value) {
|
||||
void webrtc.disconnect()
|
||||
}
|
||||
@@ -2418,19 +2304,15 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="h-screen h-dvh flex flex-col bg-background">
|
||||
<!-- Header -->
|
||||
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="px-2 sm:px-4">
|
||||
<div class="h-10 sm:h-14 flex items-center justify-between">
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-2 sm:gap-6">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2">
|
||||
<BrandMark size="md" class="hidden sm:block" />
|
||||
<BrandMark size="sm" class="sm:hidden" />
|
||||
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Status Indicators (inline, minimal) -->
|
||||
<div class="flex md:hidden items-center gap-1">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
@@ -2453,11 +2335,8 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Status Cards + User Menu -->
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<!-- Video Status -->
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
@@ -2466,8 +2345,6 @@ onUnmounted(() => {
|
||||
:error-message="videoErrorMessage"
|
||||
:details="videoDetails"
|
||||
/>
|
||||
|
||||
<!-- Audio Status -->
|
||||
<StatusCard
|
||||
v-if="systemStore.audio?.available"
|
||||
:title="t('statusCard.audio')"
|
||||
@@ -2477,8 +2354,6 @@ onUnmounted(() => {
|
||||
:error-message="audioErrorMessage"
|
||||
:details="audioDetails"
|
||||
/>
|
||||
|
||||
<!-- HID Status -->
|
||||
<StatusCard
|
||||
:title="t('statusCard.hid')"
|
||||
type="hid"
|
||||
@@ -2488,8 +2363,6 @@ onUnmounted(() => {
|
||||
:details="hidDetails"
|
||||
:hover-align="hidHoverAlign"
|
||||
/>
|
||||
|
||||
<!-- MSD Status - Hidden when CH9329 backend (no USB gadget support) -->
|
||||
<StatusCard
|
||||
v-if="showMsdStatusCard"
|
||||
:title="t('statusCard.msd')"
|
||||
@@ -2501,20 +2374,12 @@ onUnmounted(() => {
|
||||
hover-align="end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
|
||||
<Sun v-if="isDark" class="h-4 w-4" />
|
||||
<Moon v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<LanguageToggleButton class="h-8 w-8 hidden md:flex" />
|
||||
|
||||
<!-- User Menu -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 h-7 sm:h-9 px-2 sm:px-3">
|
||||
@@ -2552,8 +2417,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ActionBar -->
|
||||
<ActionBar
|
||||
:mouse-mode="mouseMode"
|
||||
:video-mode="videoMode"
|
||||
@@ -2569,10 +2432,7 @@ onUnmounted(() => {
|
||||
@wol="handleWol"
|
||||
@open-terminal="openTerminal"
|
||||
/>
|
||||
|
||||
<!-- Main Video Area -->
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<!-- Dot Pattern Background -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-100/80 dark:bg-slate-800/40 opacity-80"
|
||||
style="
|
||||
@@ -2580,8 +2440,6 @@ onUnmounted(() => {
|
||||
background-size: 20px 20px;
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Video Container -->
|
||||
<div class="relative h-full w-full flex items-center justify-center p-1 sm:p-4">
|
||||
<div
|
||||
ref="videoContainerRef"
|
||||
@@ -2604,7 +2462,6 @@ onUnmounted(() => {
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
>
|
||||
<!-- MJPEG Stream -->
|
||||
<img
|
||||
v-show="videoMode === 'mjpeg'"
|
||||
ref="videoRef"
|
||||
@@ -2614,9 +2471,6 @@ onUnmounted(() => {
|
||||
@load="handleVideoLoad"
|
||||
@error="handleVideoError"
|
||||
/>
|
||||
|
||||
<!-- WebRTC Stream (H.264/H.265/VP8/VP9) -->
|
||||
<!-- Note: muted is controlled by unifiedAudio, not hardcoded -->
|
||||
<video
|
||||
v-show="videoMode !== 'mjpeg'"
|
||||
ref="webrtcVideoRef"
|
||||
@@ -2624,16 +2478,12 @@ onUnmounted(() => {
|
||||
autoplay
|
||||
playsinline
|
||||
/>
|
||||
|
||||
<!-- Last-frame overlay (reduces black flash when switching modes) -->
|
||||
<img
|
||||
v-if="frameOverlayUrl"
|
||||
:src="frameOverlayUrl"
|
||||
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<!-- Stroked crosshair (native cursor cannot be outlined) -->
|
||||
<div
|
||||
v-if="cursorVisible && localCrosshairPos"
|
||||
class="pointer-events-none absolute z-[15] -translate-x-1/2 -translate-y-1/2"
|
||||
@@ -2685,14 +2535,11 @@ onUnmounted(() => {
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay with smooth transition and visual feedback -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="videoLoading"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300"
|
||||
>
|
||||
<!-- Animated scan line for visual feedback -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
|
||||
</div>
|
||||
@@ -2706,16 +2553,6 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!--
|
||||
Canonical 4-state signal overlay (no_signal / device_lost /
|
||||
device_busy). Fully covers the video area with a solid dim
|
||||
backdrop so the browser never shows a frozen last frame or a
|
||||
transparent video element peeking through — the MJPEG `<img>`
|
||||
has its `src` cleared the moment the backend goes offline and
|
||||
the WebRTC track is simply obscured. Sits below the loading /
|
||||
error overlays so those take precedence when both apply.
|
||||
-->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSignalOverlay && !videoLoading && !videoError"
|
||||
@@ -2750,8 +2587,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Error Overlay with smooth transition and detailed info -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="videoError && !videoLoading"
|
||||
@@ -2761,7 +2596,6 @@ onUnmounted(() => {
|
||||
<div class="text-center max-w-md px-2">
|
||||
<p class="font-medium text-sm sm:text-lg mb-1 sm:mb-2">{{ t('console.connectionFailed') }}</p>
|
||||
<p class="text-xs sm:text-sm text-slate-300 mb-2 sm:mb-3">{{ t('console.connectionFailedDesc') }}</p>
|
||||
<!-- Expandable error details -->
|
||||
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
|
||||
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
|
||||
<p class="text-sm text-slate-300 font-mono break-all">{{ videoErrorMessage }}</p>
|
||||
@@ -2778,8 +2612,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Keyboard - Above InfoBar when attached, or in body when floating -->
|
||||
<Teleport :to="virtualKeyboardAttached ? '#keyboard-anchor' : 'body'" :disabled="virtualKeyboardAttached">
|
||||
<VirtualKeyboard
|
||||
v-if="virtualKeyboardVisible"
|
||||
@@ -2792,11 +2624,7 @@ onUnmounted(() => {
|
||||
@key-up="handleVirtualKeyUp"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- Anchor for attached keyboard -->
|
||||
<div id="keyboard-anchor"></div>
|
||||
|
||||
<!-- InfoBar (Status Bar) -->
|
||||
<InfoBar
|
||||
:pressed-keys="pressedKeys"
|
||||
:caps-lock="keyboardLed.capsLock"
|
||||
@@ -2806,8 +2634,6 @@ onUnmounted(() => {
|
||||
:mouse-position="mousePosition"
|
||||
:debug-mode="false"
|
||||
/>
|
||||
|
||||
<!-- Stats Sheet -->
|
||||
<StatsSheet
|
||||
v-model:open="statsSheetOpen"
|
||||
:video-mode="videoMode"
|
||||
@@ -2815,8 +2641,6 @@ onUnmounted(() => {
|
||||
:ws-latency="0"
|
||||
:webrtc-stats="webrtc.stats.value"
|
||||
/>
|
||||
|
||||
<!-- Terminal Dialog -->
|
||||
<Dialog v-model:open="showTerminalDialog">
|
||||
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||
@@ -2848,8 +2672,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Change Password Dialog -->
|
||||
<Dialog v-model:open="changePasswordDialogOpen">
|
||||
<DialogContent class="w-[95vw] max-w-md">
|
||||
<DialogHeader>
|
||||
@@ -2900,7 +2722,6 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Smooth fade transition for video overlays */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
ScreenShare,
|
||||
Radio,
|
||||
Globe,
|
||||
Loader2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
@@ -3393,7 +3394,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="loading" @click="saveAtxConfig">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3486,7 +3487,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<!-- Save button -->
|
||||
<div v-if="extensions?.ttyd?.available" class="flex justify-end">
|
||||
<Button :disabled="loading || isExtRunning(extensions?.ttyd?.status)" @click="saveExtensionConfig('ttyd')">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3580,7 +3581,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<!-- Save button -->
|
||||
<div v-if="extensions?.gostc?.available" class="flex justify-end">
|
||||
<Button :disabled="loading || isExtRunning(extensions?.gostc?.status)" @click="saveExtensionConfig('gostc')">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3687,7 +3688,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<!-- Save button -->
|
||||
<div v-if="extensions?.easytier?.available" class="flex justify-end">
|
||||
<Button :disabled="loading || isExtRunning(extensions?.easytier?.status)" @click="saveExtensionConfig('easytier')">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3802,7 +3803,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
</Card>
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="loading || rtspLoading" @click="saveRtspConfig">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3971,7 +3972,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
<!-- Save button -->
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="loading" @click="saveRustdeskConfig">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4114,7 +4115,7 @@ watch(() => route.query.tab, (tab) => {
|
||||
{{ t('settings.otgFunctionMinWarning') }}
|
||||
</p>
|
||||
<Button class="shrink-0" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user