mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-15 07:26:44 +08:00
fix: 修复 rtsp 服务连接错误
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
use bytes::Bytes;
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use rand::Rng;
|
||||
use rtp::packet::Packet;
|
||||
use rtp::packetizer::Payloader;
|
||||
use rtsp_types as rtsp;
|
||||
use sdp_types as sdp;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
@@ -10,9 +12,8 @@ use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio::time::{sleep, Duration};
|
||||
use webrtc::util::Marshal;
|
||||
use rtsp_types as rtsp;
|
||||
use sdp_types as sdp;
|
||||
|
||||
use crate::config::{RtspCodec, RtspConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
@@ -26,6 +27,7 @@ use crate::webrtc::rtp::parse_profile_level_id_from_sps;
|
||||
const RTP_CLOCK_RATE: u32 = 90_000;
|
||||
const RTP_MTU: usize = 1200;
|
||||
const RTSP_BUF_SIZE: usize = 8192;
|
||||
const RTSP_RESUBSCRIBE_DELAY_MS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RtspServiceStatus {
|
||||
@@ -150,9 +152,9 @@ impl RtspService {
|
||||
.parse()
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid RTSP bind address: {}", e)))?;
|
||||
|
||||
let listener = TcpListener::bind(bind_addr)
|
||||
.await
|
||||
.map_err(|e| AppError::Io(io::Error::new(e.kind(), format!("RTSP bind failed: {}", e))))?;
|
||||
let listener = TcpListener::bind(bind_addr).await.map_err(|e| {
|
||||
AppError::Io(io::Error::new(e.kind(), format!("RTSP bind failed: {}", e)))
|
||||
})?;
|
||||
|
||||
let service_config = self.config.clone();
|
||||
let video_manager = self.video_manager.clone();
|
||||
@@ -245,8 +247,14 @@ async fn handle_client(
|
||||
) -> Result<()> {
|
||||
let cfg_snapshot = config.read().await.clone();
|
||||
|
||||
let auth_enabled = cfg_snapshot.username.as_ref().is_some_and(|u| !u.is_empty())
|
||||
|| cfg_snapshot.password.as_ref().is_some_and(|p| !p.is_empty());
|
||||
let auth_enabled = cfg_snapshot
|
||||
.username
|
||||
.as_ref()
|
||||
.is_some_and(|u| !u.is_empty())
|
||||
|| cfg_snapshot
|
||||
.password
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.is_empty());
|
||||
|
||||
if cfg_snapshot.allow_one_client {
|
||||
let mut active_guard = shared.active_client.lock().await;
|
||||
@@ -288,17 +296,8 @@ async fn handle_client(
|
||||
}
|
||||
};
|
||||
|
||||
if !is_valid_rtsp_path(&req.uri, &cfg_snapshot.path) {
|
||||
send_response(
|
||||
&mut stream,
|
||||
&req,
|
||||
404,
|
||||
"Not Found",
|
||||
vec![],
|
||||
"",
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
if !is_valid_rtsp_path(&req.method, &req.uri, &cfg_snapshot.path) {
|
||||
send_response(&mut stream, &req, 404, "Not Found", vec![], "", "").await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -368,21 +367,31 @@ async fn handle_client(
|
||||
&req,
|
||||
200,
|
||||
"OK",
|
||||
vec![(
|
||||
"Content-Type".to_string(),
|
||||
"application/sdp".to_string(),
|
||||
)],
|
||||
vec![("Content-Type".to_string(), "application/sdp".to_string())],
|
||||
&sdp,
|
||||
&state.session_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
rtsp::Method::Setup => {
|
||||
let transport = req
|
||||
.headers
|
||||
.get("transport")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let transport = req.headers.get("transport").cloned().unwrap_or_default();
|
||||
|
||||
if !is_tcp_transport_request(&transport) {
|
||||
send_response(
|
||||
&mut stream,
|
||||
&req,
|
||||
461,
|
||||
"Unsupported Transport",
|
||||
vec![(
|
||||
"Transport".to_string(),
|
||||
"RTP/AVP/TCP;unicast;interleaved=0-1".to_string(),
|
||||
)],
|
||||
"",
|
||||
&state.session_id,
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let interleaved = parse_interleaved_channel(&transport).unwrap_or(0);
|
||||
state.setup_done = true;
|
||||
@@ -420,16 +429,8 @@ async fn handle_client(
|
||||
continue;
|
||||
}
|
||||
|
||||
send_response(
|
||||
&mut stream,
|
||||
&req,
|
||||
200,
|
||||
"OK",
|
||||
vec![],
|
||||
"",
|
||||
&state.session_id,
|
||||
)
|
||||
.await?;
|
||||
send_response(&mut stream, &req, 200, "OK", vec![], "", &state.session_id)
|
||||
.await?;
|
||||
|
||||
if let Err(e) = stream_video_interleaved(
|
||||
stream,
|
||||
@@ -447,16 +448,8 @@ async fn handle_client(
|
||||
break 'client_loop;
|
||||
}
|
||||
rtsp::Method::Teardown => {
|
||||
send_response(
|
||||
&mut stream,
|
||||
&req,
|
||||
200,
|
||||
"OK",
|
||||
vec![],
|
||||
"",
|
||||
&state.session_id,
|
||||
)
|
||||
.await?;
|
||||
send_response(&mut stream, &req, 200, "OK", vec![], "", &state.session_id)
|
||||
.await?;
|
||||
break 'client_loop;
|
||||
}
|
||||
_ => {
|
||||
@@ -498,7 +491,9 @@ async fn stream_video_interleaved(
|
||||
let mut rx = video_manager
|
||||
.subscribe_encoded_frames()
|
||||
.await
|
||||
.ok_or_else(|| AppError::VideoError("RTSP failed to subscribe encoded frames".to_string()))?;
|
||||
.ok_or_else(|| {
|
||||
AppError::VideoError("RTSP failed to subscribe encoded frames".to_string())
|
||||
})?;
|
||||
|
||||
video_manager.request_keyframe().await.ok();
|
||||
|
||||
@@ -518,7 +513,21 @@ async fn stream_video_interleaved(
|
||||
tokio::select! {
|
||||
maybe_frame = rx.recv() => {
|
||||
let Some(frame) = maybe_frame else {
|
||||
break;
|
||||
tracing::warn!("RTSP encoded frame subscription ended, attempting to restart pipeline");
|
||||
|
||||
if let Some(new_rx) = video_manager.subscribe_encoded_frames().await {
|
||||
rx = new_rx;
|
||||
let _ = video_manager.request_keyframe().await;
|
||||
tracing::info!("RTSP frame subscription recovered");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"RTSP failed to resubscribe encoded frames, retrying in {}ms",
|
||||
RTSP_RESUBSCRIBE_DELAY_MS
|
||||
);
|
||||
sleep(Duration::from_millis(RTSP_RESUBSCRIBE_DELAY_MS)).await;
|
||||
}
|
||||
|
||||
continue;
|
||||
};
|
||||
|
||||
if !is_frame_codec_match(&frame, &rtsp_codec) {
|
||||
@@ -689,11 +698,14 @@ fn take_rtsp_request_from_buffer(buffer: &mut Vec<u8>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|window| window == needle)
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
fn parse_rtsp_request(raw: &str) -> Option<RtspRequest> {
|
||||
let (message, consumed): (rtsp::Message<Vec<u8>>, usize) = rtsp::Message::parse(raw.as_bytes()).ok()?;
|
||||
let (message, consumed): (rtsp::Message<Vec<u8>>, usize) =
|
||||
rtsp::Message::parse(raw.as_bytes()).ok()?;
|
||||
if consumed != raw.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -745,6 +757,14 @@ fn parse_interleaved_channel(transport: &str) -> Option<u8> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_tcp_transport_request(transport: &str) -> bool {
|
||||
transport
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.any(|item| item.contains("rtp/avp/tcp") || item.contains("interleaved="))
|
||||
}
|
||||
|
||||
fn update_parameter_sets(params: &mut ParameterSets, frame: &EncodedVideoFrame) {
|
||||
let nal_units = split_annexb_nal_units(frame.data.as_ref());
|
||||
|
||||
@@ -1036,18 +1056,33 @@ fn status_code_from_u16(code: u16) -> rtsp::StatusCode {
|
||||
405 => rtsp::StatusCode::MethodNotAllowed,
|
||||
453 => rtsp::StatusCode::NotEnoughBandwidth,
|
||||
455 => rtsp::StatusCode::MethodNotValidInThisState,
|
||||
461 => rtsp::StatusCode::UnsupportedTransport,
|
||||
_ => rtsp::StatusCode::InternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_rtsp_path(uri: &str, configured_path: &str) -> bool {
|
||||
fn is_valid_rtsp_path(method: &rtsp::Method, uri: &str, configured_path: &str) -> bool {
|
||||
if matches!(method, rtsp::Method::Options) && uri.trim() == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let normalized_cfg = configured_path.trim_matches('/');
|
||||
if normalized_cfg.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let request_path = extract_rtsp_path(uri);
|
||||
request_path == normalized_cfg
|
||||
|
||||
if request_path == normalized_cfg {
|
||||
return true;
|
||||
}
|
||||
|
||||
if !matches!(method, rtsp::Method::Setup | rtsp::Method::Teardown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let control_track_path = format!("{}/trackID=0", normalized_cfg);
|
||||
request_path == "trackID=0" || request_path == control_track_path
|
||||
}
|
||||
|
||||
fn extract_rtsp_path(uri: &str) -> String {
|
||||
@@ -1074,8 +1109,13 @@ fn extract_rtsp_path(uri: &str) -> String {
|
||||
fn is_frame_codec_match(frame: &EncodedVideoFrame, codec: &RtspCodec) -> bool {
|
||||
matches!(
|
||||
(frame.codec, codec),
|
||||
(crate::video::encoder::registry::VideoEncoderType::H264, RtspCodec::H264)
|
||||
| (crate::video::encoder::registry::VideoEncoderType::H265, RtspCodec::H265)
|
||||
(
|
||||
crate::video::encoder::registry::VideoEncoderType::H264,
|
||||
RtspCodec::H264
|
||||
) | (
|
||||
crate::video::encoder::registry::VideoEncoderType::H265,
|
||||
RtspCodec::H265
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1092,7 +1132,6 @@ fn generate_session_id() -> String {
|
||||
format!("{:016x}", value)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1109,9 +1148,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_response_from_duplex(mut client: tokio::io::DuplexStream) -> rtsp::Response<Vec<u8>> {
|
||||
async fn read_response_from_duplex(
|
||||
mut client: tokio::io::DuplexStream,
|
||||
) -> rtsp::Response<Vec<u8>> {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = client.read(&mut buf).await.expect("failed to read rtsp response");
|
||||
let n = client
|
||||
.read(&mut buf)
|
||||
.await
|
||||
.expect("failed to read rtsp response");
|
||||
assert!(n > 0);
|
||||
let (message, consumed): (rtsp::Message<Vec<u8>>, usize) =
|
||||
rtsp::Message::parse(&buf[..n]).expect("failed to parse rtsp response");
|
||||
@@ -1188,13 +1232,57 @@ mod tests {
|
||||
assert!(fmtp_value.contains("sprop-parameter-sets="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtsp_path_matching_follows_sdp_control_rules() {
|
||||
assert!(is_valid_rtsp_path(
|
||||
&rtsp::Method::Describe,
|
||||
"rtsp://127.0.0.1/live",
|
||||
"live"
|
||||
));
|
||||
assert!(is_valid_rtsp_path(
|
||||
&rtsp::Method::Describe,
|
||||
"rtsp://127.0.0.1/live/?token=1",
|
||||
"/live/"
|
||||
));
|
||||
assert!(!is_valid_rtsp_path(
|
||||
&rtsp::Method::Describe,
|
||||
"rtsp://127.0.0.1/live2",
|
||||
"live"
|
||||
));
|
||||
assert!(!is_valid_rtsp_path(
|
||||
&rtsp::Method::Describe,
|
||||
"rtsp://127.0.0.1/",
|
||||
"/"
|
||||
));
|
||||
|
||||
assert!(is_valid_rtsp_path(
|
||||
&rtsp::Method::Setup,
|
||||
"rtsp://127.0.0.1/live/trackID=0",
|
||||
"live"
|
||||
));
|
||||
assert!(is_valid_rtsp_path(
|
||||
&rtsp::Method::Setup,
|
||||
"rtsp://127.0.0.1/trackID=0",
|
||||
"live"
|
||||
));
|
||||
assert!(!is_valid_rtsp_path(
|
||||
&rtsp::Method::Describe,
|
||||
"rtsp://127.0.0.1/live/trackID=0",
|
||||
"live"
|
||||
));
|
||||
|
||||
assert!(is_valid_rtsp_path(&rtsp::Method::Options, "*", "live"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtsp_path_matching_is_exact_after_normalization() {
|
||||
assert!(is_valid_rtsp_path("rtsp://127.0.0.1/live", "live"));
|
||||
assert!(is_valid_rtsp_path("rtsp://127.0.0.1/live/?token=1", "/live/"));
|
||||
assert!(!is_valid_rtsp_path("rtsp://127.0.0.1/live2", "live"));
|
||||
assert!(!is_valid_rtsp_path("rtsp://127.0.0.1/", "/"));
|
||||
fn transport_parsing_detects_tcp_interleaved_requests() {
|
||||
assert!(is_tcp_transport_request(
|
||||
"RTP/AVP/TCP;unicast;interleaved=0-1"
|
||||
));
|
||||
assert!(is_tcp_transport_request("RTP/AVP;unicast;interleaved=2-3"));
|
||||
assert!(!is_tcp_transport_request(
|
||||
"RTP/AVP;unicast;client_port=8000-8001"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,6 +27,8 @@ use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
|
||||
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
|
||||
/// Restart capture stream after this many consecutive timeouts.
|
||||
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
|
||||
/// Minimum valid frame size for capture
|
||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
||||
/// Validate JPEG header every N frames to reduce overhead
|
||||
@@ -1319,6 +1321,7 @@ impl SharedVideoPipeline {
|
||||
let grace_period = Duration::from_secs(AUTO_STOP_GRACE_PERIOD_SECS);
|
||||
let mut sequence: u64 = 0;
|
||||
let mut validate_counter: u64 = 0;
|
||||
let mut consecutive_timeouts: u32 = 0;
|
||||
let capture_error_throttler = LogThrottler::with_secs(5);
|
||||
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
@@ -1363,11 +1366,27 @@ impl SharedVideoPipeline {
|
||||
|
||||
let mut owned = buffer_pool.take(MIN_CAPTURE_FRAME_SIZE);
|
||||
let meta = match stream.next_into(&mut owned) {
|
||||
Ok(meta) => meta,
|
||||
Ok(meta) => {
|
||||
consecutive_timeouts = 0;
|
||||
meta
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::TimedOut {
|
||||
consecutive_timeouts = consecutive_timeouts.saturating_add(1);
|
||||
warn!("Capture timeout - no signal?");
|
||||
|
||||
if consecutive_timeouts >= CAPTURE_TIMEOUT_RESTART_THRESHOLD {
|
||||
warn!(
|
||||
"Capture timed out {} consecutive times, restarting video pipeline",
|
||||
consecutive_timeouts
|
||||
);
|
||||
let _ = pipeline.running.send(false);
|
||||
pipeline.running_flag.store(false, Ordering::Release);
|
||||
let _ = frame_seq_tx.send(sequence.wrapping_add(1));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
consecutive_timeouts = 0;
|
||||
let key = classify_capture_error(&e);
|
||||
if capture_error_throttler.should_log(&key) {
|
||||
let suppressed =
|
||||
|
||||
@@ -250,6 +250,33 @@ impl WebRtcStreamer {
|
||||
}
|
||||
}
|
||||
|
||||
fn should_stop_pipeline(session_count: usize, subscriber_count: usize) -> bool {
|
||||
session_count == 0 && subscriber_count == 0
|
||||
}
|
||||
|
||||
async fn stop_pipeline_if_idle(&self, reason: &str) {
|
||||
let session_count = self.sessions.read().await.len();
|
||||
let pipeline = self.video_pipeline.read().await.clone();
|
||||
|
||||
let Some(pipeline) = pipeline else {
|
||||
return;
|
||||
};
|
||||
|
||||
let subscriber_count = pipeline.subscriber_count();
|
||||
if Self::should_stop_pipeline(session_count, subscriber_count) {
|
||||
info!(
|
||||
"{} stopping video pipeline (sessions={}, subscribers={})",
|
||||
reason, session_count, subscriber_count
|
||||
);
|
||||
pipeline.stop();
|
||||
} else {
|
||||
debug!(
|
||||
"Keeping video pipeline alive (reason={}, sessions={}, subscribers={})",
|
||||
reason, session_count, subscriber_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure video pipeline is initialized and running
|
||||
async fn ensure_video_pipeline(self: &Arc<Self>) -> Result<Arc<SharedVideoPipeline>> {
|
||||
let mut pipeline_guard = self.video_pipeline.write().await;
|
||||
@@ -740,13 +767,7 @@ impl WebRtcStreamer {
|
||||
session.close().await?;
|
||||
}
|
||||
|
||||
// Stop pipeline if no more sessions
|
||||
if self.sessions.read().await.is_empty() {
|
||||
if let Some(ref pipeline) = *self.video_pipeline.read().await {
|
||||
info!("No more sessions, stopping video pipeline");
|
||||
pipeline.stop();
|
||||
}
|
||||
}
|
||||
self.stop_pipeline_if_idle("After close_session").await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -763,11 +784,8 @@ impl WebRtcStreamer {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop pipeline
|
||||
drop(sessions);
|
||||
if let Some(ref pipeline) = *self.video_pipeline.read().await {
|
||||
pipeline.stop();
|
||||
}
|
||||
self.stop_pipeline_if_idle("After close_all_sessions").await;
|
||||
|
||||
count
|
||||
}
|
||||
@@ -826,14 +844,9 @@ impl WebRtcStreamer {
|
||||
sessions.remove(id);
|
||||
}
|
||||
|
||||
// Stop pipeline if no more sessions
|
||||
if sessions.is_empty() {
|
||||
drop(sessions);
|
||||
if let Some(ref pipeline) = *self.video_pipeline.read().await {
|
||||
info!("No more sessions after cleanup, stopping video pipeline");
|
||||
pipeline.stop();
|
||||
}
|
||||
}
|
||||
drop(sessions);
|
||||
self.stop_pipeline_if_idle("After cleanup_closed_sessions")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,4 +1003,12 @@ mod tests {
|
||||
let codecs = streamer.supported_video_codecs();
|
||||
assert!(codecs.contains(&VideoCodecType::H264));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_pipeline_requires_no_sessions_and_no_subscribers() {
|
||||
assert!(WebRtcStreamer::should_stop_pipeline(0, 0));
|
||||
assert!(!WebRtcStreamer::should_stop_pipeline(1, 0));
|
||||
assert!(!WebRtcStreamer::should_stop_pipeline(0, 1));
|
||||
assert!(!WebRtcStreamer::should_stop_pipeline(2, 3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next'
|
||||
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image, AlertTriangle } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import {
|
||||
configApi,
|
||||
@@ -73,6 +73,48 @@ const loadingCodecs = ref(false)
|
||||
const backends = ref<EncoderBackendInfo[]>([])
|
||||
const constraints = ref<StreamConstraintsResponse | null>(null)
|
||||
const currentEncoderBackend = computed(() => configStore.stream?.encoder || 'auto')
|
||||
const isRtspEnabled = computed(() => {
|
||||
if (typeof configStore.rtspStatus?.config?.enabled === 'boolean') {
|
||||
return configStore.rtspStatus.config.enabled
|
||||
}
|
||||
return !!configStore.rtspConfig?.enabled
|
||||
})
|
||||
const isRustdeskEnabled = computed(() => {
|
||||
if (typeof configStore.rustdeskStatus?.config?.enabled === 'boolean') {
|
||||
return configStore.rustdeskStatus.config.enabled
|
||||
}
|
||||
return !!configStore.rustdeskConfig?.enabled
|
||||
})
|
||||
const isRtspCodecLocked = computed(() => isRtspEnabled.value)
|
||||
const isRustdeskWebrtcLocked = computed(() => !isRtspEnabled.value && isRustdeskEnabled.value)
|
||||
const codecLockSources = computed(() => {
|
||||
if (isRtspCodecLocked.value) {
|
||||
return isRustdeskEnabled.value ? 'RTSP/RustDesk' : 'RTSP'
|
||||
}
|
||||
if (isRustdeskWebrtcLocked.value) return 'RustDesk'
|
||||
return ''
|
||||
})
|
||||
const codecLockMessage = computed(() => {
|
||||
if (!codecLockSources.value) return ''
|
||||
return t('actionbar.multiSourceCodecLocked', { sources: codecLockSources.value })
|
||||
})
|
||||
const videoParamWarningSources = computed(() => {
|
||||
if (isRtspEnabled.value && isRustdeskEnabled.value) return 'RTSP/RustDesk'
|
||||
if (isRtspEnabled.value) return 'RTSP'
|
||||
if (isRustdeskEnabled.value) return 'RustDesk'
|
||||
return ''
|
||||
})
|
||||
const videoParamWarningMessage = computed(() => {
|
||||
if (!videoParamWarningSources.value) return ''
|
||||
return t('actionbar.multiSourceVideoParamsWarning', { sources: videoParamWarningSources.value })
|
||||
})
|
||||
const isCodecLocked = computed(() => !!codecLockMessage.value)
|
||||
|
||||
const isCodecOptionDisabled = (codecId: string): boolean => {
|
||||
if (!isBrowserSupported(codecId)) return true
|
||||
if (isRustdeskWebrtcLocked.value && codecId === 'mjpeg') return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Browser supported codecs (WebRTC receive capabilities)
|
||||
const browserSupportedCodecs = ref<Set<string>>(new Set())
|
||||
@@ -363,6 +405,16 @@ function syncFromCurrentIfChanged() {
|
||||
function handleVideoModeChange(mode: unknown) {
|
||||
if (typeof mode !== 'string') return
|
||||
|
||||
if (isRtspCodecLocked.value) {
|
||||
toast.warning(codecLockMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (isRustdeskWebrtcLocked.value && mode === 'mjpeg') {
|
||||
toast.warning(codecLockMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (constraints.value?.allowed_codecs?.length && !constraints.value.allowed_codecs.includes(mode)) {
|
||||
toast.error(constraints.value.reason || t('actionbar.selectMode'))
|
||||
return
|
||||
@@ -500,6 +552,8 @@ watch(() => props.open, (isOpen) => {
|
||||
Promise.all([
|
||||
configStore.refreshVideo(),
|
||||
configStore.refreshStream(),
|
||||
configStore.refreshRtspStatus(),
|
||||
configStore.refreshRustdeskStatus(),
|
||||
]).then(() => {
|
||||
initializeFromCurrent()
|
||||
}).catch(() => {
|
||||
@@ -539,7 +593,7 @@ watch(currentConfig, () => {
|
||||
<Select
|
||||
:model-value="props.videoMode"
|
||||
@update:model-value="handleVideoModeChange"
|
||||
:disabled="loadingCodecs || availableCodecs.length === 0"
|
||||
:disabled="loadingCodecs || availableCodecs.length === 0 || isRtspCodecLocked"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div v-if="selectedCodecInfo" class="flex items-center gap-1.5 truncate">
|
||||
@@ -561,8 +615,8 @@ watch(currentConfig, () => {
|
||||
v-for="codec in availableCodecs"
|
||||
:key="codec.id"
|
||||
:value="codec.id"
|
||||
:disabled="!isBrowserSupported(codec.id)"
|
||||
:class="['text-xs', { 'opacity-50': !isBrowserSupported(codec.id) }]"
|
||||
:disabled="isCodecOptionDisabled(codec.id)"
|
||||
:class="['text-xs', { 'opacity-50': isCodecOptionDisabled(codec.id) }]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ codec.name }}</span>
|
||||
@@ -589,6 +643,9 @@ watch(currentConfig, () => {
|
||||
<p v-if="props.videoMode !== 'mjpeg'" class="text-xs text-muted-foreground">
|
||||
{{ t('actionbar.webrtcHint') }}
|
||||
</p>
|
||||
<p v-if="isCodecLocked" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ codecLockMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bitrate Preset - Only shown for WebRTC modes -->
|
||||
@@ -655,6 +712,16 @@ watch(currentConfig, () => {
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="videoParamWarningMessage"
|
||||
class="rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-2"
|
||||
>
|
||||
<p class="flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle class="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{{ videoParamWarningMessage }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
|
||||
<Button
|
||||
|
||||
@@ -134,6 +134,8 @@ export default {
|
||||
backendAuto: 'Auto',
|
||||
recommended: 'Recommended',
|
||||
notRecommended: 'Not Recommended',
|
||||
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
|
||||
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
|
||||
// HID Config
|
||||
hidConfig: 'Mouse & HID',
|
||||
mouseSettings: 'Mouse Settings',
|
||||
|
||||
@@ -134,6 +134,8 @@ export default {
|
||||
backendAuto: '自动',
|
||||
recommended: '推荐',
|
||||
notRecommended: '不推荐',
|
||||
multiSourceCodecLocked: '{sources} 已启用,当前编码已锁定',
|
||||
multiSourceVideoParamsWarning: '{sources} 已启用,修改视频设备和输入参数将导致流中断',
|
||||
// HID Config
|
||||
hidConfig: '鼠键配置',
|
||||
mouseSettings: '鼠标设置',
|
||||
|
||||
Reference in New Issue
Block a user