feat: ADP协议对接修正 + 多显示器支持 + 屏幕采集实现 + 流式管道
- ADP 协议修复: codecs 字段名匹配 Android 端,protocol_version 解析为字符串 - mDNS 发现: 返回结构化 DeviceInfo(设备名/地址/分辨率/TXT记录) - 屏幕采集: macOS CGDisplay + Windows captrs 实现(Linux 仍为 xrandr 分辨率检测 stub) - 新增 display 模块: 跨平台显示器枚举(list_displays),支持多显示器选择 - 流式管道: start_streaming 实现 capture→encode→send 完整链路,支持 display_index 参数 - 前端: disconnect 调用 backend,设备列表/连接面板展示名称与分辨率 - Encoder: 添加 bitrate() getter - get_display_resolution: 改为查询实际显示器分辨率
This commit is contained in:
@@ -213,12 +213,16 @@ impl AdpClient {
|
||||
serde_json::from_slice(&server_cap.payload).context("Invalid Capability JSON")?;
|
||||
|
||||
let server_cap = ServerCapability {
|
||||
protocol_version: cap_json["protocol_version"].as_u64().unwrap_or(1) as u8,
|
||||
protocol_version: cap_json["protocol_version"]
|
||||
.as_str()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1) as u8,
|
||||
device_name: cap_json["device_name"]
|
||||
.as_str()
|
||||
.unwrap_or("AirDisplay")
|
||||
.to_string(),
|
||||
supported_codecs: cap_json["supported_codecs"]
|
||||
supported_codecs: cap_json["codecs"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default(),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Linux screen capture (PipeWire / X11 stub)
|
||||
//! Linux screen capture stub.
|
||||
//!
|
||||
//! TODO: Implement using `pipewire` or `x11` + `xcb` crates.
|
||||
//! TODO: Implement via PipeWire (Wayland) or X11 SHM (X11).
|
||||
//! Use `pipewire` crate for Wayland or `x11`/`xcb` crate for X11.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use super::CapturedFrame;
|
||||
|
||||
pub struct LinuxCapturer {
|
||||
@@ -13,18 +16,44 @@ pub struct LinuxCapturer {
|
||||
|
||||
impl LinuxCapturer {
|
||||
pub fn new(fps: u32) -> Result<Self> {
|
||||
Ok(Self {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps,
|
||||
})
|
||||
let (width, height) = detect_resolution().unwrap_or((1920, 1080));
|
||||
Ok(Self { width, height, fps })
|
||||
}
|
||||
|
||||
pub fn for_display(_display: u32, fps: u32) -> Result<Self> {
|
||||
Self::new(fps)
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to detect screen resolution via xrandr.
|
||||
fn detect_resolution() -> Option<(u32, u32)> {
|
||||
let output = Command::new("xrandr")
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if line.contains(" connected ") {
|
||||
// Parse: "HDMI-1 connected primary 1920x1080+0+0"
|
||||
if let Some(mode) = line.split_whitespace().find(|s| s.contains('x')) {
|
||||
// mode looks like "1920x1080+0+0"
|
||||
let parts: Vec<&str> = mode.split(&['x', '+'][..]).collect();
|
||||
if parts.len() >= 2 {
|
||||
if let (Ok(w), Ok(h)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
|
||||
return Some((w, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl super::Capturer for LinuxCapturer {
|
||||
fn capture_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// FIXME: capture using PipeWire or X11/Shm
|
||||
bail!("Linux capture not yet implemented")
|
||||
// Screen capture requires PipeWire (Wayland) or X11 SHM (X11).
|
||||
// For development, use the desktop client on macOS or Windows.
|
||||
bail!("Linux capture not yet implemented (requires PipeWire or X11 integration)")
|
||||
}
|
||||
|
||||
fn resolution(&self) -> (u32, u32) {
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
//! macOS screen capture (CGDisplay stub)
|
||||
//! macOS screen capture using Core Graphics (CGDisplay).
|
||||
//!
|
||||
//! TODO: Implement using `core-graphics` or ScreenCaptureKit.
|
||||
//! Captures the primary display as raw RGBA pixel data.
|
||||
//! For multi-display support, pass a display_id to select which monitor.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use core_graphics::display::{CGDisplay, CGDisplayBounds};
|
||||
use core_graphics::image::CGImage;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use super::CapturedFrame;
|
||||
|
||||
pub struct MacOsCapturer {
|
||||
display_id: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
impl MacOsCapturer {
|
||||
/// Create a capturer for the primary display.
|
||||
pub fn new(fps: u32) -> Result<Self> {
|
||||
let main = CGDisplay::main();
|
||||
let bounds = CGDisplayBounds(main.id);
|
||||
let width = bounds.size.width as u32;
|
||||
let height = bounds.size.height as u32;
|
||||
Ok(Self {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
display_id: main.id,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a capturer for a specific display.
|
||||
pub fn for_display(display_id: u32, fps: u32) -> Result<Self> {
|
||||
let bounds = CGDisplayBounds(display_id);
|
||||
let width = bounds.size.width as u32;
|
||||
let height = bounds.size.height as u32;
|
||||
Ok(Self {
|
||||
display_id,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
})
|
||||
}
|
||||
@@ -23,8 +47,25 @@ impl MacOsCapturer {
|
||||
|
||||
impl super::Capturer for MacOsCapturer {
|
||||
fn capture_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// FIXME: capture using CGDisplay or SCK
|
||||
bail!("macOS capture not yet implemented")
|
||||
let image = CGDisplay::screenshot(
|
||||
self.display_id,
|
||||
self.width as usize,
|
||||
self.height as usize,
|
||||
)
|
||||
.context("Failed to capture display screenshot")?;
|
||||
|
||||
let image_width = image.width() as u32;
|
||||
let image_height = image.height() as u32;
|
||||
|
||||
let provider = image.data_provider().context("No image data provider")?;
|
||||
let raw_data = provider.data().to_vec();
|
||||
|
||||
Ok(CapturedFrame {
|
||||
data: raw_data,
|
||||
width: image_width,
|
||||
height: image_height,
|
||||
stride: image_width * 4, // RGBA = 4 bytes per pixel
|
||||
})
|
||||
}
|
||||
|
||||
fn resolution(&self) -> (u32, u32) {
|
||||
|
||||
@@ -34,21 +34,31 @@ pub trait Capturer: Send {
|
||||
fn resolution(&self) -> (u32, u32);
|
||||
}
|
||||
|
||||
/// Create a platform-appropriate screen capturer.
|
||||
pub fn create_capturer(fps: u32) -> Result<Box<dyn Capturer>> {
|
||||
/// Create a platform-appropriate screen capturer for the given display index.
|
||||
/// `display_index`: 0 = primary display, 1+ = secondary displays.
|
||||
pub fn create_capturer(display_index: usize, fps: u32) -> Result<Box<dyn Capturer>> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let capturer = windows::WindowsCapturer::new(fps)?;
|
||||
let capturer = windows::WindowsCapturer::for_display(display_index)?;
|
||||
Ok(Box::new(capturer))
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let capturer = macos::MacOsCapturer::new(fps)?;
|
||||
let capturer = if display_index == 0 {
|
||||
macos::MacOsCapturer::new(fps)?
|
||||
} else {
|
||||
let displays = crate::display::list_displays();
|
||||
let id = displays
|
||||
.get(display_index)
|
||||
.map(|d| d.index)
|
||||
.unwrap_or(0);
|
||||
macos::MacOsCapturer::for_display(id, fps)?
|
||||
};
|
||||
Ok(Box::new(capturer))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let capturer = linux::LinuxCapturer::new(fps)?;
|
||||
let capturer = linux::LinuxCapturer::for_display(display_index as u32, fps)?;
|
||||
Ok(Box::new(capturer))
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
|
||||
@@ -1,31 +1,59 @@
|
||||
//! Windows screen capture (DXGI Desktop Duplication API stub)
|
||||
//! Windows screen capture using captrs (DXGI Desktop Duplication API).
|
||||
//!
|
||||
//! TODO: Implement using `dxgi` + `d3d11` crates or `captrs`.
|
||||
//! Captures the primary display as raw RGBA pixel data.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use captrs::*;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use super::CapturedFrame;
|
||||
|
||||
pub struct WindowsCapturer {
|
||||
capturer: Capturer,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
}
|
||||
|
||||
impl WindowsCapturer {
|
||||
pub fn new(fps: u32) -> Result<Self> {
|
||||
// FIXME: init DXGI duplication
|
||||
let capturer = Capturer::new(0).context("Failed to create DXGI screen capturer")?;
|
||||
let (width, height) = capturer.geometry();
|
||||
Ok(Self {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps,
|
||||
capturer,
|
||||
width: width as u32,
|
||||
height: height as u32,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a capturer for a specific display index.
|
||||
pub fn for_display(display_index: usize, fps: u32) -> Result<Self> {
|
||||
let capturer = Capturer::new(display_index).context("Failed to create DXGI screen capturer for display")?;
|
||||
let (width, height) = capturer.geometry();
|
||||
Ok(Self {
|
||||
capturer,
|
||||
width: width as u32,
|
||||
height: height as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Capturer for WindowsCapturer {
|
||||
fn capture_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// FIXME: capture using DXGI OutputDuplication
|
||||
bail!("Windows capture not yet implemented")
|
||||
let captrs_frame = self
|
||||
.capturer
|
||||
.capture_frame()
|
||||
.context("Failed to capture desktop frame via DXGI")?;
|
||||
|
||||
// captrs encodes as BGRA8 internally, convert to RGBA
|
||||
let raw = captrs_frame.to_rgba();
|
||||
let w = self.width;
|
||||
let h = self.height;
|
||||
|
||||
Ok(CapturedFrame {
|
||||
data: raw,
|
||||
width: w,
|
||||
height: h,
|
||||
stride: w * 4,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolution(&self) -> (u32, u32) {
|
||||
|
||||
237
src-tauri/src/display.rs
Normal file
237
src-tauri/src/display.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! Cross-platform display enumeration and resolution detection.
|
||||
//!
|
||||
//! Detects available displays/monitors and their properties:
|
||||
//! - Display index, name, resolution, position, primary status
|
||||
//!
|
||||
//! Used for multi-monitor extended display support.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DisplayInfo {
|
||||
pub index: u32,
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
/// Get information about all connected displays.
|
||||
pub fn list_displays() -> Vec<DisplayInfo> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{ windows_displays() }
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{ macos_displays() }
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{ linux_displays() }
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{ default_displays() }
|
||||
}
|
||||
|
||||
/// Get the primary display resolution.
|
||||
pub fn primary_display_resolution() -> (u32, u32) {
|
||||
let displays = list_displays();
|
||||
displays
|
||||
.iter()
|
||||
.find(|d| d.is_primary)
|
||||
.or_else(|| displays.first())
|
||||
.map(|d| (d.width, d.height))
|
||||
.unwrap_or((1920, 1080))
|
||||
}
|
||||
|
||||
// ── Platform-specific implementations ──
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_displays() -> Vec<DisplayInfo> {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::mem;
|
||||
|
||||
extern "system" {
|
||||
fn GetDC(hwnd: isize) -> isize;
|
||||
fn ReleaseDC(hwnd: isize, hdc: isize) -> i32;
|
||||
fn GetDeviceCaps(hdc: isize, index: i32) -> i32;
|
||||
fn EnumDisplayMonitors(
|
||||
hdc: isize,
|
||||
clip_rect: *const u8,
|
||||
callback: extern "system" fn(isize, isize, *mut u8, isize) -> i32,
|
||||
data: isize,
|
||||
) -> i32;
|
||||
fn GetMonitorInfoW(monitor: isize, info: *mut u8) -> i32;
|
||||
fn EnumDisplayDevicesW(
|
||||
device: *const u16,
|
||||
dev_idx: u32,
|
||||
disp_device: *mut u8,
|
||||
flags: u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
struct MonitorEnumData {
|
||||
displays: Vec<DisplayInfo>,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn monitor_callback(
|
||||
monitor: isize,
|
||||
_hdc: isize,
|
||||
_rect: *mut u8,
|
||||
data: isize,
|
||||
) -> i32 {
|
||||
let enum_data = &mut *(data as *mut MonitorEnumData);
|
||||
let index = enum_data.displays.len() as u32;
|
||||
|
||||
#[repr(C)]
|
||||
struct MonitorInfo {
|
||||
size: u32,
|
||||
rect: [i32; 4], // left, top, right, bottom
|
||||
work_rect: [i32; 4], // left, top, right, bottom
|
||||
flags: u32,
|
||||
}
|
||||
|
||||
let mut info = MonitorInfo {
|
||||
size: mem::size_of::<MonitorInfo>() as u32,
|
||||
rect: [0; 4],
|
||||
work_rect: [0; 4],
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
let ret = GetMonitorInfoW(monitor, &mut info as *mut _ as *mut u8);
|
||||
if ret != 0 {
|
||||
let is_primary = (info.flags & 0x01) != 0;
|
||||
let width = (info.rect[2] - info.rect[0]) as u32;
|
||||
let height = (info.rect[3] - info.rect[1]) as u32;
|
||||
|
||||
#[repr(C)]
|
||||
struct DisplayDevice {
|
||||
size: u32,
|
||||
device_name: [u16; 32],
|
||||
device_string: [u16; 128],
|
||||
state_flags: u32,
|
||||
device_id: [u16; 128],
|
||||
device_key: [u16; 128],
|
||||
}
|
||||
|
||||
let mut dev = DisplayDevice {
|
||||
size: mem::size_of::<DisplayDevice>() as u32,
|
||||
device_name: [0; 32],
|
||||
device_string: [0; 128],
|
||||
state_flags: 0,
|
||||
device_id: [0; 128],
|
||||
device_key: [0; 128],
|
||||
};
|
||||
|
||||
let name = if EnumDisplayDevicesW(
|
||||
std::ptr::null(),
|
||||
index,
|
||||
&mut dev as *mut _ as *mut u8,
|
||||
0,
|
||||
) != 0
|
||||
{
|
||||
let end = dev.device_string.iter().position(|&c| c == 0).unwrap_or(128);
|
||||
OsString::from_wide(&dev.device_string[..end])
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
} else {
|
||||
format!("Display {}", index + 1)
|
||||
};
|
||||
|
||||
enum_data.displays.push(DisplayInfo {
|
||||
index,
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
x: info.rect[0],
|
||||
y: info.rect[1],
|
||||
is_primary,
|
||||
});
|
||||
}
|
||||
1 // Continue enumeration
|
||||
}
|
||||
|
||||
let mut data = MonitorEnumData {
|
||||
displays: Vec::new(),
|
||||
};
|
||||
|
||||
unsafe {
|
||||
EnumDisplayMonitors(
|
||||
0,
|
||||
std::ptr::null(),
|
||||
monitor_callback,
|
||||
&mut data as *mut _ as isize,
|
||||
);
|
||||
}
|
||||
|
||||
if data.displays.is_empty() {
|
||||
// Fallback: get primary display via screen DC
|
||||
let width = 1920u32;
|
||||
let height = 1080u32;
|
||||
data.displays.push(DisplayInfo {
|
||||
index: 0,
|
||||
name: "Primary Display".to_string(),
|
||||
width,
|
||||
height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
is_primary: true,
|
||||
});
|
||||
}
|
||||
|
||||
data.displays
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_displays() -> Vec<DisplayInfo> {
|
||||
use core_graphics::display::{CGDisplay, CGDisplayBounds};
|
||||
|
||||
let count = CGDisplay::active_count();
|
||||
let main_id = CGDisplay::main().id;
|
||||
|
||||
(0..count)
|
||||
.filter_map(|i| {
|
||||
let display = CGDisplay::online_displays()?.get(i as usize)?;
|
||||
let bounds = CGDisplayBounds(display);
|
||||
let is_primary = display == main_id;
|
||||
Some(DisplayInfo {
|
||||
index: i,
|
||||
name: format!("Display {}", i + 1),
|
||||
width: bounds.size.width as u32,
|
||||
height: bounds.size.height as u32,
|
||||
x: bounds.origin.x as i32,
|
||||
y: bounds.origin.y as i32,
|
||||
is_primary,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_displays() -> Vec<DisplayInfo> {
|
||||
// Linux display enumeration requires X11 or Wayland.
|
||||
// For now, return a sensible default.
|
||||
// TODO: implement via xrandr / libxcb / pipewire
|
||||
vec![DisplayInfo {
|
||||
index: 0,
|
||||
name: "Primary Display".to_string(),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
x: 0,
|
||||
y: 0,
|
||||
is_primary: true,
|
||||
}]
|
||||
}
|
||||
|
||||
fn default_displays() -> Vec<DisplayInfo> {
|
||||
vec![DisplayInfo {
|
||||
index: 0,
|
||||
name: "Primary Display".to_string(),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
x: 0,
|
||||
y: 0,
|
||||
is_primary: true,
|
||||
}]
|
||||
}
|
||||
@@ -111,6 +111,10 @@ impl SoftwareEncoder {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn bitrate(&self) -> u64 {
|
||||
self.bitrate
|
||||
}
|
||||
|
||||
/// Convert raw RGBA pixel data to an ffmpeg YUV420P frame.
|
||||
///
|
||||
/// Uses row-by-row copy to handle ffmpeg's internal stride alignment,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
mod adp;
|
||||
mod capture;
|
||||
mod display;
|
||||
mod encoder;
|
||||
mod mdns;
|
||||
|
||||
// ── Application State ──
|
||||
|
||||
struct AppState {
|
||||
/// ADP client connection (stored to keep the background read loop alive)
|
||||
client: TokioMutex<Option<adp::AdpClient>>,
|
||||
/// H.264 encoder instance
|
||||
encoder: TokioMutex<Option<encoder::SoftwareEncoder>>,
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ fn get_version() -> String {
|
||||
}
|
||||
|
||||
/// Discover AirDisplay devices on LAN via mDNS.
|
||||
/// Returns structured device info with name, address, resolution, platform.
|
||||
#[tauri::command]
|
||||
async fn discover_devices() -> Result<Vec<String>, String> {
|
||||
async fn discover_devices() -> Result<Vec<mdns::DeviceInfo>, String> {
|
||||
let mut discovery = mdns::MdnsDiscovery::new().map_err(|e| e.to_string())?;
|
||||
let devices = tokio::task::spawn_blocking(move || discovery.discover())
|
||||
.await
|
||||
@@ -155,52 +156,124 @@ async fn send_frame(
|
||||
|
||||
/// Start the full streaming pipeline (capture → encode → send).
|
||||
/// Spawns a background task that runs at the configured FPS.
|
||||
/// `display_index` selects which monitor to capture (0 = primary).
|
||||
#[tauri::command]
|
||||
async fn start_streaming(
|
||||
fps: u32,
|
||||
display_index: Option<usize>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let mut enc_guard = state.encoder.lock().await;
|
||||
let enc_guard = state.encoder.lock().await;
|
||||
let (enc_width, enc_height, enc_bitrate) = {
|
||||
let encoder = enc_guard
|
||||
.as_mut()
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Encoder not initialized. Call init_encoder first.".to_string())?;
|
||||
(encoder.resolution().0, encoder.resolution().1, encoder.bitrate())
|
||||
};
|
||||
drop(enc_guard);
|
||||
|
||||
let mut cli_guard = state.client.lock().await;
|
||||
let client = cli_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Client not connected. Call connect_adp first.".to_string())?;
|
||||
let has_client = {
|
||||
let cli_guard = state.client.lock().await;
|
||||
cli_guard.as_ref().map(|c| c.is_connected()).unwrap_or(false)
|
||||
};
|
||||
|
||||
if !client.is_connected() {
|
||||
return Err("Client not connected".to_string());
|
||||
if !has_client {
|
||||
return Err("Client not connected. Call connect_adp first.".to_string());
|
||||
}
|
||||
|
||||
// TODO: implement the full capture loop
|
||||
// let mut capturer = capture::create_capturer(fps)?;
|
||||
// loop {
|
||||
// let frame = capturer.capture_frame()?;
|
||||
// let nals = encoder.encode(&frame.data)?;
|
||||
// for nal in &nals {
|
||||
// client.send_video_frame(nal).await?;
|
||||
// }
|
||||
// }
|
||||
let idx = display_index.unwrap_or(0);
|
||||
let mut capturer = capture::create_capturer(idx, fps)
|
||||
.map_err(|e| format!("Failed to create capturer: {}", e))?;
|
||||
|
||||
Err("Streaming loop not yet implemented (capture module is a stub)".to_string())
|
||||
let capture_ms = (1000u64 / fps as u64).max(1);
|
||||
tracing::info!(
|
||||
"Starting streaming loop: {}x{} @ {}fps, {}bps, display={}",
|
||||
enc_width, enc_height, fps, enc_bitrate, idx
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let cycle = std::time::Duration::from_millis(capture_ms);
|
||||
|
||||
loop {
|
||||
let frame_start = std::time::Instant::now();
|
||||
|
||||
let captured = match capturer.capture_frame() {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tracing::error!("Capture error: {}", e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let app_state = app_handle.state::<AppState>();
|
||||
let nals = {
|
||||
let mut enc_guard = app_state.encoder.lock().await;
|
||||
if let Some(ref mut enc) = *enc_guard {
|
||||
match enc.encode(&captured.data) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::error!("Encode error: {}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
if !nals.is_empty() {
|
||||
let mut cli_guard = app_state.client.lock().await;
|
||||
if let Some(ref mut client) = *cli_guard {
|
||||
for nal in &nals {
|
||||
if let Err(e) = client.send_video_frame(nal).await {
|
||||
tracing::error!("Send error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start screen capture (stub — returns display info).
|
||||
let elapsed = frame_start.elapsed();
|
||||
if elapsed < cycle {
|
||||
tokio::time::sleep(cycle - elapsed).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining encoder data
|
||||
let app_state = app_handle.state::<AppState>();
|
||||
let mut enc_guard = app_state.encoder.lock().await;
|
||||
if let Some(ref mut enc) = *enc_guard {
|
||||
let _ = enc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(format!(
|
||||
"Streaming started: {}x{} @ {}fps on display {}",
|
||||
enc_width, enc_height, fps, idx
|
||||
))
|
||||
}
|
||||
|
||||
/// Start screen capture (returns display info for the selected display).
|
||||
#[tauri::command]
|
||||
async fn start_capture(fps: u32) -> Result<String, String> {
|
||||
let mut capturer = capture::create_capturer(fps).map_err(|e| e.to_string())?;
|
||||
async fn start_capture(fps: u32, display_index: Option<usize>) -> Result<String, String> {
|
||||
let idx = display_index.unwrap_or(0);
|
||||
let mut capturer = capture::create_capturer(idx, fps).map_err(|e| e.to_string())?;
|
||||
let (w, h) = capturer.resolution();
|
||||
Ok(format!("Capture started: {}x{} @ {}fps", w, h, fps))
|
||||
Ok(format!("Capture started: {}x{} @ {}fps (display {})", w, h, fps, idx))
|
||||
}
|
||||
|
||||
/// Get current display resolution.
|
||||
#[tauri::command]
|
||||
async fn get_display_resolution() -> Result<(u32, u32), String> {
|
||||
// TODO: query actual display resolution
|
||||
Ok((1920, 1080))
|
||||
let (w, h) = display::primary_display_resolution();
|
||||
Ok((w, h))
|
||||
}
|
||||
|
||||
/// List all connected displays (multi-monitor support).
|
||||
#[tauri::command]
|
||||
async fn list_displays() -> Result<Vec<display::DisplayInfo>, String> {
|
||||
Ok(display::list_displays())
|
||||
}
|
||||
|
||||
// ── Application Entry Point ──
|
||||
@@ -220,6 +293,7 @@ pub fn run() {
|
||||
disconnect_adp,
|
||||
start_capture,
|
||||
get_display_resolution,
|
||||
list_displays,
|
||||
init_encoder,
|
||||
send_frame,
|
||||
start_streaming,
|
||||
|
||||
@@ -2,30 +2,39 @@
|
||||
//!
|
||||
//! Discovers Android AirDisplay devices on the local network
|
||||
//! via `_airdisplay._tcp` service type.
|
||||
//!
|
||||
//! Returns structured `DeviceInfo` with name, address, port,
|
||||
//! and TXT record metadata.
|
||||
|
||||
use anyhow::Result;
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const SERVICE_TYPE: &str = "_airdisplay._tcp.local.";
|
||||
const DISCOVERY_TIMEOUT_MS: u64 = 2000;
|
||||
const DISCOVERY_TIMEOUT_MS: u64 = 3000;
|
||||
|
||||
pub struct MdnsDiscovery {
|
||||
daemon: Arc<ServiceDaemon>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub addr: String,
|
||||
pub port: u16,
|
||||
pub platform: String,
|
||||
pub device_model: String,
|
||||
pub max_width: u32,
|
||||
pub max_height: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeviceInfo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} ({}:{})", self.name, self.addr, self.port)
|
||||
write!(f, "{} @ {}:{}", self.name, self.addr, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,27 +46,47 @@ impl MdnsDiscovery {
|
||||
})
|
||||
}
|
||||
|
||||
/// Browse for devices and block briefly to collect results.
|
||||
/// Returns list of discovered device addresses (ip:port).
|
||||
pub fn discover(&mut self) -> Result<Vec<String>> {
|
||||
/// Browse for AirDisplay devices and return structured device info.
|
||||
pub fn discover(&mut self) -> Result<Vec<DeviceInfo>> {
|
||||
let receiver = self.daemon.browse(SERVICE_TYPE)?;
|
||||
let mut devices = HashSet::new();
|
||||
let mut devices: HashMap<String, DeviceInfo> = HashMap::new();
|
||||
|
||||
// Collect devices for a short duration
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(DISCOVERY_TIMEOUT_MS);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Ok(event) = receiver.try_recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
let addr = info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|a| format!("{}:{}", a, info.get_port()))
|
||||
.unwrap_or_default();
|
||||
if !addr.is_empty() {
|
||||
devices.insert(addr);
|
||||
let addrs = info.get_addresses();
|
||||
if addrs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let addr = addrs[0].to_string();
|
||||
let port = info.get_port();
|
||||
let key = format!("{}:{}", addr, port);
|
||||
|
||||
if devices.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let txt_props = info.get_properties();
|
||||
let prop = |key: &str, default: &str| -> String {
|
||||
txt_props
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(key))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
};
|
||||
let device = DeviceInfo {
|
||||
name: prop("device", "AirDisplay"),
|
||||
host: info.get_hostname().trim_end_matches('.').to_string(),
|
||||
addr,
|
||||
port,
|
||||
platform: prop("platform", "android"),
|
||||
device_model: prop("device", "unknown"),
|
||||
max_width: prop("max_width", "1920").parse().unwrap_or(1920),
|
||||
max_height: prop("max_height", "1080").parse().unwrap_or(1080),
|
||||
};
|
||||
devices.insert(key, device);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -66,6 +95,6 @@ impl MdnsDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices.into_iter().collect())
|
||||
Ok(devices.into_values().collect())
|
||||
}
|
||||
}
|
||||
|
||||
27
src/App.tsx
27
src/App.tsx
@@ -4,6 +4,17 @@ import { ConnectionPanel } from "./components/ConnectionPanel";
|
||||
import { SettingsPanel } from "./components/SettingsPanel";
|
||||
import "./App.css";
|
||||
|
||||
interface DeviceInfo {
|
||||
name: string;
|
||||
host: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
platform: string;
|
||||
device_model: string;
|
||||
max_width: number;
|
||||
max_height: number;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -12,8 +23,8 @@ interface Settings {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [devices, setDevices] = useState<string[]>([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState<string | null>(null);
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceInfo | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
@@ -30,7 +41,7 @@ function App() {
|
||||
const refreshDevices = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const list: string[] = await invoke("discover_devices");
|
||||
const list: DeviceInfo[] = await invoke("discover_devices");
|
||||
setDevices(list);
|
||||
} catch (e) {
|
||||
console.error("Discovery failed:", e);
|
||||
@@ -41,8 +52,9 @@ function App() {
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (!selectedDevice) return;
|
||||
const addr = `${selectedDevice.addr}:${selectedDevice.port}`;
|
||||
try {
|
||||
const msg: string = await invoke("connect_adp", { addr: selectedDevice });
|
||||
const msg: string = await invoke("connect_adp", { addr });
|
||||
console.log(msg);
|
||||
setConnected(true);
|
||||
} catch (e) {
|
||||
@@ -52,7 +64,14 @@ function App() {
|
||||
}, [selectedDevice]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
try {
|
||||
await invoke("disconnect_adp");
|
||||
console.log("Disconnected");
|
||||
} catch (e) {
|
||||
console.error("Disconnect failed:", e);
|
||||
} finally {
|
||||
setConnected(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleApplySettings = useCallback(async () => {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
interface DeviceInfo {
|
||||
name: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface ConnectionPanelProps {
|
||||
selectedDevice: string | null;
|
||||
selectedDevice: DeviceInfo | null;
|
||||
connected: boolean;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
@@ -16,7 +22,7 @@ export function ConnectionPanel({
|
||||
<h2>Connection</h2>
|
||||
<div className="connection-info">
|
||||
<p>
|
||||
Device: <strong>{selectedDevice || "—"}</strong>
|
||||
Device: <strong>{selectedDevice ? `${selectedDevice.name} (${selectedDevice.addr}:${selectedDevice.port})` : "—"}</strong>
|
||||
</p>
|
||||
<p>
|
||||
Status:{" "}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState, useEffect } from "react";
|
||||
interface DeviceInfo {
|
||||
name: string;
|
||||
host: string;
|
||||
addr: string;
|
||||
port: number;
|
||||
platform: string;
|
||||
device_model: string;
|
||||
max_width: number;
|
||||
max_height: number;
|
||||
}
|
||||
|
||||
interface DeviceListProps {
|
||||
devices: string[];
|
||||
selected: string | null;
|
||||
onSelect: (addr: string) => void;
|
||||
devices: DeviceInfo[];
|
||||
selected: DeviceInfo | null;
|
||||
onSelect: (device: DeviceInfo) => void;
|
||||
onRefresh: () => void;
|
||||
scanning: boolean;
|
||||
}
|
||||
@@ -25,17 +34,27 @@ export function DeviceList({ devices, selected, onSelect, onRefresh, scanning }:
|
||||
{scanning && <p className="scanning">Scanning for AirDisplay devices...</p>}
|
||||
|
||||
<ul className="device-list">
|
||||
{devices.map((addr) => (
|
||||
{devices.map((device) => {
|
||||
const key = `${device.addr}:${device.port}`;
|
||||
const isSelected = selected !== null &&
|
||||
selected.addr === device.addr &&
|
||||
selected.port === device.port;
|
||||
return (
|
||||
<li
|
||||
key={addr}
|
||||
className={`device-item ${selected === addr ? "selected" : ""}`}
|
||||
onClick={() => onSelect(addr)}
|
||||
key={key}
|
||||
className={`device-item ${isSelected ? "selected" : ""}`}
|
||||
onClick={() => onSelect(device)}
|
||||
>
|
||||
<span className="device-icon">📱</span>
|
||||
<span className="device-addr">{addr}</span>
|
||||
{selected === addr && <span className="check">✓</span>}
|
||||
<span className="device-icon">{device.platform === "android" ? "📱" : "🖥️"}</span>
|
||||
<div className="device-info">
|
||||
<span className="device-name">{device.name}</span>
|
||||
<span className="device-addr">{device.addr}:{device.port}</span>
|
||||
<span className="device-res">{device.max_width}×{device.max_height}</span>
|
||||
</div>
|
||||
{isSelected && <span className="check">✓</span>}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user