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:
2026-05-30 22:04:06 +08:00
parent 09bd36b54f
commit eda8f7d927
12 changed files with 605 additions and 105 deletions

View File

@@ -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(),

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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")))]

View File

@@ -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
View 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,
}]
}

View File

@@ -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,

View File

@@ -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 encoder = enc_guard
.as_mut()
.ok_or_else(|| "Encoder not initialized. Call init_encoder first.".to_string())?;
let enc_guard = state.encoder.lock().await;
let (enc_width, enc_height, enc_bitrate) = {
let encoder = enc_guard
.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;
}
}
}
}
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 (stub — returns display info).
/// 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,

View File

@@ -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())
}
}

View File

@@ -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 () => {
setConnected(false);
try {
await invoke("disconnect_adp");
console.log("Disconnected");
} catch (e) {
console.error("Disconnect failed:", e);
} finally {
setConnected(false);
}
}, []);
const handleApplySettings = useCallback(async () => {

View File

@@ -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:{" "}

View File

@@ -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) => (
<li
key={addr}
className={`device-item ${selected === addr ? "selected" : ""}`}
onClick={() => onSelect(addr)}
>
<span className="device-icon">📱</span>
<span className="device-addr">{addr}</span>
{selected === addr && <span className="check"></span>}
</li>
))}
{devices.map((device) => {
const key = `${device.addr}:${device.port}`;
const isSelected = selected !== null &&
selected.addr === device.addr &&
selected.port === device.port;
return (
<li
key={key}
className={`device-item ${isSelected ? "selected" : ""}`}
onClick={() => onSelect(device)}
>
<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>
);