mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
feat: 新增安卓平台支持
This commit is contained in:
@@ -85,12 +85,10 @@ impl AtxController {
|
||||
shared_serial,
|
||||
),
|
||||
] {
|
||||
let executor = AtxKeyExecutor::new_with_shared_serial(
|
||||
config.clone(),
|
||||
serial,
|
||||
);
|
||||
*slot = Self::init_key_executor(warn_label, info_label, config, executor)
|
||||
.await;
|
||||
let executor =
|
||||
AtxKeyExecutor::new_with_shared_serial(config.clone(), serial);
|
||||
*slot =
|
||||
Self::init_key_executor(warn_label, info_label, config, executor).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -102,13 +100,22 @@ impl AtxController {
|
||||
}
|
||||
} else {
|
||||
for (slot, warn_label, info_label, config) in [
|
||||
(&mut inner.power_executor, "power", "Power", inner.config.power.clone()),
|
||||
(&mut inner.reset_executor, "reset", "Reset", inner.config.reset.clone()),
|
||||
(
|
||||
&mut inner.power_executor,
|
||||
"power",
|
||||
"Power",
|
||||
inner.config.power.clone(),
|
||||
),
|
||||
(
|
||||
&mut inner.reset_executor,
|
||||
"reset",
|
||||
"Reset",
|
||||
inner.config.reset.clone(),
|
||||
),
|
||||
] {
|
||||
if config.is_configured() {
|
||||
let executor = AtxKeyExecutor::new(config.clone());
|
||||
*slot = Self::init_key_executor(warn_label, info_label, config, executor)
|
||||
.await;
|
||||
*slot = Self::init_key_executor(warn_label, info_label, config, executor).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,11 +236,13 @@ impl AtxController {
|
||||
};
|
||||
|
||||
let Some(executor) = executor else {
|
||||
return Err(AppError::Config(match action {
|
||||
AtxAction::Reset => "Reset button not configured for ATX controller",
|
||||
_ => "Power button not configured for ATX controller",
|
||||
}
|
||||
.to_string()));
|
||||
return Err(AppError::Config(
|
||||
match action {
|
||||
AtxAction::Reset => "Reset button not configured for ATX controller",
|
||||
_ => "Power button not configured for ATX controller",
|
||||
}
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
executor.pulse(duration).await?;
|
||||
|
||||
@@ -102,7 +102,7 @@ impl HidrawLinuxRelayBackend {
|
||||
device: &File,
|
||||
report: &[u8; USB_RELAY_REPORT_LEN],
|
||||
) -> std::io::Result<()> {
|
||||
let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9, report.as_ptr()) };
|
||||
let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9 as _, report.as_ptr()) };
|
||||
if rc < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
|
||||
@@ -65,9 +65,12 @@ pub fn discover_devices() -> AtxDevices {
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with("gpiochip") {
|
||||
devices.gpio_chips.push(format!("/dev/{}", name_str));
|
||||
} else if name_str.starts_with("hidraw") && is_usb_relay_hidraw(&name_str) {
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if name_str.starts_with("hidraw") && is_usb_relay_hidraw(&name_str) {
|
||||
devices.usb_relays.push(format!("/dev/{}", name_str));
|
||||
} else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") {
|
||||
}
|
||||
if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") {
|
||||
devices.serial_ports.push(format!("/dev/{}", name_str));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
#[path = "capture_linux.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
#[path = "capture_android.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "capture_windows.rs"]
|
||||
mod imp;
|
||||
|
||||
292
src/audio/capture_android.rs
Normal file
292
src/audio/capture_android.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use alsa::pcm::{Access, Format, Frames, HwParams};
|
||||
use alsa::{Direction, ValueOr, PCM};
|
||||
use bytes::Bytes;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{broadcast, watch, Mutex};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::audio::device::AudioDeviceInfo;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::utils::LogThrottler;
|
||||
use crate::{error_throttled, warn_throttled};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioConfig {
|
||||
pub device_name: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub frame_size: u32,
|
||||
pub buffer_frames: u32,
|
||||
pub period_frames: u32,
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_name: String::new(),
|
||||
sample_rate: 48_000,
|
||||
channels: 2,
|
||||
frame_size: 960,
|
||||
buffer_frames: 4096,
|
||||
period_frames: 960,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioConfig {
|
||||
pub fn for_device(device: &AudioDeviceInfo) -> Self {
|
||||
Self {
|
||||
device_name: device.name.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes_per_sample(&self) -> u32 {
|
||||
2 * self.channels
|
||||
}
|
||||
|
||||
pub fn bytes_per_frame(&self) -> usize {
|
||||
(self.frame_size * self.bytes_per_sample()) as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFrame {
|
||||
pub data: Bytes,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub samples: u32,
|
||||
pub sequence: u64,
|
||||
pub timestamp: Instant,
|
||||
}
|
||||
|
||||
impl AudioFrame {
|
||||
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
||||
let bps = 2 * channels;
|
||||
Self {
|
||||
samples: data.len() as u32 / bps,
|
||||
data,
|
||||
sample_rate,
|
||||
channels,
|
||||
sequence,
|
||||
timestamp: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CaptureState {
|
||||
Stopped,
|
||||
Running,
|
||||
Error,
|
||||
}
|
||||
|
||||
pub struct AudioCapturer {
|
||||
config: AudioConfig,
|
||||
state: Arc<watch::Sender<CaptureState>>,
|
||||
state_rx: watch::Receiver<CaptureState>,
|
||||
frame_tx: broadcast::Sender<AudioFrame>,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
sequence: Arc<AtomicU64>,
|
||||
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
log_throttler: LogThrottler,
|
||||
}
|
||||
|
||||
impl AudioCapturer {
|
||||
pub fn new(config: AudioConfig) -> Self {
|
||||
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
||||
let (frame_tx, _) = broadcast::channel(16);
|
||||
|
||||
Self {
|
||||
config,
|
||||
state: Arc::new(state_tx),
|
||||
state_rx,
|
||||
frame_tx,
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
sequence: Arc::new(AtomicU64::new(0)),
|
||||
capture_handle: Mutex::new(None),
|
||||
log_throttler: LogThrottler::with_secs(5),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> CaptureState {
|
||||
*self.state_rx.borrow()
|
||||
}
|
||||
|
||||
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
||||
self.state_rx.clone()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
||||
self.frame_tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
if self.state() == CaptureState::Running {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Starting audio capture on {} at {}Hz {}ch",
|
||||
self.config.device_name, self.config.sample_rate, self.config.channels
|
||||
);
|
||||
|
||||
self.stop_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
let config = self.config.clone();
|
||||
let state = self.state.clone();
|
||||
let frame_tx = self.frame_tx.clone();
|
||||
let stop_flag = self.stop_flag.clone();
|
||||
let sequence = self.sequence.clone();
|
||||
let log_throttler = self.log_throttler.clone();
|
||||
|
||||
let handle = tokio::task::spawn_blocking(move || {
|
||||
let result = run_capture(
|
||||
&config,
|
||||
&state,
|
||||
&frame_tx,
|
||||
&stop_flag,
|
||||
&sequence,
|
||||
&log_throttler,
|
||||
);
|
||||
|
||||
if let Err(e) = result {
|
||||
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
|
||||
let _ = state.send(CaptureState::Error);
|
||||
} else {
|
||||
let _ = state.send(CaptureState::Stopped);
|
||||
}
|
||||
});
|
||||
|
||||
*self.capture_handle.lock().await = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
info!("Stopping audio capture");
|
||||
self.stop_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Some(handle) = self.capture_handle.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
let _ = self.state.send(CaptureState::Stopped);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.state() == CaptureState::Running
|
||||
}
|
||||
}
|
||||
|
||||
fn run_capture(
|
||||
config: &AudioConfig,
|
||||
state: &watch::Sender<CaptureState>,
|
||||
frame_tx: &broadcast::Sender<AudioFrame>,
|
||||
stop_flag: &AtomicBool,
|
||||
sequence: &AtomicU64,
|
||||
log_throttler: &LogThrottler,
|
||||
) -> Result<()> {
|
||||
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
|
||||
AppError::AudioError(format!(
|
||||
"Failed to open audio device {}: {}",
|
||||
config.device_name, e
|
||||
))
|
||||
})?;
|
||||
|
||||
{
|
||||
let hwp = HwParams::any(&pcm)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
|
||||
|
||||
hwp.set_channels(config.channels)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
|
||||
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
|
||||
hwp.set_format(Format::s16())
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
|
||||
hwp.set_access(Access::RWInterleaved)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
|
||||
hwp.set_buffer_size_near(config.buffer_frames as Frames)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
|
||||
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
|
||||
pcm.hw_params(&hwp)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
|
||||
}
|
||||
|
||||
let hw_now = pcm.hw_params_current().map_err(|e| {
|
||||
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
|
||||
})?;
|
||||
let actual_rate = hw_now
|
||||
.get_rate()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
|
||||
let actual_ch = hw_now
|
||||
.get_channels()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
|
||||
if actual_rate != 48_000 {
|
||||
return Err(AppError::AudioError(format!(
|
||||
"Audio capture requires 48000 Hz; device is {} Hz",
|
||||
actual_rate
|
||||
)));
|
||||
}
|
||||
if actual_ch != 2 {
|
||||
return Err(AppError::AudioError(format!(
|
||||
"Audio capture requires 2 channels (stereo); device has {}",
|
||||
actual_ch
|
||||
)));
|
||||
}
|
||||
debug!("Audio capture: 48000 Hz, 2 ch");
|
||||
|
||||
pcm.prepare()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
|
||||
let _ = state.send(CaptureState::Running);
|
||||
|
||||
let period_frames = pcm
|
||||
.hw_params_current()
|
||||
.ok()
|
||||
.and_then(|h| h.get_period_size().ok())
|
||||
.map(|f| f as usize)
|
||||
.unwrap_or(1024)
|
||||
.max(256);
|
||||
let buf_frames = period_frames.saturating_mul(4).max(2048);
|
||||
let io = pcm
|
||||
.io_i16()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to get PCM IO: {}", e)))?;
|
||||
|
||||
let mut buffer = vec![0i16; buf_frames * 2];
|
||||
let mut next_log = Instant::now();
|
||||
|
||||
while !stop_flag.load(Ordering::SeqCst) {
|
||||
match io.readi(&mut buffer[..period_frames * 2]) {
|
||||
Ok(frames_read) => {
|
||||
if frames_read == 0 {
|
||||
continue;
|
||||
}
|
||||
let samples = frames_read * 2;
|
||||
let data = Bytes::copy_from_slice(bytemuck::cast_slice(&buffer[..samples]));
|
||||
let seq = sequence.fetch_add(1, Ordering::SeqCst);
|
||||
let frame = AudioFrame::new_interleaved(data, 2, 48_000, seq);
|
||||
let _ = frame_tx.send(frame);
|
||||
if next_log.elapsed().as_secs() >= 5 {
|
||||
debug!("Captured audio frame {} ({} samples)", seq, samples / 2);
|
||||
next_log = Instant::now();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn_throttled!(
|
||||
log_throttler,
|
||||
"alsa_read",
|
||||
"ALSA read error on {}: {}",
|
||||
config.device_name,
|
||||
err
|
||||
);
|
||||
let _ = pcm.try_recover(err, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = pcm.drain();
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,11 +6,13 @@ use tokio::sync::RwLock;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::capture::AudioConfig;
|
||||
use super::device::{enumerate_audio_devices_with_current, find_best_audio_device, AudioDeviceInfo};
|
||||
use super::device::{
|
||||
enumerate_audio_devices_with_current, find_best_audio_device, AudioDeviceInfo,
|
||||
};
|
||||
use super::encoder::OpusFrame;
|
||||
use super::monitor::AudioHealthMonitor;
|
||||
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
||||
use super::recovery;
|
||||
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
||||
use super::types::{AudioControllerConfig, AudioQuality, AudioStatus};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::events::EventBus;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
#[path = "device_linux.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
#[path = "device_android.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "device_windows.rs"]
|
||||
mod imp;
|
||||
|
||||
185
src/audio/device_android.rs
Normal file
185
src/audio/device_android.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use alsa::pcm::HwParams;
|
||||
use alsa::{Direction, PCM};
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AudioDeviceInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub card_index: i32,
|
||||
pub device_index: i32,
|
||||
pub sample_rates: Vec<u32>,
|
||||
pub channels: Vec<u32>,
|
||||
pub is_capture: bool,
|
||||
pub is_hdmi: bool,
|
||||
pub usb_bus: Option<String>,
|
||||
}
|
||||
|
||||
fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
||||
if card_index < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let device_path = format!("/sys/class/sound/card{}/device", card_index);
|
||||
let link_target = std::fs::read_link(&device_path).ok()?;
|
||||
let link_str = link_target.to_string_lossy();
|
||||
|
||||
for component in link_str.split('/') {
|
||||
if component.contains('-') && !component.contains(':') {
|
||||
if component
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(component.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
||||
enumerate_audio_devices_with_current(None)
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices_with_current(
|
||||
current_device: Option<&str>,
|
||||
) -> Result<Vec<AudioDeviceInfo>> {
|
||||
let mut devices = Vec::new();
|
||||
|
||||
for card_result in alsa::card::Iter::new() {
|
||||
let card = match card_result {
|
||||
Ok(card) => card,
|
||||
Err(err) => {
|
||||
debug!("Error iterating card: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let card_index = card.get_index();
|
||||
let card_name = card.get_name().unwrap_or_else(|_| "Unknown".to_string());
|
||||
let card_longname = card.get_longname().unwrap_or_else(|_| card_name.clone());
|
||||
|
||||
debug!("Found audio card {}: {}", card_index, card_longname);
|
||||
|
||||
let long_lower = card_longname.to_lowercase();
|
||||
let is_hdmi = long_lower.contains("hdmi")
|
||||
|| long_lower.contains("capture")
|
||||
|| long_lower.contains("usb");
|
||||
let usb_bus = get_usb_bus_info(card_index);
|
||||
|
||||
for device_index in 0..8 {
|
||||
let device_name = format!("hw:{},{}", card_index, device_index);
|
||||
let is_current_device = current_device == Some(device_name.as_str());
|
||||
|
||||
let mut push_info =
|
||||
|sample_rates: Vec<u32>, channels: Vec<u32>, description: String| {
|
||||
devices.push(AudioDeviceInfo {
|
||||
name: device_name.clone(),
|
||||
description,
|
||||
card_index,
|
||||
device_index,
|
||||
sample_rates,
|
||||
channels,
|
||||
is_capture: true,
|
||||
is_hdmi,
|
||||
usb_bus: usb_bus.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
match PCM::new(&device_name, Direction::Capture, false) {
|
||||
Ok(pcm) => {
|
||||
let (sample_rates, channels) = query_device_caps(&pcm);
|
||||
if !sample_rates.is_empty() && !channels.is_empty() {
|
||||
push_info(
|
||||
sample_rates,
|
||||
channels,
|
||||
format!("{} - Device {}", card_longname, device_index),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) if is_current_device => {
|
||||
debug!(
|
||||
"Device {} is busy (in use by us), adding with default caps",
|
||||
device_name
|
||||
);
|
||||
push_info(
|
||||
vec![44_100, 48_000],
|
||||
vec![2],
|
||||
format!("{} - Device {} (in use)", card_longname, device_index),
|
||||
);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} audio capture devices", devices.len());
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
|
||||
let hwp = match HwParams::any(pcm) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
|
||||
let common_rates = [8000, 16000, 22050, 44100, 48000, 96000];
|
||||
let mut supported_rates = Vec::new();
|
||||
|
||||
for rate in &common_rates {
|
||||
if hwp.test_rate(*rate).is_ok() {
|
||||
supported_rates.push(*rate);
|
||||
}
|
||||
}
|
||||
|
||||
let mut supported_channels = Vec::new();
|
||||
for ch in 1..=8 {
|
||||
if hwp.test_channels(ch).is_ok() {
|
||||
supported_channels.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
(supported_rates, supported_channels)
|
||||
}
|
||||
|
||||
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||
let devices = enumerate_audio_devices()?;
|
||||
|
||||
if devices.is_empty() {
|
||||
return Err(AppError::AudioError(
|
||||
"No audio capture devices found".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut first_48k_stereo: Option<&AudioDeviceInfo> = None;
|
||||
for device in &devices {
|
||||
if !device.sample_rates.contains(&48_000) || !device.channels.contains(&2) {
|
||||
continue;
|
||||
}
|
||||
if device.is_hdmi {
|
||||
info!("Selected HDMI audio device: {}", device.description);
|
||||
return Ok(device.clone());
|
||||
}
|
||||
if first_48k_stereo.is_none() {
|
||||
first_48k_stereo = Some(device);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(device) = first_48k_stereo {
|
||||
info!("Selected audio device: {}", device.description);
|
||||
return Ok(device.clone());
|
||||
}
|
||||
|
||||
let device = devices.into_iter().next().unwrap();
|
||||
warn!(
|
||||
"Using fallback audio device: {} (may not support optimal settings)",
|
||||
device.description
|
||||
);
|
||||
Ok(device)
|
||||
}
|
||||
@@ -4,11 +4,11 @@ use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::capture::AudioConfig;
|
||||
use super::controller::AudioRecoveredCallback;
|
||||
use super::device::{enumerate_audio_devices, AudioDeviceInfo};
|
||||
use super::monitor::AudioHealthMonitor;
|
||||
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
||||
use super::types::AudioControllerConfig;
|
||||
use super::controller::AudioRecoveredCallback;
|
||||
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
|
||||
|
||||
const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
@@ -25,4 +25,3 @@ impl AtxConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,4 +61,3 @@ impl std::fmt::Display for BitratePreset {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -306,4 +306,3 @@ impl HidConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,4 +41,3 @@ impl AppConfig {
|
||||
crate::platform::defaults::apply(self);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,4 +146,3 @@ impl Default for RedfishConfig {
|
||||
Self { enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,19 @@ fn get_meminfo() -> MemInfo {
|
||||
}
|
||||
|
||||
fn get_network_addresses() -> Vec<NetworkAddress> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
return get_network_addresses_android();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
get_network_addresses_ifaddrs()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> {
|
||||
let all_addrs = match nix::ifaddrs::getifaddrs() {
|
||||
Ok(addrs) => addrs,
|
||||
Err(_) => return Vec::new(),
|
||||
@@ -247,6 +260,101 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
|
||||
addresses
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn get_network_addresses_android() -> Vec<NetworkAddress> {
|
||||
let net_dir = match std::fs::read_dir("/sys/class/net") {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for entry in net_dir.flatten() {
|
||||
let iface_name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if iface_name == "lo" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let operstate_path = entry.path().join("operstate");
|
||||
let is_up = std::fs::read_to_string(&operstate_path)
|
||||
.map(|s| s.trim() == "up")
|
||||
.unwrap_or(false);
|
||||
if !is_up {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(ip) = android_ipv4_for_interface(&iface_name) else {
|
||||
continue;
|
||||
};
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ip_str = ip.to_string();
|
||||
if seen.insert((iface_name.clone(), ip_str.clone())) {
|
||||
addresses.push(NetworkAddress {
|
||||
interface: iface_name,
|
||||
ip: ip_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addresses
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_ipv4_for_interface(iface_name: &str) -> Option<std::net::Ipv4Addr> {
|
||||
use std::ffi::CString;
|
||||
use std::mem::{size_of, zeroed};
|
||||
|
||||
let name = CString::new(iface_name).ok()?;
|
||||
if name.as_bytes().len() >= libc::IFNAMSIZ {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
|
||||
if fd < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut request: libc::ifreq = zeroed();
|
||||
std::ptr::copy_nonoverlapping(
|
||||
name.as_ptr(),
|
||||
request.ifr_name.as_mut_ptr(),
|
||||
name.as_bytes_with_nul().len(),
|
||||
);
|
||||
|
||||
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
|
||||
let result = libc::ioctl(fd, request_code, &mut request);
|
||||
libc::close(fd);
|
||||
if result < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sockaddr = request.ifr_ifru.ifru_addr;
|
||||
if sockaddr.sa_family as libc::c_int != libc::AF_INET {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut storage = [0u8; size_of::<libc::sockaddr_in>()];
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&sockaddr as *const libc::sockaddr as *const u8,
|
||||
storage.as_mut_ptr(),
|
||||
size_of::<libc::sockaddr>(),
|
||||
);
|
||||
let sockaddr_in = &*(storage.as_ptr() as *const libc::sockaddr_in);
|
||||
Some(std::net::Ipv4Addr::from(u32::from_be(
|
||||
sockaddr_in.sin_addr.s_addr,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes};
|
||||
|
||||
@@ -36,7 +36,6 @@ const RECONNECT_DELAY_MS: u64 = 2000;
|
||||
|
||||
const INIT_WAIT_MS: u64 = 3000;
|
||||
|
||||
|
||||
struct Ch9329RuntimeState {
|
||||
initialized: AtomicBool,
|
||||
online: AtomicBool,
|
||||
@@ -843,8 +842,8 @@ impl HidBackend for Ch9329Backend {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::ch9329_proto::{build_packet, calculate_checksum};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_packet_building() {
|
||||
|
||||
@@ -167,7 +167,10 @@ pub fn calculate_checksum(data: &[u8]) -> u8 {
|
||||
|
||||
#[inline]
|
||||
pub fn build_packet_buf(address: u8, cmd: u8, data: &[u8]) -> ([u8; MAX_PACKET_SIZE], usize) {
|
||||
debug_assert!(data.len() <= MAX_DATA_LEN, "Data too long for CH9329 packet");
|
||||
debug_assert!(
|
||||
data.len() <= MAX_DATA_LEN,
|
||||
"Data too long for CH9329 packet"
|
||||
);
|
||||
|
||||
let len = data.len() as u8;
|
||||
let packet_len = 6 + data.len();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
|
||||
|
||||
pub mod backend;
|
||||
mod ch9329_proto;
|
||||
pub mod ch9329;
|
||||
mod ch9329_proto;
|
||||
pub mod consumer;
|
||||
pub mod datachannel;
|
||||
mod factory;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! Polled timed writes (JetKVM-style). Treat `ESHUTDOWN` (108) by closing handles and reopening; keep fd on `EAGAIN` (11). Host/gadget teardown during MSD resembles PiKVM. <https://github.com/raspberrypi/linux/issues/4373>
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||
use parking_lot::Mutex;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Read;
|
||||
@@ -14,7 +15,6 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||
use tokio::sync::watch;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
@@ -222,15 +222,7 @@ impl OtgBackend {
|
||||
}
|
||||
|
||||
fn find_udc() -> Option<String> {
|
||||
let udc_path = PathBuf::from("/sys/class/udc");
|
||||
if let Ok(entries) = fs::read_dir(&udc_path) {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
crate::otg::configfs::find_udc()
|
||||
}
|
||||
|
||||
/// PiKVM-style: drop handle if node missing; reopen when path reappears.
|
||||
|
||||
33
src/lib.rs
33
src/lib.rs
@@ -1,37 +1,64 @@
|
||||
//! Core library for One-KVM (IP‑KVM: capture, HID, OTG, streaming, Web UI glue).
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
#[cfg(not(any(feature = "android", unix, windows)))]
|
||||
compile_error!("One-KVM supports Linux and Windows targets only.");
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod runtime;
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod atx;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod audio;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod auth;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod config;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod db;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod diagnostics;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod error;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod events;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod extensions;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod hid;
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, any(feature = "android", feature = "desktop")))]
|
||||
pub mod msd;
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, any(feature = "android", feature = "desktop")))]
|
||||
pub mod otg;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod platform;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod redfish;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod rtsp;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod rustdesk;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod state;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod stream;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod stream_encoder;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod update;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod utils;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod video;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod web;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod webrtc;
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod secrets {
|
||||
include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs"));
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub use error::{AppError, Result};
|
||||
|
||||
@@ -49,16 +49,26 @@ pub fn ensure_libcomposite_loaded() -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn find_udc() -> Option<String> {
|
||||
let udc_path = Path::new("/sys/class/udc");
|
||||
if !udc_path.exists() {
|
||||
return None;
|
||||
}
|
||||
list_udcs().into_iter().next()
|
||||
}
|
||||
|
||||
fs::read_dir(udc_path)
|
||||
.ok()?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.next()
|
||||
pub fn list_udcs() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
collect_dir_names(Path::new("/sys/class/udc"), &mut devices);
|
||||
devices.sort();
|
||||
devices.dedup();
|
||||
devices
|
||||
}
|
||||
|
||||
fn collect_dir_names(path: &Path, devices: &mut Vec<String>) {
|
||||
if let Ok(entries) = fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().trim().to_string();
|
||||
if !name.is_empty() {
|
||||
devices.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_low_endpoint_udc(name: &str) -> bool {
|
||||
|
||||
@@ -25,13 +25,12 @@ pub use service::{HidDevicePaths, OtgService};
|
||||
|
||||
/// List USB Device Controller names exposed by sysfs.
|
||||
pub fn list_udc_devices() -> Vec<String> {
|
||||
let mut devices: Vec<String> = std::fs::read_dir("/sys/class/udc")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||
.filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
|
||||
.collect();
|
||||
|
||||
devices.sort();
|
||||
devices
|
||||
#[cfg(unix)]
|
||||
{
|
||||
configfs::list_udcs()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
43
src/platform/android.rs
Normal file
43
src/platform/android.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Android Amlogic platform capabilities.
|
||||
|
||||
use super::{FeatureCapability, PlatformCapabilities, PlatformMode};
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
#[allow(dead_code)]
|
||||
fn _keep_android_bionic_ifaddrs_shim_linked() {
|
||||
let _ = crate::platform::android_bionic::freeifaddrs
|
||||
as unsafe extern "C" fn(*mut crate::platform::android_bionic::ifaddrs);
|
||||
let _ = crate::platform::android_bionic::getifaddrs
|
||||
as unsafe extern "C" fn(*mut *mut crate::platform::android_bionic::ifaddrs) -> i32;
|
||||
}
|
||||
|
||||
pub fn capabilities() -> PlatformCapabilities {
|
||||
#[cfg(feature = "android")]
|
||||
_keep_android_bionic_ifaddrs_shim_linked();
|
||||
|
||||
PlatformCapabilities {
|
||||
mode: PlatformMode::AndroidAmlogic,
|
||||
mode_label: PlatformMode::AndroidAmlogic.label(),
|
||||
video_capture: FeatureCapability::available(["v4l2_uvc"])
|
||||
.with_selected_backend(Some("v4l2_uvc".to_string())),
|
||||
encoder: FeatureCapability::available(["ffmpeg_mediacodec_h264", "mjpeg"])
|
||||
.with_selected_backend(Some(
|
||||
if cfg!(feature = "android-mediacodec") {
|
||||
"ffmpeg_mediacodec_h264"
|
||||
} else {
|
||||
"mjpeg"
|
||||
}
|
||||
.to_string(),
|
||||
)),
|
||||
hid: FeatureCapability::available(["otg_configfs", "ch9329", "none"]),
|
||||
atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]),
|
||||
msd: FeatureCapability::available(["otg_configfs"]),
|
||||
otg: FeatureCapability::available(["configfs"]),
|
||||
audio: FeatureCapability::available(["alsa", "opus"])
|
||||
.with_selected_backend(Some("alsa".to_string())),
|
||||
rustdesk: FeatureCapability::available(["builtin"]),
|
||||
diagnostics: FeatureCapability::available(["android_linux"]),
|
||||
extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"),
|
||||
service_installation: FeatureCapability::available(["android_foreground_service"]),
|
||||
}
|
||||
}
|
||||
175
src/platform/android_bionic.rs
Normal file
175
src/platform/android_bionic.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
#![allow(clippy::missing_safety_doc)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
||||
|
||||
#[repr(C)]
|
||||
pub struct ifaddrs {
|
||||
pub ifa_next: *mut ifaddrs,
|
||||
pub ifa_name: *mut c_char,
|
||||
pub ifa_flags: c_uint,
|
||||
pub ifa_addr: *mut libc::sockaddr,
|
||||
pub ifa_netmask: *mut libc::sockaddr,
|
||||
pub ifa_ifu: *mut libc::sockaddr,
|
||||
pub ifa_data: *mut c_void,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct AddrNode {
|
||||
ifa: ifaddrs,
|
||||
name: CString,
|
||||
addr: libc::sockaddr_in,
|
||||
next: *mut AddrNode,
|
||||
}
|
||||
|
||||
fn sockaddr_to_ipv4(addr: libc::sockaddr) -> Option<std::net::Ipv4Addr> {
|
||||
if addr.sa_family as c_int != libc::AF_INET {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let sin = &*(&addr as *const libc::sockaddr as *const libc::sockaddr_in);
|
||||
Some(std::net::Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)))
|
||||
}
|
||||
}
|
||||
|
||||
fn query_ipv4(iface_name: &str) -> Option<libc::sockaddr_in> {
|
||||
let name = CString::new(iface_name).ok()?;
|
||||
if name.as_bytes().len() >= libc::IFNAMSIZ {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
|
||||
if fd < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut request: libc::ifreq = zeroed();
|
||||
std::ptr::copy_nonoverlapping(
|
||||
name.as_ptr(),
|
||||
request.ifr_name.as_mut_ptr(),
|
||||
name.as_bytes_with_nul().len(),
|
||||
);
|
||||
|
||||
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
|
||||
let rc = libc::ioctl(fd, request_code, &mut request);
|
||||
libc::close(fd);
|
||||
if rc < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let addr = request.ifr_ifru.ifru_addr;
|
||||
if addr.sa_family as c_int != libc::AF_INET {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut sin: libc::sockaddr_in = zeroed();
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&addr as *const libc::sockaddr as *const u8,
|
||||
&mut sin as *mut libc::sockaddr_in as *mut u8,
|
||||
size_of::<libc::sockaddr_in>(),
|
||||
);
|
||||
Some(sin)
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn getifaddrs(addrs: *mut *mut ifaddrs) -> c_int {
|
||||
if addrs.is_null() {
|
||||
return -1;
|
||||
}
|
||||
*addrs = std::ptr::null_mut();
|
||||
|
||||
let net_dir = match std::fs::read_dir("/sys/class/net") {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
|
||||
let mut head: *mut AddrNode = std::ptr::null_mut();
|
||||
let mut tail: *mut AddrNode = std::ptr::null_mut();
|
||||
|
||||
for entry in net_dir.flatten() {
|
||||
let iface_name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if iface_name == "lo" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let operstate_path = entry.path().join("operstate");
|
||||
let is_up = std::fs::read_to_string(&operstate_path)
|
||||
.map(|s| s.trim() == "up")
|
||||
.unwrap_or(false);
|
||||
if !is_up {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(addr) = query_ipv4(&iface_name) else {
|
||||
continue;
|
||||
};
|
||||
let ip = sockaddr_to_ipv4(unsafe {
|
||||
std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr)
|
||||
});
|
||||
if ip
|
||||
.map(|ip| ip.is_loopback() || ip.is_unspecified())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match CString::new(iface_name) {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut node = Box::new(AddrNode {
|
||||
ifa: ifaddrs {
|
||||
ifa_next: std::ptr::null_mut(),
|
||||
ifa_name: std::ptr::null_mut(),
|
||||
ifa_flags: 0,
|
||||
ifa_addr: std::ptr::null_mut(),
|
||||
ifa_netmask: std::ptr::null_mut(),
|
||||
ifa_ifu: std::ptr::null_mut(),
|
||||
ifa_data: std::ptr::null_mut(),
|
||||
},
|
||||
name,
|
||||
addr,
|
||||
next: std::ptr::null_mut(),
|
||||
});
|
||||
|
||||
node.ifa.ifa_name = node.name.as_ptr() as *mut c_char;
|
||||
node.ifa.ifa_addr = &mut node.addr as *mut libc::sockaddr_in as *mut libc::sockaddr;
|
||||
node.ifa.ifa_ifu = std::ptr::null_mut();
|
||||
node.ifa.ifa_netmask = std::ptr::null_mut();
|
||||
node.ifa.ifa_flags = (libc::IFF_UP | libc::IFF_RUNNING) as c_uint;
|
||||
|
||||
let raw = Box::into_raw(node);
|
||||
if head.is_null() {
|
||||
head = raw;
|
||||
} else {
|
||||
(*tail).next = raw;
|
||||
(*tail).ifa.ifa_next = raw as *mut ifaddrs;
|
||||
}
|
||||
tail = raw;
|
||||
}
|
||||
|
||||
*addrs = if head.is_null() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
head as *mut ifaddrs
|
||||
};
|
||||
0
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn freeifaddrs(addrs: *mut ifaddrs) {
|
||||
let mut current = addrs as *mut AddrNode;
|
||||
while !current.is_null() {
|
||||
let next = (*current).next;
|
||||
drop(Box::from_raw(current));
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,16 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlatformMode {
|
||||
AndroidAmlogic,
|
||||
Linux,
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl PlatformMode {
|
||||
pub const fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
if cfg!(feature = "android") {
|
||||
Self::AndroidAmlogic
|
||||
} else if cfg!(windows) {
|
||||
Self::Windows
|
||||
} else {
|
||||
Self::Linux
|
||||
@@ -20,6 +23,7 @@ impl PlatformMode {
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::AndroidAmlogic => "Android Amlogic",
|
||||
Self::Linux => "Linux",
|
||||
Self::Windows => "Windows",
|
||||
}
|
||||
@@ -81,9 +85,17 @@ pub struct PlatformCapabilities {
|
||||
|
||||
impl PlatformCapabilities {
|
||||
pub fn current() -> Self {
|
||||
match PlatformMode::current() {
|
||||
PlatformMode::Linux => crate::platform::linux::capabilities(),
|
||||
PlatformMode::Windows => crate::platform::windows::capabilities(),
|
||||
#[cfg(feature = "android")]
|
||||
{
|
||||
return crate::platform::android::capabilities();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return crate::platform::windows::capabilities();
|
||||
}
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
{
|
||||
return crate::platform::linux::capabilities();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,73 @@
|
||||
use crate::config::{AppConfig, AtxDriverType, HidBackend};
|
||||
use crate::config::AppConfig;
|
||||
#[cfg(windows)]
|
||||
use crate::config::AtxDriverType;
|
||||
#[cfg(any(windows, all(unix, feature = "android")))]
|
||||
use crate::config::HidBackend;
|
||||
|
||||
pub fn apply(config: &mut AppConfig) {
|
||||
if cfg!(windows) {
|
||||
#[cfg(not(any(windows, all(unix, feature = "android"))))]
|
||||
{
|
||||
let _ = config;
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "android"))]
|
||||
{
|
||||
apply_android(config);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
apply_windows(config);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "android"))]
|
||||
fn apply_android(config: &mut AppConfig) {
|
||||
let detected_udc = crate::otg::configfs::find_udc();
|
||||
if config
|
||||
.hid
|
||||
.otg_udc
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
config.hid.otg_udc = detected_udc;
|
||||
}
|
||||
|
||||
let otg_available = config.hid.otg_udc.is_some();
|
||||
if !config.initialized && otg_available {
|
||||
config.hid.backend = HidBackend::Otg;
|
||||
} else if config.hid.backend == HidBackend::Ch9329
|
||||
&& config.hid.ch9329_port == "/dev/ttyUSB0"
|
||||
&& !std::path::Path::new(&config.hid.ch9329_port).exists()
|
||||
&& otg_available
|
||||
{
|
||||
config.hid.backend = HidBackend::Otg;
|
||||
}
|
||||
|
||||
if !config.initialized {
|
||||
config.audio.enabled = false;
|
||||
config.audio.device.clear();
|
||||
config.atx.enabled = false;
|
||||
config.rustdesk.enabled = false;
|
||||
config.rtsp.enabled = false;
|
||||
config.redfish.enabled = false;
|
||||
}
|
||||
|
||||
config
|
||||
.video
|
||||
.device
|
||||
.get_or_insert_with(|| "auto".to_string());
|
||||
config
|
||||
.video
|
||||
.format
|
||||
.get_or_insert_with(|| "MJPEG".to_string());
|
||||
config.web.bind_address = "0.0.0.0".to_string();
|
||||
config.web.bind_addresses = vec!["0.0.0.0".to_string()];
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn apply_windows(config: &mut AppConfig) {
|
||||
config.msd.enabled = false;
|
||||
config.hid.otg_udc = None;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
//! Platform selection and capability reporting.
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
pub mod android;
|
||||
#[cfg(feature = "android")]
|
||||
pub mod android_bionic;
|
||||
pub mod capabilities;
|
||||
pub mod defaults;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(unix)]
|
||||
pub mod usb_reset;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
pub use capabilities::{FeatureCapability, PlatformCapabilities, PlatformMode};
|
||||
|
||||
@@ -4,7 +4,7 @@ mod event;
|
||||
mod managers;
|
||||
mod session;
|
||||
mod systems;
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
mod virtual_media;
|
||||
|
||||
use axum::{
|
||||
@@ -200,7 +200,7 @@ pub fn create_redfish_router(state: Arc<AppState>) -> Router {
|
||||
redfish_auth_middleware,
|
||||
));
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone()));
|
||||
|
||||
Router::new()
|
||||
|
||||
735
src/runtime/android.rs
Normal file
735
src/runtime/android.rs
Normal file
@@ -0,0 +1,735 @@
|
||||
//! Android service runtime.
|
||||
//!
|
||||
//! Android is treated as a packaged Linux distribution: the APK/Java layer only
|
||||
//! starts and stops this runtime, while the Rust side builds the same AppState
|
||||
//! and Axum router used by the desktop service.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::atx::AtxController;
|
||||
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
||||
use crate::auth::{SessionStore, UserStore};
|
||||
use crate::config::{self, AppConfig, ConfigStore};
|
||||
use crate::db::DatabasePool;
|
||||
use crate::events::EventBus;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hid::{HidBackendType, HidController};
|
||||
use crate::msd::MsdController;
|
||||
use crate::otg::OtgService;
|
||||
use crate::rtsp::RtspService;
|
||||
use crate::rustdesk::RustDeskService;
|
||||
use crate::state::AppState;
|
||||
use crate::stream_encoder::encoder_type_to_backend;
|
||||
use crate::update::UpdateService;
|
||||
use crate::utils::bind_tcp_listener;
|
||||
use crate::video::codec_constraints::{
|
||||
enforce_constraints_with_stream_manager, StreamCodecConstraints,
|
||||
};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
use crate::video::{Streamer, VideoStreamManager};
|
||||
use crate::web;
|
||||
use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AndroidRuntimeConfig {
|
||||
pub data_dir: String,
|
||||
pub bind_address: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
struct RuntimeHandle {
|
||||
stop_tx: oneshot::Sender<()>,
|
||||
join: JoinHandle<()>,
|
||||
}
|
||||
|
||||
static HANDLE: OnceLock<Mutex<Option<RuntimeHandle>>> = OnceLock::new();
|
||||
|
||||
fn handle_slot() -> &'static Mutex<Option<RuntimeHandle>> {
|
||||
HANDLE.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub fn start(config: AndroidRuntimeConfig) -> Result<String, String> {
|
||||
init_logging();
|
||||
|
||||
let mut slot = handle_slot()
|
||||
.lock()
|
||||
.map_err(|_| "runtime lock poisoned".to_string())?;
|
||||
if slot.is_some() {
|
||||
return Ok(status());
|
||||
}
|
||||
|
||||
let (stop_tx, stop_rx) = oneshot::channel();
|
||||
let config_for_thread = config.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("one-kvm-android-runtime".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(err) = run_runtime(config_for_thread, stop_rx) {
|
||||
tracing::error!("One-KVM Android runtime exited: {}", err);
|
||||
}
|
||||
})
|
||||
.map_err(|err| format!("failed to spawn runtime: {err}"))?;
|
||||
|
||||
*slot = Some(RuntimeHandle { stop_tx, join });
|
||||
Ok(format!(
|
||||
"One-KVM Android runtime starting on http://{}:{}",
|
||||
config.bind_address, config.port
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_foreground(config: AndroidRuntimeConfig) -> Result<(), String> {
|
||||
init_logging();
|
||||
let (_stop_tx, stop_rx) = oneshot::channel();
|
||||
run_runtime(config, stop_rx)
|
||||
}
|
||||
|
||||
pub fn init_rustls_provider() {
|
||||
ensure_rustls_provider();
|
||||
}
|
||||
|
||||
pub fn stop() -> String {
|
||||
let handle = match handle_slot().lock() {
|
||||
Ok(mut slot) => slot.take(),
|
||||
Err(_) => return "runtime lock poisoned".to_string(),
|
||||
};
|
||||
|
||||
let Some(handle) = handle else {
|
||||
return "One-KVM Android runtime is not running".to_string();
|
||||
};
|
||||
|
||||
let _ = handle.stop_tx.send(());
|
||||
match handle.join.join() {
|
||||
Ok(()) => "One-KVM Android runtime stopped".to_string(),
|
||||
Err(_) => "One-KVM Android runtime stopped after panic".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status() -> String {
|
||||
match handle_slot().lock() {
|
||||
Ok(slot) if slot.is_some() => "One-KVM Android runtime running".to_string(),
|
||||
Ok(_) => "One-KVM Android runtime stopped".to_string(),
|
||||
Err(_) => "runtime lock poisoned".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_runtime(config: AndroidRuntimeConfig, stop_rx: oneshot::Receiver<()>) -> Result<(), String> {
|
||||
ensure_rustls_provider();
|
||||
let runtime = Runtime::new().map_err(|err| format!("failed to create tokio runtime: {err}"))?;
|
||||
runtime.block_on(async move { run_async(config, stop_rx).await })
|
||||
}
|
||||
|
||||
async fn run_async(
|
||||
config: AndroidRuntimeConfig,
|
||||
stop_rx: oneshot::Receiver<()>,
|
||||
) -> Result<(), String> {
|
||||
let (db, config_store, app_config) =
|
||||
load_runtime_config(&PathBuf::from(&config.data_dir), &config).await?;
|
||||
let (shutdown_tx, _) = broadcast::channel::<()>(1);
|
||||
let state = build_app_state(
|
||||
PathBuf::from(&config.data_dir),
|
||||
db,
|
||||
config_store,
|
||||
app_config,
|
||||
shutdown_tx.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let app = web::create_router(state.clone());
|
||||
let listener = bind_android_listener(&config.bind_address, config.port)?;
|
||||
let local_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|err| format!("failed to get listener address: {err}"))?;
|
||||
tracing::info!(
|
||||
"Starting One-KVM desktop router on Android at http://{}",
|
||||
local_addr
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::from_std(listener)
|
||||
.map_err(|err| format!("failed to create tokio listener: {err}"))?;
|
||||
let server = axum::serve(listener, app);
|
||||
|
||||
let shutdown_signal = async move {
|
||||
let _ = stop_rx.await;
|
||||
tracing::info!("Android stop request received");
|
||||
let _ = shutdown_tx.send(());
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
result = server => {
|
||||
if let Err(err) = result {
|
||||
tracing::error!("Android HTTP server error: {}", err);
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal => {}
|
||||
}
|
||||
|
||||
cleanup(&state).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_runtime_config(
|
||||
data_dir: &Path,
|
||||
runtime_config: &AndroidRuntimeConfig,
|
||||
) -> Result<(DatabasePool, ConfigStore, AppConfig), String> {
|
||||
tokio::fs::create_dir_all(data_dir)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create data dir {}: {err}", data_dir.display()))?;
|
||||
|
||||
let db_path = data_dir.join("one-kvm.db");
|
||||
let db = DatabasePool::new(&db_path)
|
||||
.await
|
||||
.map_err(|err| format!("failed to open database {}: {err}", db_path.display()))?;
|
||||
db.init_schema()
|
||||
.await
|
||||
.map_err(|err| format!("failed to initialize database schema: {err}"))?;
|
||||
|
||||
let config_store = ConfigStore::new(db.clone_pool())
|
||||
.map_err(|err| format!("failed to create config store: {err}"))?;
|
||||
config_store
|
||||
.load()
|
||||
.await
|
||||
.map_err(|err| format!("failed to load config: {err}"))?;
|
||||
|
||||
let mut config = (*config_store.get()).clone();
|
||||
config.apply_platform_defaults();
|
||||
config.web.bind_address = runtime_config.bind_address.clone();
|
||||
config.web.bind_addresses = vec![runtime_config.bind_address.clone()];
|
||||
config.web.http_port = runtime_config.port;
|
||||
config.web.https_enabled = false;
|
||||
prepare_android_runtime_dirs(data_dir, &config_store, &mut config).await?;
|
||||
|
||||
if let Some(device) = config.video.device.as_deref() {
|
||||
if device == "auto" {
|
||||
config.video.device = None;
|
||||
}
|
||||
}
|
||||
|
||||
config_store
|
||||
.set(config.clone())
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist Android runtime config: {err}"))?;
|
||||
|
||||
Ok((db, config_store, config))
|
||||
}
|
||||
|
||||
async fn prepare_android_runtime_dirs(
|
||||
data_dir: &Path,
|
||||
config_store: &ConfigStore,
|
||||
config: &mut AppConfig,
|
||||
) -> Result<(), String> {
|
||||
let mut updated = false;
|
||||
if config.msd.msd_dir.trim().is_empty() {
|
||||
config.msd.msd_dir = data_dir.join("msd").to_string_lossy().to_string();
|
||||
updated = true;
|
||||
} else if !PathBuf::from(&config.msd.msd_dir).is_absolute() {
|
||||
config.msd.msd_dir = data_dir
|
||||
.join(&config.msd.msd_dir)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
let msd_dir = config.msd.msd_dir_path();
|
||||
tokio::fs::create_dir_all(msd_dir.join("images"))
|
||||
.await
|
||||
.map_err(|err| format!("failed to create Android MSD images dir: {err}"))?;
|
||||
tokio::fs::create_dir_all(msd_dir.join("ventoy"))
|
||||
.await
|
||||
.map_err(|err| format!("failed to create Android MSD ventoy dir: {err}"))?;
|
||||
|
||||
if updated {
|
||||
config_store
|
||||
.set(config.clone())
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist Android MSD dir: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn build_app_state(
|
||||
data_dir: PathBuf,
|
||||
db: DatabasePool,
|
||||
config_store: ConfigStore,
|
||||
config: AppConfig,
|
||||
shutdown_tx: broadcast::Sender<()>,
|
||||
) -> Result<Arc<AppState>, String> {
|
||||
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||
let user_store = UserStore::new(db.clone_pool());
|
||||
let events = Arc::new(EventBus::new());
|
||||
|
||||
let (video_format, video_resolution) = parse_video_config(&config);
|
||||
let streamer = Streamer::new();
|
||||
streamer.set_event_bus(events.clone()).await;
|
||||
if let Some(ref device_path) = config.video.device {
|
||||
if let Err(err) = streamer
|
||||
.apply_video_config(
|
||||
device_path,
|
||||
video_format,
|
||||
video_resolution,
|
||||
config.video.fps,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Android video config failed, falling back to auto: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let webrtc_streamer = WebRtcStreamer::with_config(WebRtcStreamerConfig {
|
||||
resolution: video_resolution,
|
||||
input_format: video_format,
|
||||
fps: config.video.fps,
|
||||
bitrate_preset: config.stream.bitrate_preset,
|
||||
encoder_backend: encoder_type_to_backend(config.stream.encoder.clone()),
|
||||
webrtc: build_webrtc_config(&config),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let hid_backend = match config.hid.backend {
|
||||
config::HidBackend::Otg => HidBackendType::Otg,
|
||||
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||
port: config.hid.ch9329_port.clone(),
|
||||
baud_rate: config.hid.ch9329_baudrate,
|
||||
},
|
||||
config::HidBackend::None => HidBackendType::None,
|
||||
};
|
||||
let otg_service = Arc::new(OtgService::new());
|
||||
if let Err(err) = otg_service.apply_config(&config.hid, &config.msd).await {
|
||||
tracing::warn!("Failed to apply Android OTG config: {}", err);
|
||||
}
|
||||
|
||||
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
|
||||
hid.set_event_bus(events.clone()).await;
|
||||
if let Err(err) = hid.init().await {
|
||||
tracing::warn!("Failed to initialize Android HID backend: {}", err);
|
||||
}
|
||||
|
||||
let msd = if config.msd.enabled {
|
||||
let ventoy_resource_dir = data_dir.join("ventoy");
|
||||
if ventoy_resource_dir.exists() {
|
||||
if let Err(err) = ventoy_img::init_resources(&ventoy_resource_dir) {
|
||||
tracing::warn!("Failed to initialize Android Ventoy resources: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path());
|
||||
if let Err(err) = controller.init().await {
|
||||
tracing::warn!("Failed to initialize Android MSD controller: {}", err);
|
||||
None
|
||||
} else {
|
||||
controller.set_event_bus(events.clone()).await;
|
||||
Some(controller)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let atx = if config.atx.enabled {
|
||||
let controller = AtxController::new(config.atx.to_controller_config());
|
||||
if let Err(err) = controller.init().await {
|
||||
tracing::warn!("Failed to initialize Android ATX controller: {}", err);
|
||||
None
|
||||
} else {
|
||||
Some(controller)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let audio = {
|
||||
let audio_config = AudioControllerConfig {
|
||||
enabled: config.audio.enabled,
|
||||
device: config.audio.device.clone(),
|
||||
quality: config
|
||||
.audio
|
||||
.quality
|
||||
.parse::<AudioQuality>()
|
||||
.unwrap_or(AudioQuality::Balanced),
|
||||
};
|
||||
let controller = AudioController::new(audio_config);
|
||||
controller.set_event_bus(events.clone()).await;
|
||||
if config.audio.enabled {
|
||||
if let Err(err) = controller.start_streaming().await {
|
||||
tracing::warn!("Failed to start Android audio: {}", err);
|
||||
}
|
||||
}
|
||||
Arc::new(controller)
|
||||
};
|
||||
|
||||
let extensions = Arc::new(ExtensionManager::new());
|
||||
webrtc_streamer.set_hid_controller(hid.clone()).await;
|
||||
webrtc_streamer.set_audio_controller(audio.clone()).await;
|
||||
|
||||
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
||||
streamer.current_capture_config().await;
|
||||
webrtc_streamer
|
||||
.update_video_config(actual_resolution, actual_format, actual_fps)
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
let (subdev_path, bridge_kind, v4l2_driver) = streamer
|
||||
.current_device()
|
||||
.await
|
||||
.map(|device| {
|
||||
(
|
||||
device.subdev_path.clone(),
|
||||
device.bridge_kind.clone(),
|
||||
Some(device.driver.clone()),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None, None));
|
||||
webrtc_streamer
|
||||
.set_capture_device(
|
||||
device_path,
|
||||
jpeg_quality,
|
||||
subdev_path,
|
||||
bridge_kind,
|
||||
v4l2_driver,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let stream_manager = VideoStreamManager::with_webrtc_streamer(
|
||||
streamer.clone(),
|
||||
webrtc_streamer.clone() as Arc<dyn crate::video::traits::VideoOutput>,
|
||||
);
|
||||
stream_manager.set_event_bus(events.clone()).await;
|
||||
stream_manager.set_config_store(config_store.clone()).await;
|
||||
{
|
||||
let stream_manager_weak = Arc::downgrade(&stream_manager);
|
||||
audio
|
||||
.set_recovered_callback(Arc::new(move || {
|
||||
if let Some(stream_manager) = stream_manager_weak.upgrade() {
|
||||
tokio::spawn(async move {
|
||||
stream_manager.reconnect_webrtc_audio_sources().await;
|
||||
});
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(err) = stream_manager
|
||||
.init_with_mode(config.stream.mode.clone())
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to initialize Android stream manager: {}", err);
|
||||
}
|
||||
|
||||
let rustdesk = if config.rustdesk.is_valid() {
|
||||
Some(Arc::new(RustDeskService::new(
|
||||
config.rustdesk.clone(),
|
||||
stream_manager.clone(),
|
||||
hid.clone(),
|
||||
audio.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rtsp = if config.rtsp.enabled {
|
||||
Some(Arc::new(RtspService::new(
|
||||
config.rtsp.clone(),
|
||||
stream_manager.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
||||
let state = AppState::new(
|
||||
db,
|
||||
config_store.clone(),
|
||||
session_store,
|
||||
user_store,
|
||||
otg_service,
|
||||
stream_manager,
|
||||
webrtc_streamer,
|
||||
hid,
|
||||
msd,
|
||||
atx,
|
||||
audio,
|
||||
rustdesk.clone(),
|
||||
rtsp.clone(),
|
||||
extensions.clone(),
|
||||
events.clone(),
|
||||
update_service,
|
||||
shutdown_tx,
|
||||
data_dir,
|
||||
);
|
||||
|
||||
extensions.set_event_bus(events.clone()).await;
|
||||
|
||||
if let Some(service) = rustdesk {
|
||||
if let Err(err) = service.start().await {
|
||||
tracing::warn!("Failed to start Android RustDesk service: {}", err);
|
||||
}
|
||||
}
|
||||
if let Some(service) = rtsp {
|
||||
if let Err(err) = service.start().await {
|
||||
tracing::warn!("Failed to start Android RTSP service: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let constraints = StreamCodecConstraints::from_config(&state.config.get());
|
||||
if let Err(err) =
|
||||
enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await
|
||||
{
|
||||
tracing::warn!("Failed to enforce Android stream constraints: {}", err);
|
||||
}
|
||||
|
||||
state.publish_device_info().await;
|
||||
spawn_device_info_broadcaster(state.clone(), events);
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn build_webrtc_config(config: &AppConfig) -> WebRtcConfig {
|
||||
let mut webrtc = WebRtcConfig::default();
|
||||
if let Some(stun) = config
|
||||
.stream
|
||||
.stun_server
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
webrtc.stun_servers.push(stun.clone());
|
||||
}
|
||||
if let Some(turn) = config
|
||||
.stream
|
||||
.turn_server
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
webrtc
|
||||
.turn_servers
|
||||
.push(crate::webrtc::config::TurnServer::new(
|
||||
turn.clone(),
|
||||
config.stream.turn_username.clone().unwrap_or_default(),
|
||||
config.stream.turn_password.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
webrtc
|
||||
}
|
||||
|
||||
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
|
||||
let format = config
|
||||
.video
|
||||
.format
|
||||
.as_ref()
|
||||
.and_then(|value| value.parse::<PixelFormat>().ok())
|
||||
.unwrap_or(PixelFormat::Mjpeg);
|
||||
(
|
||||
format,
|
||||
Resolution::new(config.video.width, config.video.height),
|
||||
)
|
||||
}
|
||||
|
||||
fn bind_android_listener(bind_address: &str, port: u16) -> Result<std::net::TcpListener, String> {
|
||||
let ip = bind_address
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|err| format!("invalid Android bind address {bind_address}: {err}"))?;
|
||||
bind_tcp_listener(SocketAddr::new(ip, port))
|
||||
.map_err(|err| format!("failed to bind Android listener {bind_address}:{port}: {err}"))
|
||||
}
|
||||
|
||||
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||
enum DeviceInfoTrigger {
|
||||
Event,
|
||||
Lagged { topic: &'static str, count: u64 },
|
||||
}
|
||||
|
||||
const DEVICE_INFO_TOPICS: &[&str] = &[
|
||||
"stream.state_changed",
|
||||
"stream.config_applied",
|
||||
"stream.mode_ready",
|
||||
];
|
||||
const DEBOUNCE_MS: u64 = 100;
|
||||
|
||||
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
|
||||
for topic in DEVICE_INFO_TOPICS {
|
||||
let Some(mut rx) = events.subscribe_topic(topic) else {
|
||||
continue;
|
||||
};
|
||||
let trigger_tx = trigger_tx.clone();
|
||||
let topic_name = *topic;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(_) => {
|
||||
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
if trigger_tx
|
||||
.send(DeviceInfoTrigger::Lagged {
|
||||
topic: topic_name,
|
||||
count,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let mut dirty_rx = events.subscribe_device_info_dirty();
|
||||
let trigger_tx = trigger_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match dirty_rx.recv().await {
|
||||
Ok(()) => {
|
||||
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
if trigger_tx
|
||||
.send(DeviceInfoTrigger::Lagged {
|
||||
topic: "device_info_dirty",
|
||||
count,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
||||
let mut pending_broadcast = false;
|
||||
|
||||
loop {
|
||||
let recv_result = if pending_broadcast {
|
||||
let remaining =
|
||||
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
||||
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
|
||||
} else {
|
||||
Ok(trigger_rx.recv().await)
|
||||
};
|
||||
|
||||
match recv_result {
|
||||
Ok(Some(DeviceInfoTrigger::Event)) => pending_broadcast = true,
|
||||
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
|
||||
tracing::warn!(
|
||||
"Android device info broadcaster lagged by {} events on {}",
|
||||
count,
|
||||
topic
|
||||
);
|
||||
pending_broadcast = true;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
|
||||
state.publish_device_info().await;
|
||||
last_broadcast = Instant::now();
|
||||
pending_broadcast = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn cleanup(state: &Arc<AppState>) {
|
||||
state.extensions.stop_all().await;
|
||||
|
||||
if let Some(service) = state.rustdesk.read().await.as_ref() {
|
||||
if let Err(err) = service.stop().await {
|
||||
tracing::warn!("Failed to stop Android RustDesk service: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(service) = state.rtsp.read().await.as_ref() {
|
||||
if let Err(err) = service.stop().await {
|
||||
tracing::warn!("Failed to stop Android RTSP service: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = state.stream_manager.stop().await {
|
||||
tracing::warn!("Failed to stop Android stream manager: {}", err);
|
||||
}
|
||||
if let Err(err) = state.hid.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android HID: {}", err);
|
||||
}
|
||||
if let Some(msd) = state.msd.write().await.as_mut() {
|
||||
if let Err(err) = msd.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android MSD: {}", err);
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.otg_service.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android OTG: {}", err);
|
||||
}
|
||||
if let Some(atx) = state.atx.write().await.as_mut() {
|
||||
if let Err(err) = atx.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android ATX: {}", err);
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.audio.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android audio: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
static INIT: OnceLock<()> = OnceLock::new();
|
||||
INIT.get_or_init(|| {
|
||||
let _ = tracing_log::LogTracer::init();
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "one_kvm=info,tower_http=info,webrtc_sctp=warn".into());
|
||||
let fmt_layer = tracing_subscriber::fmt::layer();
|
||||
if let Ok(path) = std::env::var("ONE_KVM_ANDROID_LOG_FILE") {
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(file) => {
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_ansi(false)
|
||||
.with_writer(Arc::new(file));
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt_layer)
|
||||
.with(file_layer)
|
||||
.try_init();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("failed to open Android Rust log file {path}: {err}");
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt_layer)
|
||||
.try_init();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt_layer)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_rustls_provider() {
|
||||
static INIT: OnceLock<()> = OnceLock::new();
|
||||
INIT.get_or_init(|| {
|
||||
let _ = CryptoProvider::install_default(ring::default_provider());
|
||||
});
|
||||
}
|
||||
4
src/runtime/mod.rs
Normal file
4
src/runtime/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! Runtime entry points for packaged service modes.
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
pub mod android;
|
||||
@@ -1,4 +1,5 @@
|
||||
use arc_swap::ArcSwap;
|
||||
#[cfg(feature = "desktop")]
|
||||
use parking_lot::Mutex as ParkingMutex;
|
||||
use parking_lot::RwLock as ParkingRwLock;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -6,13 +7,18 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, info, warn};
|
||||
#[cfg(feature = "desktop")]
|
||||
use tracing::debug;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Generation token paired with `client_id` so [`unregister_client`] ignores stale drops.
|
||||
pub type ClientGeneration = u64;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
use crate::video::codec::traits::{Encoder, EncoderConfig};
|
||||
#[cfg(feature = "desktop")]
|
||||
use crate::video::codec::JpegEncoder;
|
||||
#[cfg(feature = "desktop")]
|
||||
use crate::video::format::PixelFormat;
|
||||
use crate::video::VideoFrame;
|
||||
|
||||
@@ -108,6 +114,7 @@ pub struct MjpegStreamHandler {
|
||||
last_frame_ts: ParkingRwLock<Option<Instant>>,
|
||||
dropped_same_frames: AtomicU64,
|
||||
max_drop_same_frames: AtomicU64,
|
||||
#[cfg(feature = "desktop")]
|
||||
jpeg_encoder: ParkingMutex<Option<JpegEncoder>>,
|
||||
jpeg_quality: AtomicU64,
|
||||
}
|
||||
@@ -126,6 +133,7 @@ impl MjpegStreamHandler {
|
||||
sequence: AtomicU64::new(0),
|
||||
clients: ParkingRwLock::new(HashMap::new()),
|
||||
next_generation: AtomicU64::new(1),
|
||||
#[cfg(feature = "desktop")]
|
||||
jpeg_encoder: ParkingMutex::new(None),
|
||||
auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()),
|
||||
last_frame_ts: ParkingRwLock::new(None),
|
||||
@@ -157,6 +165,7 @@ impl MjpegStreamHandler {
|
||||
}
|
||||
|
||||
let frame = if !frame.format.is_compressed() {
|
||||
#[cfg(feature = "desktop")]
|
||||
match self.encode_to_jpeg(&frame) {
|
||||
Ok(jpeg_frame) => jpeg_frame,
|
||||
Err(e) => {
|
||||
@@ -164,6 +173,13 @@ impl MjpegStreamHandler {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
{
|
||||
warn!(
|
||||
"Dropping non-JPEG frame for MJPEG stream on Android; native encoder is not wired yet"
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
frame
|
||||
};
|
||||
@@ -200,6 +216,7 @@ impl MjpegStreamHandler {
|
||||
let _ = self.frame_notify.send(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
fn encode_to_jpeg(&self, frame: &VideoFrame) -> Result<VideoFrame, String> {
|
||||
let resolution = frame.resolution;
|
||||
let sequence = self.sequence.load(Ordering::Relaxed);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! MJPEG multipart streaming and WebSocket HID (for MJPEG mode).
|
||||
|
||||
pub mod mjpeg;
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod ws_hid;
|
||||
|
||||
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
||||
#[cfg(feature = "desktop")]
|
||||
pub use ws_hid::WsHidHandler;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
pub mod fs;
|
||||
pub mod host;
|
||||
#[cfg(unix)]
|
||||
#[cfg(all(unix, not(target_os = "android")))]
|
||||
pub mod net;
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(any(not(unix), target_os = "android"))]
|
||||
#[path = "net_disabled.rs"]
|
||||
pub mod net;
|
||||
pub mod serial;
|
||||
|
||||
122
src/video/codec/android_mediacodec.rs
Normal file
122
src/video/codec/android_mediacodec.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Android FFmpeg/MediaCodec encoder glue.
|
||||
|
||||
use bytes::Bytes;
|
||||
use hwcodec::common::{Quality, RateControl};
|
||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
|
||||
pub struct AndroidMediaCodecH264Encoder {
|
||||
inner: HwEncoder,
|
||||
resolution: Resolution,
|
||||
input_format: PixelFormat,
|
||||
bitrate_kbps: u32,
|
||||
}
|
||||
|
||||
impl AndroidMediaCodecH264Encoder {
|
||||
pub fn new(
|
||||
resolution: Resolution,
|
||||
input_format: PixelFormat,
|
||||
fps: u32,
|
||||
bitrate_kbps: u32,
|
||||
) -> Result<Self> {
|
||||
let pixfmt = match input_format {
|
||||
PixelFormat::Nv12 => resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
PixelFormat::Yuv420 => {
|
||||
resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P)
|
||||
}
|
||||
other => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"FFmpeg h264_mediacodec accepts NV12/YUV420P memory frames; {other} requires conversion first"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: "h264_mediacodec".to_string(),
|
||||
mc_name: None,
|
||||
width: resolution.width as i32,
|
||||
height: resolution.height as i32,
|
||||
pixfmt,
|
||||
align: 1,
|
||||
fps: fps.max(1) as i32,
|
||||
gop: fps.max(1) as i32,
|
||||
rc: RateControl::RC_CBR,
|
||||
quality: Quality::Quality_Low,
|
||||
kbs: bitrate_kbps.max(1) as i32,
|
||||
q: 23,
|
||||
thread_count: 1,
|
||||
};
|
||||
|
||||
let inner = HwEncoder::new(ctx).map_err(|_| {
|
||||
AppError::VideoError("Failed to create FFmpeg h264_mediacodec encoder".to_string())
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
inner,
|
||||
resolution,
|
||||
input_format,
|
||||
bitrate_kbps: bitrate_kbps.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<AndroidH264Packet>> {
|
||||
let min_len = self
|
||||
.input_format
|
||||
.frame_size(self.resolution)
|
||||
.ok_or_else(|| AppError::VideoError("MediaCodec input must be raw YUV".to_string()))?;
|
||||
if data.len() < min_len {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"MediaCodec {} frame too small: {} < {}",
|
||||
self.input_format,
|
||||
data.len(),
|
||||
min_len
|
||||
)));
|
||||
}
|
||||
|
||||
let packets = self
|
||||
.inner
|
||||
.encode_bytes(data, pts_ms)
|
||||
.map_err(|err| AppError::VideoError(format!("h264_mediacodec encode failed: {err}")))?;
|
||||
|
||||
Ok(packets
|
||||
.into_iter()
|
||||
.map(|packet| AndroidH264Packet {
|
||||
data: packet.data,
|
||||
pts: packet.pts,
|
||||
key_frame: packet.key == 1,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.inner
|
||||
.set_bitrate(bitrate_kbps.max(1) as i32)
|
||||
.map_err(|_| AppError::VideoError("Failed to set MediaCodec bitrate".to_string()))?;
|
||||
self.bitrate_kbps = bitrate_kbps.max(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn request_keyframe(&mut self) {
|
||||
self.inner.request_keyframe();
|
||||
}
|
||||
|
||||
pub fn codec_name(&self) -> &str {
|
||||
"h264_mediacodec"
|
||||
}
|
||||
|
||||
pub fn input_format(&self) -> PixelFormat {
|
||||
self.input_format
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for AndroidMediaCodecH264Encoder {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AndroidH264Packet {
|
||||
pub data: Bytes,
|
||||
pub pts: i64,
|
||||
pub key_frame: bool,
|
||||
}
|
||||
137
src/video/codec/android_mjpeg.rs
Normal file
137
src/video/codec/android_mjpeg.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
//! Android FFmpeg/MediaCodec MJPEG decoder glue.
|
||||
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::codec::convert::Nv12Converter;
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
|
||||
pub struct AndroidMediaCodecMjpegDecoder {
|
||||
decoder: Decoder,
|
||||
resolution: Resolution,
|
||||
nv12_converter: Option<Nv12Converter>,
|
||||
last_output_format: Option<PixelFormat>,
|
||||
pending_frames: u32,
|
||||
}
|
||||
|
||||
impl AndroidMediaCodecMjpegDecoder {
|
||||
pub fn new(resolution: Resolution) -> Result<Self> {
|
||||
let ctx = DecodeContext {
|
||||
name: "mjpeg_mediacodec".to_string(),
|
||||
width: resolution.width as i32,
|
||||
height: resolution.height as i32,
|
||||
sw_pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
thread_count: 1,
|
||||
};
|
||||
let decoder = Decoder::new(ctx).map_err(|_| {
|
||||
AppError::VideoError("Failed to create FFmpeg mjpeg_mediacodec decoder".to_string())
|
||||
})?;
|
||||
Ok(Self {
|
||||
decoder,
|
||||
resolution,
|
||||
nv12_converter: None,
|
||||
last_output_format: None,
|
||||
pending_frames: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_to_nv12(&mut self, mjpeg: &[u8]) -> Result<Vec<u8>> {
|
||||
let frames = match self.decoder.decode(mjpeg) {
|
||||
Ok(frames) => frames,
|
||||
Err(err) if err == -11 => {
|
||||
self.pending_frames += 1;
|
||||
if self.pending_frames <= 3 {
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decode needs more input".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"mjpeg_mediacodec decode failed: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if frames.is_empty() {
|
||||
self.pending_frames += 1;
|
||||
if self.pending_frames <= 3 {
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decode needs more input".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
|
||||
));
|
||||
}
|
||||
self.pending_frames = 0;
|
||||
if frames.len() > 1 {
|
||||
warn!(
|
||||
"mjpeg_mediacodec decode returned {} frames, using last",
|
||||
frames.len()
|
||||
);
|
||||
}
|
||||
|
||||
let frame = frames.pop().ok_or_else(|| {
|
||||
AppError::VideoError("mjpeg_mediacodec decode returned empty".to_string())
|
||||
})?;
|
||||
|
||||
if frame.width as u32 != self.resolution.width
|
||||
|| frame.height as u32 != self.resolution.height
|
||||
{
|
||||
warn!(
|
||||
"mjpeg_mediacodec output size {}x{} differs from expected {}x{}",
|
||||
frame.width, frame.height, self.resolution.width, self.resolution.height
|
||||
);
|
||||
}
|
||||
|
||||
let output_format = pixel_format_from_av(frame.pixfmt).ok_or_else(|| {
|
||||
AppError::VideoError(format!(
|
||||
"mjpeg_mediacodec output pixfmt {:?} is not supported",
|
||||
frame.pixfmt
|
||||
))
|
||||
})?;
|
||||
|
||||
if self.last_output_format != Some(output_format) {
|
||||
info!("mjpeg_mediacodec output format: {}", output_format);
|
||||
self.last_output_format = Some(output_format);
|
||||
}
|
||||
|
||||
match output_format {
|
||||
PixelFormat::Nv12 => Ok(frame.data),
|
||||
PixelFormat::Nv21 => {
|
||||
let converter = self
|
||||
.nv12_converter
|
||||
.get_or_insert_with(|| Nv12Converter::nv21_to_nv12(self.resolution));
|
||||
Ok(converter.convert(&frame.data)?.to_vec())
|
||||
}
|
||||
PixelFormat::Yuv420 => {
|
||||
let converter = self
|
||||
.nv12_converter
|
||||
.get_or_insert_with(|| Nv12Converter::yuv420_to_nv12(self.resolution));
|
||||
Ok(converter.convert(&frame.data)?.to_vec())
|
||||
}
|
||||
other => Err(AppError::VideoError(format!(
|
||||
"mjpeg_mediacodec output {} cannot be converted to NV12",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel_format_from_av(format: AVPixelFormat) -> Option<PixelFormat> {
|
||||
match format {
|
||||
AVPixelFormat::AV_PIX_FMT_NV12 => Some(PixelFormat::Nv12),
|
||||
AVPixelFormat::AV_PIX_FMT_NV21 => Some(PixelFormat::Nv21),
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P | AVPixelFormat::AV_PIX_FMT_YUVJ420P => {
|
||||
Some(PixelFormat::Yuv420)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for AndroidMediaCodecMjpegDecoder {}
|
||||
@@ -539,8 +539,46 @@ pub struct Nv12Converter {
|
||||
resolution: Resolution,
|
||||
/// Output buffer (reused across conversions)
|
||||
output_buffer: Nv12Buffer,
|
||||
/// Optional I420 buffer for intermediate conversions
|
||||
i420_buffer: Option<Yuv420pBuffer>,
|
||||
}
|
||||
|
||||
/// MJPEG decoder that writes NV12 directly using libyuv.
|
||||
pub struct MjpegToNv12Decoder {
|
||||
resolution: Resolution,
|
||||
output_buffer: Nv12Buffer,
|
||||
size_checked: bool,
|
||||
}
|
||||
|
||||
impl MjpegToNv12Decoder {
|
||||
pub fn new(resolution: Resolution) -> Self {
|
||||
Self {
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
size_checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode(&mut self, input: &[u8]) -> Result<&[u8]> {
|
||||
let width = self.resolution.width as i32;
|
||||
let height = self.resolution.height as i32;
|
||||
|
||||
if !self.size_checked {
|
||||
let (src_width, src_height) = libyuv::mjpg_size(input).map_err(|e| {
|
||||
AppError::VideoError(format!("libyuv MJPEG header read failed: {}", e))
|
||||
})?;
|
||||
if src_width != width || src_height != height {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"libyuv MJPEG size mismatch: {}x{} (expected {}x{})",
|
||||
src_width, src_height, width, height
|
||||
)));
|
||||
}
|
||||
self.size_checked = true;
|
||||
}
|
||||
|
||||
libyuv::mjpg_to_nv12(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv MJPEG->NV12 failed: {}", e)))?;
|
||||
|
||||
Ok(self.output_buffer.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl Nv12Converter {
|
||||
@@ -550,7 +588,6 @@ impl Nv12Converter {
|
||||
src_format: PixelFormat::Bgr24,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +597,6 @@ impl Nv12Converter {
|
||||
src_format: PixelFormat::Rgb24,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +606,6 @@ impl Nv12Converter {
|
||||
src_format: PixelFormat::Yuyv,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,7 +615,6 @@ impl Nv12Converter {
|
||||
src_format: PixelFormat::Yuv420,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,7 +624,6 @@ impl Nv12Converter {
|
||||
src_format: PixelFormat::Nv21,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: Some(Yuv420pBuffer::new(resolution)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,7 +633,6 @@ impl Nv12Converter {
|
||||
src_format: PixelFormat::Nv16,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +642,6 @@ impl Nv12Converter {
|
||||
src_format: PixelFormat::Nv24,
|
||||
resolution,
|
||||
output_buffer: Nv12Buffer::new(resolution),
|
||||
i420_buffer: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,23 +652,6 @@ impl Nv12Converter {
|
||||
|
||||
// Handle formats that need custom conversion without holding dst borrow
|
||||
match self.src_format {
|
||||
PixelFormat::Nv21 => {
|
||||
let mut i420 = self.i420_buffer.take().ok_or_else(|| {
|
||||
AppError::VideoError("NV21 I420 buffer not initialized".to_string())
|
||||
})?;
|
||||
{
|
||||
let dst = self.output_buffer.as_bytes_mut();
|
||||
Self::convert_nv21_to_nv12_with_dims(
|
||||
self.resolution.width as usize,
|
||||
self.resolution.height as usize,
|
||||
input,
|
||||
dst,
|
||||
&mut i420,
|
||||
)?;
|
||||
}
|
||||
self.i420_buffer = Some(i420);
|
||||
return Ok(self.output_buffer.as_bytes());
|
||||
}
|
||||
PixelFormat::Nv16 => {
|
||||
let dst = self.output_buffer.as_bytes_mut();
|
||||
Self::convert_nv16_to_nv12_with_dims(
|
||||
@@ -667,6 +681,7 @@ impl Nv12Converter {
|
||||
PixelFormat::Rgb24 => libyuv::rgb24_to_nv12(input, dst, width, height),
|
||||
PixelFormat::Yuyv => libyuv::yuy2_to_nv12(input, dst, width, height),
|
||||
PixelFormat::Yuv420 => libyuv::i420_to_nv12(input, dst, width, height),
|
||||
PixelFormat::Nv21 => libyuv::nv21_to_nv12(input, dst, width, height),
|
||||
_ => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Unsupported conversion to NV12: {}",
|
||||
@@ -680,21 +695,6 @@ impl Nv12Converter {
|
||||
Ok(self.output_buffer.as_bytes())
|
||||
}
|
||||
|
||||
fn convert_nv21_to_nv12_with_dims(
|
||||
width: usize,
|
||||
height: usize,
|
||||
input: &[u8],
|
||||
dst: &mut [u8],
|
||||
yuv: &mut Yuv420pBuffer,
|
||||
) -> Result<()> {
|
||||
libyuv::nv21_to_i420(input, yuv.as_bytes_mut(), width as i32, height as i32)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv NV21->I420 failed: {}", e)))?;
|
||||
libyuv::i420_to_nv12(yuv.as_bytes(), dst, width as i32, height as i32)
|
||||
.map_err(|e| AppError::VideoError(format!("libyuv I420->NV12 failed: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_nv16_to_nv12_with_dims(
|
||||
width: usize,
|
||||
height: usize,
|
||||
|
||||
@@ -48,6 +48,8 @@ pub enum H264EncoderType {
|
||||
Rkmpp,
|
||||
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
||||
V4l2M2m,
|
||||
/// Android MediaCodec via FFmpeg
|
||||
MediaCodec,
|
||||
/// Software encoding (libx264/openh264)
|
||||
Software,
|
||||
/// No encoder available
|
||||
@@ -64,6 +66,7 @@ impl std::fmt::Display for H264EncoderType {
|
||||
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||
H264EncoderType::MediaCodec => write!(f, "MediaCodec"),
|
||||
H264EncoderType::Software => write!(f, "Software"),
|
||||
H264EncoderType::None => write!(f, "None"),
|
||||
}
|
||||
@@ -80,6 +83,7 @@ impl From<EncoderBackend> for H264EncoderType {
|
||||
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
||||
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
||||
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
||||
EncoderBackend::MediaCodec => H264EncoderType::MediaCodec,
|
||||
EncoderBackend::Software => H264EncoderType::Software,
|
||||
}
|
||||
}
|
||||
@@ -224,10 +228,10 @@ pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option<
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoded frame from hwcodec (cloned for ownership)
|
||||
/// Encoded frame from hwcodec.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HwEncodeFrame {
|
||||
pub data: Vec<u8>,
|
||||
pub data: Bytes,
|
||||
pub pts: i64,
|
||||
pub key: i32,
|
||||
}
|
||||
@@ -372,14 +376,12 @@ impl H264Encoder {
|
||||
|
||||
self.frame_count += 1;
|
||||
|
||||
match self.inner.encode(data, pts_ms) {
|
||||
match self.inner.encode_bytes(data, pts_ms) {
|
||||
Ok(frames) => {
|
||||
// Zero-copy: drain frames from hwcodec buffer instead of cloning
|
||||
// hwcodec returns &mut Vec, so we can take ownership via drain
|
||||
let owned_frames: Vec<HwEncodeFrame> = frames
|
||||
.drain(..)
|
||||
.into_iter()
|
||||
.map(|f| HwEncodeFrame {
|
||||
data: f.data, // Move, not clone
|
||||
data: f.data,
|
||||
pts: f.pts,
|
||||
key: f.key,
|
||||
})
|
||||
|
||||
@@ -45,6 +45,8 @@ pub enum H265EncoderType {
|
||||
Rkmpp,
|
||||
/// V4L2 M2M (ARM generic)
|
||||
V4l2M2m,
|
||||
/// Android MediaCodec via FFmpeg
|
||||
MediaCodec,
|
||||
/// Software encoder (libx265)
|
||||
Software,
|
||||
/// No encoder available
|
||||
@@ -61,6 +63,7 @@ impl std::fmt::Display for H265EncoderType {
|
||||
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||
H265EncoderType::MediaCodec => write!(f, "MediaCodec"),
|
||||
H265EncoderType::Software => write!(f, "Software"),
|
||||
H265EncoderType::None => write!(f, "None"),
|
||||
}
|
||||
@@ -76,6 +79,7 @@ impl From<EncoderBackend> for H265EncoderType {
|
||||
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
||||
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
||||
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
||||
EncoderBackend::MediaCodec => H265EncoderType::MediaCodec,
|
||||
EncoderBackend::Software => H265EncoderType::Software,
|
||||
}
|
||||
}
|
||||
@@ -243,10 +247,10 @@ pub fn is_h265_available() -> bool {
|
||||
registry.is_codec_available(VideoEncoderType::H265)
|
||||
}
|
||||
|
||||
/// Encoded frame from hwcodec (cloned for ownership)
|
||||
/// Encoded frame from hwcodec.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HwEncodeFrame {
|
||||
pub data: Vec<u8>,
|
||||
pub data: Bytes,
|
||||
pub pts: i64,
|
||||
pub key: i32,
|
||||
}
|
||||
@@ -465,13 +469,12 @@ impl H265Encoder {
|
||||
);
|
||||
}
|
||||
|
||||
match self.inner.encode(data, pts_ms) {
|
||||
match self.inner.encode_bytes(data, pts_ms) {
|
||||
Ok(frames) => {
|
||||
// Zero-copy: drain frames from hwcodec buffer instead of cloning
|
||||
let owned_frames: Vec<HwEncodeFrame> = frames
|
||||
.drain(..)
|
||||
.into_iter()
|
||||
.map(|f| HwEncodeFrame {
|
||||
data: f.data, // Move, not clone
|
||||
data: f.data,
|
||||
pts: f.pts,
|
||||
key: f.key,
|
||||
})
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//! MJPEG decoder using TurboJPEG (software) -> RGB24.
|
||||
|
||||
use turbojpeg::{Decompressor, Image, PixelFormat as TJPixelFormat};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::format::Resolution;
|
||||
|
||||
pub struct MjpegTurboDecoder {
|
||||
decompressor: Decompressor,
|
||||
resolution: Resolution,
|
||||
}
|
||||
|
||||
impl MjpegTurboDecoder {
|
||||
pub fn new(resolution: Resolution) -> Result<Self> {
|
||||
let decompressor = Decompressor::new().map_err(|e| {
|
||||
AppError::VideoError(format!("Failed to create turbojpeg decoder: {}", e))
|
||||
})?;
|
||||
Ok(Self {
|
||||
decompressor,
|
||||
resolution,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_to_rgb(&mut self, mjpeg: &[u8]) -> Result<Vec<u8>> {
|
||||
let header = self
|
||||
.decompressor
|
||||
.read_header(mjpeg)
|
||||
.map_err(|e| AppError::VideoError(format!("turbojpeg read_header failed: {}", e)))?;
|
||||
|
||||
if header.width as u32 != self.resolution.width
|
||||
|| header.height as u32 != self.resolution.height
|
||||
{
|
||||
return Err(AppError::VideoError(format!(
|
||||
"turbojpeg size mismatch: {}x{} (expected {}x{})",
|
||||
header.width, header.height, self.resolution.width, self.resolution.height
|
||||
)));
|
||||
}
|
||||
|
||||
let pitch = header.width * 3;
|
||||
let mut image = Image {
|
||||
pixels: vec![0u8; header.height * pitch],
|
||||
width: header.width,
|
||||
pitch,
|
||||
height: header.height,
|
||||
format: TJPixelFormat::RGB,
|
||||
};
|
||||
|
||||
self.decompressor
|
||||
.decompress(mjpeg, image.as_deref_mut())
|
||||
.map_err(|e| AppError::VideoError(format!("turbojpeg decode failed: {}", e)))?;
|
||||
|
||||
Ok(image.pixels)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
use hwcodec::common::DataFormat;
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub mod android_mediacodec;
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub mod android_mjpeg;
|
||||
pub mod convert;
|
||||
|
||||
pub mod h264;
|
||||
@@ -16,16 +20,17 @@ pub mod video_codec;
|
||||
pub mod vp8;
|
||||
pub mod vp9;
|
||||
|
||||
pub mod mjpeg_turbo;
|
||||
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
|
||||
pub mod mjpeg_rkmpp;
|
||||
|
||||
pub use convert::{PixelConverter, Yuv420pBuffer};
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub use android_mediacodec::{AndroidH264Packet, AndroidMediaCodecH264Encoder};
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub use android_mjpeg::AndroidMediaCodecMjpegDecoder;
|
||||
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
|
||||
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
||||
pub use jpeg::JpegEncoder;
|
||||
pub use mjpeg_turbo::MjpegTurboDecoder;
|
||||
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
pub use self_check::{
|
||||
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
|
||||
|
||||
@@ -96,6 +96,8 @@ pub enum EncoderBackend {
|
||||
Rkmpp,
|
||||
/// V4L2 Memory-to-Memory (ARM)
|
||||
V4l2m2m,
|
||||
/// Android MediaCodec via FFmpeg
|
||||
MediaCodec,
|
||||
/// Software encoding (libx264, libx265, libvpx)
|
||||
Software,
|
||||
}
|
||||
@@ -115,6 +117,8 @@ impl EncoderBackend {
|
||||
EncoderBackend::Rkmpp
|
||||
} else if name.contains("v4l2m2m") {
|
||||
EncoderBackend::V4l2m2m
|
||||
} else if name.contains("mediacodec") {
|
||||
EncoderBackend::MediaCodec
|
||||
} else {
|
||||
EncoderBackend::Software
|
||||
}
|
||||
@@ -134,6 +138,7 @@ impl EncoderBackend {
|
||||
EncoderBackend::Amf => "AMF",
|
||||
EncoderBackend::Rkmpp => "RKMPP",
|
||||
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
||||
EncoderBackend::MediaCodec => "MediaCodec",
|
||||
EncoderBackend::Software => "Software",
|
||||
}
|
||||
}
|
||||
@@ -148,6 +153,7 @@ impl EncoderBackend {
|
||||
"amf" => Some(EncoderBackend::Amf),
|
||||
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
||||
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
||||
"mediacodec" | "android-mediacodec" => Some(EncoderBackend::MediaCodec),
|
||||
"software" | "cpu" => Some(EncoderBackend::Software),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use serde::Serialize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
use super::AndroidMediaCodecH264Encoder;
|
||||
use super::{
|
||||
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
||||
VP9Config, VP9Encoder, VideoEncoderType,
|
||||
@@ -235,6 +237,32 @@ fn run_smoke_test(
|
||||
}
|
||||
|
||||
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
if codec_name_ffmpeg == "h264_mediacodec" {
|
||||
let mut encoder = AndroidMediaCodecH264Encoder::new(
|
||||
resolution,
|
||||
PixelFormat::Nv12,
|
||||
30,
|
||||
bitrate_kbps_for_resolution(resolution),
|
||||
)?;
|
||||
encoder.request_keyframe();
|
||||
let frame = build_nv12_test_frame(
|
||||
resolution,
|
||||
PixelFormat::Nv12.frame_size(resolution).unwrap_or(0),
|
||||
);
|
||||
|
||||
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
return Err(AppError::VideoError(
|
||||
"Encoder produced no output after multiple frames".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut encoder = H264Encoder::with_codec(
|
||||
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||
codec_name_ffmpeg,
|
||||
|
||||
@@ -898,6 +898,17 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
||||
candidates.push(path);
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
let sysfs_entries = video_node_names("/sys/class/video4linux");
|
||||
let dev_entries = video_node_names("/dev");
|
||||
warn!(
|
||||
"No video probe candidates after sysfs filter; /dev={:?}, /sys/class/video4linux={:?}",
|
||||
dev_entries, sysfs_entries
|
||||
);
|
||||
} else {
|
||||
debug!("Video probe candidates: {:?}", candidates);
|
||||
}
|
||||
|
||||
collapse_rkcif_probe_candidates(&mut candidates);
|
||||
|
||||
// Second pass: probe the remaining candidates in parallel. Each probe
|
||||
@@ -952,11 +963,35 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
||||
// for a single MIPI CSI pipeline. Keep only the highest-priority node per
|
||||
// (driver, bus_info) group so users see one device instead of ~11.
|
||||
dedup_platform_subdevices(&mut devices);
|
||||
devices.retain(|device| {
|
||||
let hide = should_hide_android_platform_node(device);
|
||||
if hide {
|
||||
debug!(
|
||||
"Hiding Android platform video node: {} ({}) {}",
|
||||
device.name,
|
||||
device.driver,
|
||||
device.path.display()
|
||||
);
|
||||
}
|
||||
!hide
|
||||
});
|
||||
|
||||
info!("Found {} video capture devices", devices.len());
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn video_node_names(dir: &str) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||
.filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
|
||||
.filter(|name| name.starts_with("video"))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn select_recovery_device(
|
||||
devices: &[VideoDeviceInfo],
|
||||
hint: &VideoDeviceRecoveryHint,
|
||||
@@ -1020,6 +1055,33 @@ fn dedup_platform_subdevices(devices: &mut Vec<VideoDeviceInfo>) {
|
||||
});
|
||||
}
|
||||
|
||||
fn should_hide_android_platform_node(device: &VideoDeviceInfo) -> bool {
|
||||
if !cfg!(feature = "android") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let driver = device.driver.to_ascii_lowercase();
|
||||
let name = device.name.to_ascii_lowercase();
|
||||
let card = device.card.to_ascii_lowercase();
|
||||
let usb_device = driver == "uvcvideo" || device.bus_info.starts_with("usb-");
|
||||
let known_bridge =
|
||||
driver.contains("rkcif") || driver.contains("rk_hdmirx") || driver.contains("tc358743");
|
||||
if usb_device || known_bridge {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(
|
||||
driver.as_str(),
|
||||
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||
) || matches!(
|
||||
name.as_str(),
|
||||
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||
) || matches!(
|
||||
card.as_str(),
|
||||
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||
)
|
||||
}
|
||||
|
||||
/// rkcif registers many `/dev/video*` queues; probing all in parallel can
|
||||
/// contend and time out. Keep one node per board (lowest `videoN`).
|
||||
fn collapse_rkcif_probe_candidates(candidates: &mut Vec<PathBuf>) {
|
||||
@@ -1123,6 +1185,20 @@ fn sysfs_maybe_capture(path: &Path) -> bool {
|
||||
.to_lowercase();
|
||||
let driver = extract_uevent_value(&uevent, "driver");
|
||||
|
||||
if cfg!(feature = "android") {
|
||||
let platform_skip = ["ionvideo", "amlvideo", "amlvideo2", "videosync"];
|
||||
let driver_skip = driver
|
||||
.as_ref()
|
||||
.is_some_and(|driver| platform_skip.iter().any(|hint| driver == hint));
|
||||
if driver_skip || platform_skip.iter().any(|hint| sysfs_name == *hint) {
|
||||
debug!(
|
||||
"Skipping Android platform video node {:?}: {}",
|
||||
path, sysfs_name
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let mut maybe_capture = false;
|
||||
let capture_hints = [
|
||||
"capture",
|
||||
|
||||
@@ -6,7 +6,10 @@ mod linux;
|
||||
mod windows;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use linux::*;
|
||||
pub use linux::{
|
||||
enumerate_devices, find_best_device, select_recovery_device, VideoDevice, VideoDeviceInfo,
|
||||
VideoDeviceRecoveryHint,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
pub use windows::*;
|
||||
|
||||
@@ -33,3 +36,6 @@ pub(crate) fn is_rkcif_driver(driver: &str) -> bool {
|
||||
pub(crate) fn is_csi_hdmi_bridge(device: &VideoDeviceInfo) -> bool {
|
||||
is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) use linux::parse_bridge_kind;
|
||||
|
||||
@@ -8,13 +8,21 @@ pub mod codec_constraints;
|
||||
pub mod device;
|
||||
pub mod format;
|
||||
pub mod frame;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod pipeline;
|
||||
pub mod signal;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod stream_manager;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod streamer;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod traits;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod types;
|
||||
|
||||
pub use capture::{CaptureMeta, CaptureStream};
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub use codec::{AndroidH264Packet, AndroidMediaCodecH264Encoder};
|
||||
pub use codec::{H264Encoder, H264EncoderType, JpegEncoder, PixelConverter, Yuv420pBuffer};
|
||||
pub use device::{VideoDevice, VideoDeviceInfo};
|
||||
pub use format::PixelFormat;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::codec::convert::{Nv12Converter, PixelConverter};
|
||||
use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter};
|
||||
use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat};
|
||||
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
|
||||
use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||
use crate::video::codec::traits::EncoderConfig;
|
||||
use crate::video::codec::vp8::{VP8Config, VP8Encoder};
|
||||
use crate::video::codec::vp9::{VP9Config, VP9Encoder};
|
||||
use crate::video::codec::MjpegTurboDecoder;
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
use crate::video::codec::AndroidMediaCodecH264Encoder;
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
use crate::video::codec::AndroidMediaCodecMjpegDecoder;
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
use bytes::Bytes;
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
use hwcodec::ffmpeg_hw::{
|
||||
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
|
||||
};
|
||||
@@ -22,9 +29,15 @@ pub(super) struct EncoderThreadState {
|
||||
pub(super) nv12_converter: Option<Nv12Converter>,
|
||||
pub(super) yuv420p_converter: Option<PixelConverter>,
|
||||
pub(super) encoder_needs_yuv420p: bool,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
pub(super) ffmpeg_hw_enabled: bool,
|
||||
pub(super) fps: u32,
|
||||
pub(super) codec: VideoEncoderType,
|
||||
@@ -39,7 +52,7 @@ pub(super) trait VideoEncoderTrait: Send {
|
||||
}
|
||||
|
||||
pub(super) struct EncodedFrame {
|
||||
pub(super) data: Vec<u8>,
|
||||
pub(super) data: Bytes,
|
||||
pub(super) key: i32,
|
||||
}
|
||||
|
||||
@@ -70,6 +83,25 @@ impl VideoEncoderTrait for H264EncoderWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_h264_encoder(
|
||||
config: &SharedVideoPipelineConfig,
|
||||
input_format: H264InputFormat,
|
||||
codec_name: &str,
|
||||
) -> Result<Box<dyn VideoEncoderTrait + Send>> {
|
||||
let encoder = H264Encoder::with_codec(
|
||||
H264Config {
|
||||
base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()),
|
||||
bitrate_kbps: config.bitrate_kbps(),
|
||||
gop_size: config.gop_size(),
|
||||
fps: config.fps,
|
||||
input_format,
|
||||
},
|
||||
codec_name,
|
||||
)?;
|
||||
info!("Created H264 encoder: {}", encoder.codec_name());
|
||||
Ok(Box::new(H264EncoderWrapper(encoder)))
|
||||
}
|
||||
|
||||
struct H265EncoderWrapper(H265Encoder);
|
||||
|
||||
impl VideoEncoderTrait for H265EncoderWrapper {
|
||||
@@ -97,6 +129,35 @@ impl VideoEncoderTrait for H265EncoderWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
struct AndroidMediaCodecH264EncoderWrapper(AndroidMediaCodecH264Encoder);
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
impl VideoEncoderTrait for AndroidMediaCodecH264EncoderWrapper {
|
||||
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
|
||||
let frames = self.0.encode_raw(data, pts_ms)?;
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
key: if f.key_frame { 1 } else { 0 },
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.0.set_bitrate(bitrate_kbps)
|
||||
}
|
||||
|
||||
fn codec_name(&self) -> &str {
|
||||
self.0.codec_name()
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {
|
||||
self.0.request_keyframe()
|
||||
}
|
||||
}
|
||||
|
||||
struct VP8EncoderWrapper(VP8Encoder);
|
||||
|
||||
impl VideoEncoderTrait for VP8EncoderWrapper {
|
||||
@@ -105,7 +166,7 @@ impl VideoEncoderTrait for VP8EncoderWrapper {
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
data: f.data.into(),
|
||||
key: f.key,
|
||||
})
|
||||
.collect())
|
||||
@@ -130,7 +191,7 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
data: f.data.into(),
|
||||
key: f.key,
|
||||
})
|
||||
.collect())
|
||||
@@ -148,17 +209,100 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
|
||||
}
|
||||
|
||||
pub(super) enum MjpegDecoderKind {
|
||||
Turbo(MjpegTurboDecoder),
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
AndroidMediaCodec {
|
||||
decoder: AndroidMediaCodecMjpegDecoder,
|
||||
fallback: Box<MjpegDecoderKind>,
|
||||
fallback_active: bool,
|
||||
output: Vec<u8>,
|
||||
},
|
||||
Libyuv {
|
||||
decoder: MjpegToNv12Decoder,
|
||||
},
|
||||
}
|
||||
|
||||
impl MjpegDecoderKind {
|
||||
pub(super) fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> {
|
||||
pub(super) fn decode(&mut self, data: &[u8]) -> Result<&[u8]> {
|
||||
match self {
|
||||
MjpegDecoderKind::Turbo(decoder) => decoder.decode_to_rgb(data),
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
MjpegDecoderKind::AndroidMediaCodec {
|
||||
decoder,
|
||||
fallback,
|
||||
fallback_active,
|
||||
output,
|
||||
} => {
|
||||
if !*fallback_active {
|
||||
match decoder.decode_to_nv12(data) {
|
||||
Ok(decoded) => {
|
||||
*output = decoded;
|
||||
return Ok(output.as_slice());
|
||||
}
|
||||
Err(AppError::VideoError(message))
|
||||
if message.contains("needs more input") =>
|
||||
{
|
||||
return Err(AppError::VideoError(message));
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Android MediaCodec MJPEG decode failed; falling back to libyuv MJPEG->NV12: {}",
|
||||
err
|
||||
);
|
||||
*fallback_active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback.decode(data)
|
||||
}
|
||||
MjpegDecoderKind::Libyuv { decoder } => decoder.decode(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn libyuv_mjpeg_decoder(resolution: Resolution) -> MjpegDecoderKind {
|
||||
MjpegDecoderKind::Libyuv {
|
||||
decoder: MjpegToNv12Decoder::new(resolution),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, PixelFormat)> {
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
{
|
||||
if std::env::var_os("ONE_KVM_ANDROID_MJPEG_MEDIACODEC").is_none() {
|
||||
info!("MJPEG input detected, using libyuv decoder (MJPEG -> NV12)");
|
||||
return Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12));
|
||||
}
|
||||
|
||||
info!("MJPEG input detected, trying Android MediaCodec decoder (MJPEG -> NV12)");
|
||||
match AndroidMediaCodecMjpegDecoder::new(resolution) {
|
||||
Ok(decoder) => {
|
||||
info!("Using Android MediaCodec MJPEG decoder");
|
||||
return Ok((
|
||||
MjpegDecoderKind::AndroidMediaCodec {
|
||||
decoder,
|
||||
fallback: Box::new(libyuv_mjpeg_decoder(resolution)),
|
||||
fallback_active: false,
|
||||
output: Vec::with_capacity(
|
||||
PixelFormat::Nv12
|
||||
.frame_size(resolution)
|
||||
.unwrap_or((resolution.width * resolution.height * 3 / 2) as usize),
|
||||
),
|
||||
},
|
||||
PixelFormat::Nv12,
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Android MediaCodec MJPEG decoder unavailable; using libyuv MJPEG->NV12: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("MJPEG input detected, using libyuv decoder (MJPEG -> NV12)");
|
||||
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
|
||||
}
|
||||
|
||||
pub(super) fn build_encoder_state(
|
||||
config: &SharedVideoPipelineConfig,
|
||||
) -> Result<EncoderThreadState> {
|
||||
@@ -256,9 +400,15 @@ pub(super) fn build_encoder_state(
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
if needs_mjpeg_decode
|
||||
&& is_rkmpp_encoder
|
||||
&& matches!(
|
||||
@@ -298,9 +448,15 @@ pub(super) fn build_encoder_state(
|
||||
nv12_converter: None,
|
||||
yuv420p_converter: None,
|
||||
encoder_needs_yuv420p: false,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
ffmpeg_hw_pipeline: Some(pipeline),
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
ffmpeg_hw_enabled: true,
|
||||
fps: config.fps,
|
||||
codec: config.output_codec,
|
||||
@@ -309,16 +465,8 @@ pub(super) fn build_encoder_state(
|
||||
}
|
||||
|
||||
let (mjpeg_decoder, pipeline_input_format) = if needs_mjpeg_decode {
|
||||
info!(
|
||||
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
|
||||
config.input_format
|
||||
);
|
||||
(
|
||||
Some(MjpegDecoderKind::Turbo(MjpegTurboDecoder::new(
|
||||
config.resolution,
|
||||
)?)),
|
||||
PixelFormat::Rgb24,
|
||||
)
|
||||
let (decoder, format) = create_mjpeg_decoder(config.resolution)?;
|
||||
(Some(decoder), format)
|
||||
} else {
|
||||
(None, config.input_format)
|
||||
};
|
||||
@@ -347,18 +495,40 @@ pub(super) fn build_encoder_state(
|
||||
);
|
||||
}
|
||||
|
||||
let encoder = H264Encoder::with_codec(
|
||||
H264Config {
|
||||
base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()),
|
||||
bitrate_kbps: config.bitrate_kbps(),
|
||||
gop_size: config.gop_size(),
|
||||
fps: config.fps,
|
||||
input_format,
|
||||
},
|
||||
&codec_name,
|
||||
)?;
|
||||
info!("Created H264 encoder: {}", encoder.codec_name());
|
||||
Box::new(H264EncoderWrapper(encoder))
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
{
|
||||
if codec_name == "h264_mediacodec" {
|
||||
info!(
|
||||
"Creating Android MediaCodec H264 encoder for {:?} input",
|
||||
input_format
|
||||
);
|
||||
let pixel_format = match input_format {
|
||||
H264InputFormat::Nv12 => PixelFormat::Nv12,
|
||||
H264InputFormat::Yuv420p => PixelFormat::Yuv420,
|
||||
other => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Android MediaCodec H264 does not support {:?} direct input",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
let encoder = AndroidMediaCodecH264Encoder::new(
|
||||
config.resolution,
|
||||
pixel_format,
|
||||
config.fps,
|
||||
config.bitrate_kbps(),
|
||||
)?;
|
||||
info!("Created Android MediaCodec H264 encoder");
|
||||
Box::new(AndroidMediaCodecH264EncoderWrapper(encoder))
|
||||
} else {
|
||||
create_h264_encoder(config, input_format, &codec_name)?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "android-mediacodec"))]
|
||||
{
|
||||
create_h264_encoder(config, input_format, &codec_name)?
|
||||
}
|
||||
}
|
||||
VideoEncoderType::H265 => {
|
||||
let codec_name = selected_codec_name.clone();
|
||||
@@ -452,6 +622,11 @@ pub(super) fn build_encoder_state(
|
||||
pipeline_input_format,
|
||||
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
||||
)
|
||||
} else if codec_name.contains("mediacodec") {
|
||||
matches!(
|
||||
pipeline_input_format,
|
||||
PixelFormat::Nv12 | PixelFormat::Yuv420
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -501,9 +676,15 @@ pub(super) fn build_encoder_state(
|
||||
nv12_converter,
|
||||
yuv420p_converter,
|
||||
encoder_needs_yuv420p: needs_yuv420p,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
ffmpeg_hw_pipeline: None,
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
ffmpeg_hw_enabled: false,
|
||||
fps: config.fps,
|
||||
codec: config.output_codec,
|
||||
@@ -527,6 +708,12 @@ fn h264_direct_input_format(
|
||||
PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
|
||||
_ => None,
|
||||
}
|
||||
} else if codec_name.contains("mediacodec") {
|
||||
match input_format {
|
||||
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
|
||||
_ => None,
|
||||
}
|
||||
} else if codec_name.contains("libx264") {
|
||||
match input_format {
|
||||
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||
|
||||
@@ -38,6 +38,7 @@ const CSI_BRIDGE_NOSIGNAL_INTERVAL_MS: u64 = 500;
|
||||
const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20);
|
||||
/// Throttle repeated encoding errors to avoid log flooding
|
||||
const ENCODE_ERROR_THROTTLE_SECS: u64 = 5;
|
||||
const INVALID_MJPEG_LOG_THROTTLE_SECS: u64 = 5;
|
||||
|
||||
static PROCESS_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||
|
||||
@@ -60,7 +61,10 @@ use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
||||
use crate::video::signal::SignalStatus;
|
||||
|
||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
|
||||
|
||||
/// Encoded video frame for distribution
|
||||
@@ -480,9 +484,15 @@ impl SharedVideoPipeline {
|
||||
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
|
||||
match cmd {
|
||||
PipelineCmd::SetBitrate { bitrate_kbps, gop } => {
|
||||
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
|
||||
#[cfg(any(
|
||||
not(any(target_arch = "aarch64", target_arch = "arm")),
|
||||
target_os = "android"
|
||||
))]
|
||||
let _ = gop;
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
if state.ffmpeg_hw_enabled {
|
||||
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
|
||||
pipeline
|
||||
@@ -649,12 +659,14 @@ impl SharedVideoPipeline {
|
||||
*guard = Some(cmd_tx);
|
||||
}
|
||||
|
||||
// Encoder loop (runs on tokio, consumes latest frame)
|
||||
// Encoder loop uses a dedicated OS thread because FFmpeg/MediaCodec work is synchronous.
|
||||
{
|
||||
let pipeline = pipeline.clone();
|
||||
let latest_frame = latest_frame.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut frame_count: u64 = 0;
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
std::thread::spawn(move || {
|
||||
let mut input_frame_count: u64 = 0;
|
||||
let mut encoded_frame_count: u64 = 0;
|
||||
let mut last_fps_time = Instant::now();
|
||||
let mut fps_frame_count: u64 = 0;
|
||||
let mut last_seq = *frame_seq_rx.borrow();
|
||||
@@ -662,7 +674,7 @@ impl SharedVideoPipeline {
|
||||
let mut suppressed_encode_errors: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
while pipeline.running_flag.load(Ordering::Acquire) {
|
||||
if frame_seq_rx.changed().await.is_err() {
|
||||
if handle.block_on(frame_seq_rx.changed()).is_err() {
|
||||
break;
|
||||
}
|
||||
if !pipeline.running_flag.load(Ordering::Acquire) {
|
||||
@@ -694,15 +706,19 @@ impl SharedVideoPipeline {
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match pipeline.encode_frame_sync(&mut encoder_state, &frame, frame_count) {
|
||||
Ok(Some(encoded_frame)) => {
|
||||
let encoded_arc = Arc::new(encoded_frame);
|
||||
pipeline.broadcast_encoded(encoded_arc).await;
|
||||
input_frame_count = input_frame_count.wrapping_add(1);
|
||||
|
||||
frame_count += 1;
|
||||
fps_frame_count += 1;
|
||||
match pipeline.encode_frame_sync(&mut encoder_state, &frame, input_frame_count)
|
||||
{
|
||||
Ok(encoded_frames) => {
|
||||
for encoded_frame in encoded_frames {
|
||||
let encoded_arc = Arc::new(encoded_frame);
|
||||
handle.block_on(pipeline.broadcast_encoded(encoded_arc));
|
||||
|
||||
encoded_frame_count = encoded_frame_count.wrapping_add(1);
|
||||
fps_frame_count += 1;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
log_encoding_error(
|
||||
&encode_error_throttler,
|
||||
@@ -718,8 +734,15 @@ impl SharedVideoPipeline {
|
||||
fps_frame_count = 0;
|
||||
last_fps_time = Instant::now();
|
||||
|
||||
let mut s = pipeline.stats.lock().await;
|
||||
s.current_fps = current_fps;
|
||||
handle.block_on(async {
|
||||
let mut s = pipeline.stats.lock().await;
|
||||
s.current_fps = current_fps;
|
||||
});
|
||||
trace!(
|
||||
"Shared pipeline processed {} input frames, emitted {} encoded frames",
|
||||
input_frame_count,
|
||||
encoded_frame_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,6 +870,8 @@ impl SharedVideoPipeline {
|
||||
let mut sequence: u64 = 0;
|
||||
let mut consecutive_timeouts: u32 = 0;
|
||||
let capture_error_throttler = LogThrottler::with_secs(5);
|
||||
let invalid_mjpeg_throttler =
|
||||
LogThrottler::with_secs(INVALID_MJPEG_LOG_THROTTLE_SECS);
|
||||
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
|
||||
|
||||
while pipeline.running_flag.load(Ordering::Acquire) {
|
||||
@@ -1207,6 +1232,20 @@ impl SharedVideoPipeline {
|
||||
}
|
||||
|
||||
owned.truncate(frame_size);
|
||||
if pixel_format.is_compressed() && !VideoFrame::is_valid_jpeg_bytes(&owned) {
|
||||
if invalid_mjpeg_throttler.should_log("invalid_mjpeg_capture_frame") {
|
||||
let b0 = owned.first().copied().unwrap_or_default();
|
||||
let b1 = owned.get(1).copied().unwrap_or_default();
|
||||
warn!(
|
||||
"Dropping invalid MJPEG capture frame: size={}, starts with 0x{:02x} 0x{:02x}",
|
||||
owned.len(),
|
||||
b0,
|
||||
b1
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Notify streaming only after frame validation passes —
|
||||
// stale/warm-up frames from V4L2 kernel queues can cause
|
||||
// DQBUF Ok with invalid data, which would prematurely
|
||||
@@ -1244,7 +1283,7 @@ impl SharedVideoPipeline {
|
||||
state: &mut EncoderThreadState,
|
||||
frame: &VideoFrame,
|
||||
frame_count: u64,
|
||||
) -> Result<Option<EncodedVideoFrame>> {
|
||||
) -> Result<Vec<EncodedVideoFrame>> {
|
||||
let fps = state.fps;
|
||||
let codec = state.codec;
|
||||
let input_format = state.input_format;
|
||||
@@ -1268,7 +1307,10 @@ impl SharedVideoPipeline {
|
||||
current_ts_us.saturating_sub(start_ts_us) / 1000
|
||||
};
|
||||
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
if state.ffmpeg_hw_enabled {
|
||||
if input_format != PixelFormat::Mjpeg {
|
||||
return Err(AppError::VideoError(
|
||||
@@ -1295,17 +1337,17 @@ impl SharedVideoPipeline {
|
||||
|
||||
if let Some((data, is_keyframe)) = packet {
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
return Ok(Some(EncodedVideoFrame {
|
||||
return Ok(vec![EncodedVideoFrame {
|
||||
data: Bytes::from(data),
|
||||
pts_ms,
|
||||
is_keyframe,
|
||||
sequence,
|
||||
duration: Duration::from_millis(1000 / fps as u64),
|
||||
codec,
|
||||
}));
|
||||
}]);
|
||||
}
|
||||
|
||||
return Ok(None);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let decoded_buf = if input_format.is_compressed() {
|
||||
@@ -1313,12 +1355,26 @@ impl SharedVideoPipeline {
|
||||
.mjpeg_decoder
|
||||
.as_mut()
|
||||
.ok_or_else(|| AppError::VideoError("MJPEG decoder not initialized".to_string()))?;
|
||||
let decoded = decoder.decode(raw_frame)?;
|
||||
let decoded = match decoder.decode(raw_frame) {
|
||||
Ok(decoded) => decoded,
|
||||
Err(err) => {
|
||||
warn!("Dropping undecodable MJPEG frame before encode: {}", err);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
Some(decoded)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let raw_frame = decoded_buf.as_deref().unwrap_or(raw_frame);
|
||||
let compacted_buf = if decoded_buf.is_none() {
|
||||
compact_strided_frame_for_encoder(frame, raw_frame)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let raw_frame = decoded_buf
|
||||
.as_deref()
|
||||
.or(compacted_buf.as_deref())
|
||||
.unwrap_or(raw_frame);
|
||||
|
||||
// Debug log for H265
|
||||
if codec == VideoEncoderType::H265 && frame_count % 30 == 1 {
|
||||
@@ -1365,8 +1421,24 @@ impl SharedVideoPipeline {
|
||||
|
||||
match encode_result {
|
||||
Ok(frames) => {
|
||||
if !frames.is_empty() {
|
||||
let encoded = frames.into_iter().next().unwrap();
|
||||
if frames.is_empty() {
|
||||
if codec == VideoEncoderType::H265 {
|
||||
warn!(
|
||||
"[Pipeline-H265] Encoder returned no frames for frame #{}",
|
||||
frame_count
|
||||
);
|
||||
} else {
|
||||
trace!(
|
||||
"Encoder returned no frames for input frame #{} ({})",
|
||||
frame_count,
|
||||
codec
|
||||
);
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut encoded_frames = Vec::with_capacity(frames.len());
|
||||
for encoded in frames {
|
||||
let is_keyframe = encoded.key == 1;
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if codec == VideoEncoderType::H264 {
|
||||
@@ -1390,23 +1462,17 @@ impl SharedVideoPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(EncodedVideoFrame {
|
||||
data: Bytes::from(encoded.data),
|
||||
encoded_frames.push(EncodedVideoFrame {
|
||||
data: encoded.data,
|
||||
pts_ms,
|
||||
is_keyframe,
|
||||
sequence,
|
||||
duration: Duration::from_millis(1000 / fps as u64),
|
||||
codec,
|
||||
}))
|
||||
} else {
|
||||
if codec == VideoEncoderType::H265 {
|
||||
warn!(
|
||||
"[Pipeline-H265] Encoder returned no frames for frame #{}",
|
||||
frame_count
|
||||
);
|
||||
}
|
||||
Ok(None)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(encoded_frames)
|
||||
}
|
||||
Err(e) => {
|
||||
if codec == VideoEncoderType::H265 {
|
||||
@@ -1490,6 +1556,174 @@ impl SharedVideoPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_strided_frame_for_encoder(frame: &VideoFrame, data: &[u8]) -> Result<Option<Vec<u8>>> {
|
||||
let width = frame.resolution.width as usize;
|
||||
let height = frame.resolution.height as usize;
|
||||
let stride = frame.stride as usize;
|
||||
if width == 0 || height == 0 || stride == 0 || frame.format.is_compressed() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let compact_size = match frame.format {
|
||||
PixelFormat::Nv12 | PixelFormat::Nv21 | PixelFormat::Yuv420 | PixelFormat::Yvu420 => {
|
||||
width * height * 3 / 2
|
||||
}
|
||||
PixelFormat::Nv16 | PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy => {
|
||||
width * height * 2
|
||||
}
|
||||
PixelFormat::Nv24 | PixelFormat::Rgb24 | PixelFormat::Bgr24 => width * height * 3,
|
||||
PixelFormat::Rgb565 => width * height * 2,
|
||||
PixelFormat::Grey => width * height,
|
||||
PixelFormat::Mjpeg | PixelFormat::Jpeg => return Ok(None),
|
||||
};
|
||||
|
||||
if data.len() == compact_size {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; compact_size];
|
||||
match frame.format {
|
||||
PixelFormat::Nv12 | PixelFormat::Nv21 => {
|
||||
let src_y_size = stride * height;
|
||||
let src_uv_size = stride * height / 2;
|
||||
require_len(data, src_y_size + src_uv_size, frame.format, stride)?;
|
||||
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
|
||||
copy_rows(
|
||||
data,
|
||||
src_y_size,
|
||||
stride,
|
||||
&mut out,
|
||||
width * height,
|
||||
width,
|
||||
width,
|
||||
height / 2,
|
||||
);
|
||||
}
|
||||
PixelFormat::Yuv420 | PixelFormat::Yvu420 => {
|
||||
let src_y_size = stride * height;
|
||||
let src_chroma_stride = stride / 2;
|
||||
let src_chroma_size = src_chroma_stride * height / 2;
|
||||
let dst_y_size = width * height;
|
||||
let dst_chroma_stride = width / 2;
|
||||
let dst_chroma_size = dst_chroma_stride * height / 2;
|
||||
require_len(data, src_y_size + src_chroma_size * 2, frame.format, stride)?;
|
||||
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
|
||||
copy_rows(
|
||||
data,
|
||||
src_y_size,
|
||||
src_chroma_stride,
|
||||
&mut out,
|
||||
dst_y_size,
|
||||
dst_chroma_stride,
|
||||
dst_chroma_stride,
|
||||
height / 2,
|
||||
);
|
||||
copy_rows(
|
||||
data,
|
||||
src_y_size + src_chroma_size,
|
||||
src_chroma_stride,
|
||||
&mut out,
|
||||
dst_y_size + dst_chroma_size,
|
||||
dst_chroma_stride,
|
||||
dst_chroma_stride,
|
||||
height / 2,
|
||||
);
|
||||
}
|
||||
PixelFormat::Nv16 => {
|
||||
let src_y_size = stride * height;
|
||||
require_len(data, src_y_size + stride * height, frame.format, stride)?;
|
||||
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
|
||||
copy_rows(
|
||||
data,
|
||||
src_y_size,
|
||||
stride,
|
||||
&mut out,
|
||||
width * height,
|
||||
width,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
}
|
||||
PixelFormat::Nv24 => {
|
||||
let src_y_size = stride * height;
|
||||
let src_uv_stride = stride * 2;
|
||||
require_len(
|
||||
data,
|
||||
src_y_size + src_uv_stride * height,
|
||||
frame.format,
|
||||
stride,
|
||||
)?;
|
||||
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
|
||||
copy_rows(
|
||||
data,
|
||||
src_y_size,
|
||||
src_uv_stride,
|
||||
&mut out,
|
||||
width * height,
|
||||
width * 2,
|
||||
width * 2,
|
||||
height,
|
||||
);
|
||||
}
|
||||
PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy | PixelFormat::Rgb565 => {
|
||||
let row_bytes = width * 2;
|
||||
require_len(data, stride * height, frame.format, stride)?;
|
||||
copy_rows(data, 0, stride, &mut out, 0, row_bytes, row_bytes, height);
|
||||
}
|
||||
PixelFormat::Rgb24 | PixelFormat::Bgr24 => {
|
||||
let row_bytes = width * 3;
|
||||
require_len(data, stride * height, frame.format, stride)?;
|
||||
copy_rows(data, 0, stride, &mut out, 0, row_bytes, row_bytes, height);
|
||||
}
|
||||
PixelFormat::Grey => {
|
||||
require_len(data, stride * height, frame.format, stride)?;
|
||||
copy_rows(data, 0, stride, &mut out, 0, width, width, height);
|
||||
}
|
||||
PixelFormat::Mjpeg | PixelFormat::Jpeg => return Ok(None),
|
||||
}
|
||||
|
||||
trace!(
|
||||
"Compacted strided {} frame for encoder: {} -> {} bytes (stride={}, width={})",
|
||||
frame.format,
|
||||
data.len(),
|
||||
out.len(),
|
||||
stride,
|
||||
width
|
||||
);
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn require_len(data: &[u8], required: usize, format: PixelFormat, stride: usize) -> Result<()> {
|
||||
if data.len() < required {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"{} frame too small for stride compaction: {} < {} (stride={})",
|
||||
format,
|
||||
data.len(),
|
||||
required,
|
||||
stride
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_rows(
|
||||
src: &[u8],
|
||||
src_offset: usize,
|
||||
src_stride: usize,
|
||||
dst: &mut [u8],
|
||||
dst_offset: usize,
|
||||
dst_stride: usize,
|
||||
row_bytes: usize,
|
||||
rows: usize,
|
||||
) {
|
||||
for row in 0..rows {
|
||||
let src_start = src_offset + row * src_stride;
|
||||
let dst_start = dst_offset + row * dst_stride;
|
||||
dst[dst_start..dst_start + row_bytes]
|
||||
.copy_from_slice(&src[src_start..src_start + row_bytes]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SharedVideoPipeline {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.running.send(false);
|
||||
|
||||
@@ -415,7 +415,7 @@ pub async fn apply_rustdesk_config(
|
||||
let mut rustdesk_guard = state.rustdesk.write().await;
|
||||
let mut credentials_to_save = None;
|
||||
|
||||
if old_config.enabled && !new_config.enabled {
|
||||
if !new_config.enabled {
|
||||
if let Some(ref service) = *rustdesk_guard {
|
||||
service
|
||||
.stop()
|
||||
@@ -493,7 +493,7 @@ pub async fn apply_rtsp_config(
|
||||
|
||||
let mut rtsp_guard = state.rtsp.write().await;
|
||||
|
||||
if old_config.enabled && !new_config.enabled {
|
||||
if !new_config.enabled {
|
||||
if let Some(ref service) = *rtsp_guard {
|
||||
service
|
||||
.stop()
|
||||
|
||||
@@ -21,10 +21,13 @@ pub use hid::{get_hid_config, update_hid_config};
|
||||
#[cfg(unix)]
|
||||
pub use msd::{get_msd_config, update_msd_config};
|
||||
pub use redfish::{get_redfish_config, update_redfish_config};
|
||||
pub use rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config};
|
||||
pub use rtsp::{
|
||||
get_rtsp_config, get_rtsp_status, start_rtsp_service, stop_rtsp_service, update_rtsp_config,
|
||||
};
|
||||
pub use rustdesk::{
|
||||
get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id,
|
||||
regenerate_device_password, update_rustdesk_config,
|
||||
regenerate_device_password, start_rustdesk_service, stop_rustdesk_service,
|
||||
update_rustdesk_config,
|
||||
};
|
||||
pub use stream::{get_stream_config, update_stream_config};
|
||||
pub use video::{get_video_config, update_video_config};
|
||||
|
||||
@@ -9,6 +9,12 @@ pub async fn update_overview(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Query(query): axum::extract::Query<UpdateOverviewQuery>,
|
||||
) -> Result<Json<UpdateOverviewResponse>> {
|
||||
if cfg!(feature = "android") {
|
||||
return Err(AppError::BadRequest(
|
||||
"Online upgrade is disabled on Android".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let channel = query.channel.unwrap_or(UpdateChannel::Stable);
|
||||
let response = state.update.overview(channel).await?;
|
||||
Ok(Json(response))
|
||||
@@ -18,6 +24,12 @@ pub async fn update_upgrade(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<UpgradeRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
if cfg!(feature = "android") {
|
||||
return Err(AppError::BadRequest(
|
||||
"Online upgrade is disabled on Android".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
state.update.start_upgrade(req, state.shutdown_tx.clone())?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
@@ -26,6 +38,14 @@ pub async fn update_upgrade(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_status(State(state): State<Arc<AppState>>) -> Json<UpdateStatusResponse> {
|
||||
Json(state.update.status().await)
|
||||
pub async fn update_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<UpdateStatusResponse>> {
|
||||
if cfg!(feature = "android") {
|
||||
return Err(AppError::BadRequest(
|
||||
"Online upgrade is disabled on Android".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(state.update.status().await))
|
||||
}
|
||||
|
||||
@@ -853,10 +853,20 @@ impl UniversalSession {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
self.pc
|
||||
.close()
|
||||
.await
|
||||
.map_err(|e| AppError::VideoError(format!("Failed to close peer connection: {}", e)))?;
|
||||
if let Err(e) = self.pc.close().await {
|
||||
let error = e.to_string();
|
||||
if error.contains("mpsc send: channel closed") {
|
||||
debug!(
|
||||
"{} session {} peer connection was already closed: {}",
|
||||
self.codec, self.session_id, error
|
||||
);
|
||||
} else {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Failed to close peer connection: {}",
|
||||
error
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self.state.send(ConnectionState::Closed);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user