diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 316ecad..c848fbd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -243,6 +243,76 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -765,6 +835,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "deranged" version = "0.5.4" @@ -1681,12 +1757,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.7.0" @@ -1700,6 +1788,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2213,6 +2302,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.6" @@ -2243,6 +2338,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2784,8 +2889,12 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" name = "pathfinder" version = "0.1.0" dependencies = [ + "anyhow", + "axum", "dirs 5.0.1", "enigo", + "futures-util", + "rand 0.8.5", "serde", "serde_json", "tauri", @@ -2793,6 +2902,8 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-global-shortcut", "tauri-plugin-opener", + "tokio", + "tower-http 0.5.2", "uuid", "walkdir", ] @@ -3368,7 +3479,7 @@ dependencies = [ "tokio", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", @@ -3590,6 +3701,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3694,6 +3816,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4388,12 +4521,38 @@ dependencies = [ "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "slab", "socket2", + "tokio-macros", "windows-sys 0.59.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -4516,6 +4675,32 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -4554,6 +4739,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4619,6 +4805,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4683,6 +4887,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.19" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2f02fb8..6270259 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,3 +28,9 @@ uuid = { version = "1.0", features = ["v4"] } enigo = "0.2.0" walkdir = "2.4" dirs = "5.0" +tokio = { version = "1", features = ["full"] } +futures-util = "0.3" +anyhow = "1.0" +axum = { version = "0.7", features = ["macros", "ws"] } +tower-http = { version = "0.5", features = ["cors", "fs"] } +rand = "0.8" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 44d5c35..20d6cae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,18 @@ use std::time::{SystemTime, UNIX_EPOCH}; use std::fs; use std::path::PathBuf; use walkdir::WalkDir; +use std::collections::HashMap; +use std::process::Stdio; +use tokio::process::Command; +use futures_util::{SinkExt, StreamExt}; +use axum::{ + extract::{ws::WebSocketUpgrade, State}, + http::StatusCode, + response::{Html, IntoResponse, Response}, + routing::get, + Router, +}; +use tower_http::cors::CorsLayer; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClipboardItem { @@ -502,10 +514,11 @@ fn hide_window(app: tauri::AppHandle) { let _ = window.hide(); } } + fn start_clipboard_monitor(app_handle: tauri::AppHandle, db: Arc>) { std::thread::spawn(move || { let mut last_content = String::new(); - + loop { std::thread::sleep(std::time::Duration::from_millis(500)); @@ -549,6 +562,933 @@ fn start_clipboard_monitor(app_handle: tauri::AppHandle, db: Arc>>, // IP -> last seen +} + +struct HlsServerHandle { + ffmpeg_handle: Option, + server_handle: tokio::task::JoinHandle>, + tunnel_handle: Option, + access_code: String, + port: u16, + tunnel_url: Option, + tunnel_domain: Option, + public_dir: PathBuf, + viewers: Arc>>, +} + +// Check if FFmpeg is available +#[tauri::command] +async fn check_ffmpeg() -> Result { + let output = Command::new("ffmpeg") + .arg("-version") + .output() + .await; + + match output { + Ok(output) => Ok(output.status.success()), + Err(_) => Ok(false), + } +} + +// List available FFmpeg devices (macOS avfoundation) +#[tauri::command] +async fn list_ffmpeg_devices() -> Result { + eprintln!("๐Ÿ” Starting FFmpeg device detection..."); + + #[cfg(target_os = "macos")] + { + eprintln!("๐Ÿ“ฑ Running on macOS, using avfoundation"); + let output = Command::new("ffmpeg") + .args(&["-f", "avfoundation", "-list_devices", "true", "-i", ""]) + .output() + .await + .map_err(|e| { + eprintln!("โŒ Failed to run ffmpeg command: {}", e); + format!("Failed to run ffmpeg: {}", e) + })?; + + eprintln!("โœ… FFmpeg command executed, exit code: {:?}", output.status.code()); + + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("๐Ÿ“„ FFmpeg stderr output ({} bytes):", stderr.len()); + eprintln!("--- START FFmpeg Output ---"); + for (i, line) in stderr.lines().enumerate() { + eprintln!("Line {}: {}", i + 1, line); + } + eprintln!("--- END FFmpeg Output ---"); + + // Parse video devices + let mut video_devices = Vec::new(); + let mut audio_devices = Vec::new(); + let mut in_video_section = false; + let mut in_audio_section = false; + + eprintln!("๐Ÿ”Ž Parsing device list..."); + for (line_num, line) in stderr.lines().enumerate() { + if line.contains("AVFoundation video devices:") { + eprintln!("๐Ÿ“น Found video devices section at line {}", line_num + 1); + in_video_section = true; + in_audio_section = false; + continue; + } + if line.contains("AVFoundation audio devices:") { + eprintln!("๐Ÿ”Š Found audio devices section at line {}", line_num + 1); + in_audio_section = true; + in_video_section = false; + continue; + } + if line.contains("AVFoundation input device") { + continue; + } + + // Parse device line: [AVFoundation indev @ 0x...] [index] Device Name + if in_video_section || in_audio_section { + // Check if this line contains the AVFoundation indev pattern + if line.contains("[AVFoundation indev @") { + // Find the second set of brackets (the device index) + // Format: [AVFoundation indev @ 0x...] [0] Device Name + if let Some(first_bracket_end) = line.find(']') { + // Look for the second bracket after the first one + let after_first = &line[first_bracket_end + 1..]; + if let Some(second_bracket_start) = after_first.find('[') { + if let Some(second_bracket_end) = after_first[second_bracket_start + 1..].find(']') { + let index_str = &after_first[second_bracket_start + 1..second_bracket_start + 1 + second_bracket_end]; + if let Ok(index) = index_str.trim().parse::() { + // Device name is everything after the second bracket + let name_start = second_bracket_start + 1 + second_bracket_end + 1; + let name = after_first[name_start..].trim().to_string(); + + if !name.is_empty() { + eprintln!(" โœ“ Found device: [{}] \"{}\" (section: {})", + index, name, + if in_video_section { "video" } else { "audio" }); + if in_video_section { + video_devices.push(serde_json::json!({ + "index": index, + "name": name + })); + } else if in_audio_section { + audio_devices.push(serde_json::json!({ + "index": index, + "name": name + })); + } + } else { + eprintln!(" โš ๏ธ Line {}: Empty device name: {}", line_num + 1, line); + } + } else { + eprintln!(" โš ๏ธ Line {}: Could not parse index '{}' from: {}", line_num + 1, index_str, line); + } + } else { + eprintln!(" โš ๏ธ Line {}: No closing bracket for device index: {}", line_num + 1, line); + } + } else { + eprintln!(" โš ๏ธ Line {}: No second bracket found: {}", line_num + 1, line); + } + } + } + } + } + + eprintln!("๐Ÿ“Š Parsing complete:"); + eprintln!(" Video devices found: {}", video_devices.len()); + for device in &video_devices { + eprintln!(" - [{}] {}", device["index"], device["name"]); + } + eprintln!(" Audio devices found: {}", audio_devices.len()); + for device in &audio_devices { + eprintln!(" - [{}] {}", device["index"], device["name"]); + } + + let result = serde_json::json!({ + "video": video_devices, + "audio": audio_devices + }); + + eprintln!("โœ… Returning device list to frontend"); + Ok(result) + } + + #[cfg(not(target_os = "macos"))] + { + eprintln!("โš ๏ธ Not running on macOS, returning empty device list"); + // For non-macOS platforms, return empty lists + Ok(serde_json::json!({ + "video": [], + "audio": [] + })) + } +} + +// Check if localtunnel is available (via npx) +#[tauri::command] +async fn check_localtunnel() -> Result { + // Check if npx is available + let npx_check = Command::new("npx") + .arg("--version") + .output() + .await; + + if npx_check.is_err() { + return Ok(false); + } + + // Try to run localtunnel --help (this will download it if needed, but we just check if it works) + // Actually, we'll just check if npx works - localtunnel will be downloaded on first use + Ok(true) +} + +// Start localtunnel and parse the URL +async fn start_localtunnel(port: u16) -> anyhow::Result<(tokio::process::Child, String, String)> { + let mut cmd = Command::new("npx"); + cmd.args(&["-y", "localtunnel", "--port", &port.to_string()]); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + + // Wait a bit for localtunnel to start and output the URL + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + + use tokio::io::{AsyncBufReadExt, BufReader}; + + // Helper function to extract URL and domain from a line + fn extract_url_and_domain(line: &str) -> Option<(String, String)> { + // Look for URL pattern: "https://xxx.loca.lt" anywhere in the line + if line.contains("https://") && line.contains(".loca.lt") { + if let Some(url_start) = line.find("https://") { + let url_part = &line[url_start..]; + // Find the end of the URL (space, newline, or end of string) + let url_end = url_part + .find(' ') + .or_else(|| url_part.find('\n')) + .or_else(|| url_part.find('\r')) + .unwrap_or(url_part.len()); + + let url = url_part[..url_end].trim().to_string(); + + // Extract domain (e.g., "xxx" from "https://xxx.loca.lt") + // URL format is "https://xxx.loca.lt" + if let Some(domain_start) = url.find("https://") { + let after_https = &url[domain_start + 8..]; // Skip "https://" + if let Some(domain_end) = after_https.find(".loca.lt") { + let domain = after_https[..domain_end].to_string(); + return Some((url, domain)); + } + } + } + } + None + } + + // Try to read from stderr first (localtunnel usually outputs to stderr) + let mut found_url = None; + let mut stderr_consumed = false; + + if let Some(mut stderr) = child.stderr.take() { + let reader = BufReader::new(&mut stderr); + let mut lines = reader.lines(); + + // Read lines for a few seconds to find the URL + let timeout = tokio::time::sleep(tokio::time::Duration::from_secs(8)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + break; + } + line_result = lines.next_line() => { + match line_result { + Ok(Some(line)) => { + eprintln!("Localtunnel stderr: {}", line); + if let Some((url, domain)) = extract_url_and_domain(&line) { + found_url = Some((url, domain)); + stderr_consumed = true; + break; + } + } + Ok(None) => break, + Err(_) => break, + } + } + } + } + + // Put stderr back if we haven't consumed it + if !stderr_consumed { + child.stderr = Some(stderr); + } + } + + // If not found in stderr, try stdout + let mut stdout_consumed = false; + if found_url.is_none() { + if let Some(mut stdout) = child.stdout.take() { + let reader = BufReader::new(&mut stdout); + let mut lines = reader.lines(); + + let timeout = tokio::time::sleep(tokio::time::Duration::from_secs(5)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + break; + } + line_result = lines.next_line() => { + match line_result { + Ok(Some(line)) => { + eprintln!("Localtunnel stdout: {}", line); + if let Some((url, domain)) = extract_url_and_domain(&line) { + found_url = Some((url, domain)); + stdout_consumed = true; + break; + } + } + Ok(None) => break, + Err(_) => break, + } + } + } + } + + // Put stdout back if we haven't consumed it + if !stdout_consumed { + child.stdout = Some(stdout); + } + } + } + + if let Some((url, domain)) = found_url { + Ok((child, url, domain)) + } else { + // Wait a bit more and check if process is still running + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + Err(anyhow::anyhow!("Could not parse localtunnel URL from output. Check if localtunnel is working correctly.")) + } +} + +// Generate random 6-character access code +fn generate_access_code() -> String { + use rand::Rng; + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + (0..6) + .map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char) + .collect() +} + +// Get platform-specific FFmpeg input arguments +fn get_ffmpeg_input_args(device: Option<&str>) -> Vec { + #[cfg(target_os = "macos")] + { + let device_str = device.unwrap_or("2:0"); // Default to 2:0 + vec![ + "-f".to_string(), + "avfoundation".to_string(), + "-framerate".to_string(), + "30".to_string(), + "-video_size".to_string(), + "1920x1080".to_string(), + "-i".to_string(), + device_str.to_string(), + ] + } + #[cfg(target_os = "windows")] + { + let _ = device; // Unused on Windows + vec![ + "-f".to_string(), + "gdigrab".to_string(), + "-i".to_string(), + "desktop".to_string(), + ] + } + #[cfg(target_os = "linux")] + { + let _ = device; // Unused on Linux + vec![ + "-f".to_string(), + "x11grab".to_string(), + "-video_size".to_string(), + "1920x1080".to_string(), + "-i".to_string(), + ":0.0".to_string(), + ] + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + let _ = device; // Unused + vec![] // Unknown platform + } +} + +// Cleanup HLS directory - remove all .ts and .m3u8 files +fn cleanup_hls_directory(public_dir: &PathBuf) -> Result<(), String> { + eprintln!("๐Ÿงน Cleaning up HLS directory: {}", public_dir.display()); + + if !public_dir.exists() { + eprintln!(" Directory doesn't exist, skipping cleanup"); + return Ok(()); + } + + let mut cleaned_count = 0; + match fs::read_dir(public_dir) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension() { + if ext == "ts" || ext == "m3u8" { + match fs::remove_file(&path) { + Ok(_) => { + cleaned_count += 1; + eprintln!(" โœ“ Removed: {}", path.file_name().unwrap_or_default().to_string_lossy()); + } + Err(e) => { + eprintln!(" โš ๏ธ Failed to remove {}: {}", path.display(), e); + } + } + } + } + } + } + Err(e) => { + eprintln!(" โš ๏ธ Error reading directory entry: {}", e); + } + } + } + } + Err(e) => { + return Err(format!("Failed to read HLS directory: {}", e)); + } + } + + eprintln!("โœ… Cleanup complete: removed {} files", cleaned_count); + Ok(()) +} + +// Start FFmpeg process +async fn start_ffmpeg(public_dir: &PathBuf, device: Option<&str>) -> anyhow::Result { + // Clean up old files first + cleanup_hls_directory(public_dir).map_err(|e| anyhow::anyhow!("Cleanup failed: {}", e))?; + + // Ensure public directory exists + fs::create_dir_all(public_dir)?; + + let mut args = vec![ + "-loglevel".to_string(), + "info".to_string(), + "-fflags".to_string(), + "+genpts".to_string(), + "-probesize".to_string(), + "50M".to_string(), + "-analyzeduration".to_string(), + "50M".to_string(), + ]; + + // Add platform-specific input + args.extend(get_ffmpeg_input_args(device)); + + // Add encoding and output args + args.extend(vec![ + "-c:v".to_string(), + "libx264".to_string(), + "-preset".to_string(), + "ultrafast".to_string(), + "-tune".to_string(), + "zerolatency".to_string(), + "-profile:v".to_string(), + "baseline".to_string(), + "-level".to_string(), + "3.0".to_string(), + "-pix_fmt".to_string(), + "yuv420p".to_string(), + "-c:a".to_string(), + "aac".to_string(), + "-ar".to_string(), + "44100".to_string(), + "-b:a".to_string(), + "128k".to_string(), + "-ac".to_string(), + "2".to_string(), + "-f".to_string(), + "hls".to_string(), + "-hls_time".to_string(), + "2".to_string(), + "-hls_list_size".to_string(), + "5".to_string(), + "-hls_flags".to_string(), + "delete_segments+independent_segments".to_string(), + "-hls_segment_type".to_string(), + "mpegts".to_string(), + "-hls_segment_filename".to_string(), + format!("{}/segment_%03d.ts", public_dir.display()), + format!("{}/stream.m3u8", public_dir.display()), + ]); + + let mut cmd = Command::new("ffmpeg"); + cmd.args(&args); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let child = cmd.spawn()?; + Ok(child) +} + +// HTTP handler for API info +async fn hls_api_info(State(state): State>) -> axum::Json { + axum::Json(serde_json::json!({ + "code": state.access_code, + "port": state.port, + })) +} + + +// Serve HLS segment files with auth +async fn serve_hls_file( + path: axum::extract::Path, + State(state): State>, + headers: axum::http::HeaderMap, + query: axum::extract::Query>, +) -> Result { + let path_str = path.as_str(); + eprintln!("๐Ÿ“ฆ Request for segment: {}", path_str); + + // Validate access code for segment files + let provided_code = headers + .get("x-access-code") + .and_then(|h| h.to_str().ok()) + .or_else(|| query.get("code").map(|s| s.as_str())); + + if let Some(code) = provided_code { + if code != state.access_code { + eprintln!("โŒ Invalid access code for segment: {}", path_str); + return Err(StatusCode::FORBIDDEN); + } + } else { + eprintln!("โŒ No access code provided for segment: {}", path_str); + return Err(StatusCode::FORBIDDEN); + } + + // Construct full filename (path is like "012.ts" from route "/segment_:path") + let filename = format!("segment_{}", path_str); + let file_path = state.public_dir.join(&filename); + + eprintln!("๐Ÿ“ Looking for file: {}", file_path.display()); + eprintln!("๐Ÿ“ Public dir: {}", state.public_dir.display()); + + if file_path.exists() { + eprintln!("โœ… Found segment file: {}", filename); + let content = fs::read(&file_path).map_err(|e| { + eprintln!("โŒ Error reading file: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let content_type = "video/mp2t"; + + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, content_type)], + content, + )) + } else { + eprintln!("โŒ Segment file not found: {}", filename); + // List files in directory for debugging + if let Ok(entries) = fs::read_dir(&state.public_dir) { + eprintln!("๐Ÿ“‚ Files in public dir:"); + for entry in entries.flatten() { + if let Ok(name) = entry.file_name().into_string() { + eprintln!(" - {}", name); + } + } + } + Err(StatusCode::NOT_FOUND) + } +} + +// Start HLS server +async fn start_hls_server(state: Arc) -> anyhow::Result<()> { + use axum::routing::get; + + // Helper to get client IP + fn get_client_ip(headers: &axum::http::HeaderMap) -> String { + // Try to get IP from X-Forwarded-For (for tunnel) or X-Real-IP + if let Some(forwarded) = headers.get("x-forwarded-for") { + if let Ok(forwarded_str) = forwarded.to_str() { + // Take the first IP if there are multiple + if let Some(ip) = forwarded_str.split(',').next() { + return ip.trim().to_string(); + } + } + } + if let Some(real_ip) = headers.get("x-real-ip") { + if let Ok(ip_str) = real_ip.to_str() { + return ip_str.to_string(); + } + } + // Fallback to "unknown" + "unknown".to_string() + } + + // Helper to track viewer + // For better tracking, we use IP + User-Agent as a unique identifier + // This helps distinguish multiple clients behind the same tunnel + fn track_viewer(state: &Arc, ip: String, user_agent: Option<&str>) { + let mut viewers = state.viewers.lock().unwrap(); + + // Create a unique viewer ID from IP and User-Agent + let viewer_id = if let Some(ua) = user_agent { + format!("{}|{}", ip, ua) + } else { + ip.clone() + }; + + let was_new = !viewers.contains_key(&viewer_id); + viewers.insert(viewer_id.clone(), SystemTime::now()); + let count = viewers.len(); + + if was_new { + eprintln!("๐Ÿ‘ฅ New viewer connected: {} (Total: {})", ip, count); + } + } + + // Handler for stream.m3u8 (no path param) + async fn serve_stream_m3u8( + State(state): State>, + headers: axum::http::HeaderMap, + query: axum::extract::Query>, + ) -> Result { + // Validate access code + let provided_code = headers + .get("x-access-code") + .and_then(|h| h.to_str().ok()) + .or_else(|| query.get("code").map(|s| s.as_str())); + + if let Some(code) = provided_code { + if code != state.access_code { + return Err(StatusCode::FORBIDDEN); + } + } else { + return Err(StatusCode::FORBIDDEN); + } + + // Track viewer + let client_ip = get_client_ip(&headers); + let user_agent = headers.get("user-agent") + .and_then(|h| h.to_str().ok()); + track_viewer(&state, client_ip, user_agent); + + let file_path = state.public_dir.join("stream.m3u8"); + if file_path.exists() { + let content = fs::read(&file_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/vnd.apple.mpegurl")], + content, + )) + } else { + Err(StatusCode::NOT_FOUND) + } + } + + // Handler for segment files using a catch-all approach + async fn serve_segment_catchall( + uri: axum::http::Uri, + State(state): State>, + headers: axum::http::HeaderMap, + query: axum::extract::Query>, + ) -> Result { + let path = uri.path().trim_start_matches('/'); + eprintln!("๐Ÿ“ฆ Request for: {}", path); + + // Only handle segment files + if !path.starts_with("segment_") || !path.ends_with(".ts") { + return Err(StatusCode::NOT_FOUND); + } + + // Validate access code + let provided_code = headers + .get("x-access-code") + .and_then(|h| h.to_str().ok()) + .or_else(|| query.get("code").map(|s| s.as_str())); + + if let Some(code) = provided_code { + if code != state.access_code { + eprintln!("โŒ Invalid access code for segment: {}", path); + return Err(StatusCode::FORBIDDEN); + } + } else { + eprintln!("โŒ No access code provided for segment: {}", path); + return Err(StatusCode::FORBIDDEN); + } + + // Track viewer (update timestamp to keep them active) + let client_ip = get_client_ip(&headers); + let user_agent = headers.get("user-agent") + .and_then(|h| h.to_str().ok()); + track_viewer(&state, client_ip, user_agent); + + let file_path = state.public_dir.join(path); + eprintln!("๐Ÿ“ Looking for file: {}", file_path.display()); + + if file_path.exists() { + eprintln!("โœ… Found segment file: {}", path); + let content = fs::read(&file_path).map_err(|e| { + eprintln!("โŒ Error reading file: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "video/mp2t")], + content, + )) + } else { + eprintln!("โŒ Segment file not found: {}", path); + // List files in directory for debugging + if let Ok(entries) = fs::read_dir(&state.public_dir) { + eprintln!("๐Ÿ“‚ Files in public dir:"); + for entry in entries.flatten() { + if let Ok(name) = entry.file_name().into_string() { + eprintln!(" - {}", name); + } + } + } + Err(StatusCode::NOT_FOUND) + } + } + + use axum::routing::any; + + // Spawn cleanup task to remove stale viewers (older than 15 seconds) + // HLS clients typically request segments every 2 seconds, so 15 seconds is a safe timeout + let cleanup_state = state.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5)); + loop { + interval.tick().await; + let mut viewers = cleanup_state.viewers.lock().unwrap(); + let now = SystemTime::now(); + let before_count = viewers.len(); + let timeout_secs = 15; // Remove viewers inactive for 15 seconds + + viewers.retain(|ip, last_seen| { + if let Ok(duration) = now.duration_since(*last_seen) { + let is_active = duration.as_secs() < timeout_secs; + if !is_active { + eprintln!(" ๐Ÿ—‘๏ธ Removing inactive viewer: {} (last seen {}s ago)", ip, duration.as_secs()); + } + is_active + } else { + eprintln!(" ๐Ÿ—‘๏ธ Removing viewer with invalid timestamp: {}", ip); + false + } + }); + let after_count = viewers.len(); + if before_count != after_count { + eprintln!("๐Ÿงน Cleaned up {} stale viewers. Active: {}", before_count - after_count, after_count); + } + } + }); + + let app = Router::new() + .route("/api/info", get(hls_api_info)) + .route("/stream.m3u8", get(serve_stream_m3u8)) + .fallback(any(serve_segment_catchall)) + .layer(CorsLayer::permissive()) + .with_state(state.clone()); + + let addr = format!("127.0.0.1:{}", state.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + eprintln!("โœ… HLS server started on http://{}", addr); + eprintln!(" Access code: {}", state.access_code); + + axum::serve(listener, app).await?; + Ok(()) +} + +// Tauri command to start HLS server +#[tauri::command] +async fn start_hls_server_cmd( + state: tauri::State<'_, Arc>>>, + app_handle: tauri::AppHandle, + device: Option, +) -> Result { + // Check if server is already running + { + let mut handle_opt = state.lock().unwrap(); + if handle_opt.is_some() { + return Err("HLS server is already running".to_string()); + } + } + + // Get app data directory for public folder + let app_data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + let public_dir = app_data_dir.join("hls_public"); + + // Generate access code + let access_code = generate_access_code(); + let port = 3000u16; + + let hls_state = Arc::new(HlsServerState { + access_code: access_code.clone(), + port, + public_dir: public_dir.clone(), + viewers: Arc::new(Mutex::new(std::collections::HashMap::new())), + }); + + // Start FFmpeg with device selection + let device_str = device.as_deref(); + let ffmpeg_handle = start_ffmpeg(&public_dir, device_str) + .await + .map_err(|e| format!("Failed to start FFmpeg: {}", e))?; + + // Start HTTP server + let server_state = hls_state.clone(); + let server_handle = tokio::spawn(async move { + start_hls_server(server_state).await + }); + + // Start localtunnel + let (tunnel_handle, tunnel_url, tunnel_domain) = match start_localtunnel(port).await { + Ok((handle, url, domain)) => { + eprintln!("โœ… Tunnel created: {}", url); + eprintln!(" Domain: {}", domain); + (Some(handle), Some(url), Some(domain)) + } + Err(e) => { + eprintln!("โš ๏ธ Failed to create tunnel: {}", e); + eprintln!(" Server still running on localhost - tunnel creation failed"); + (None, None, None) + } + }; + + // Store handle + { + let mut handle_opt = state.lock().unwrap(); + *handle_opt = Some(HlsServerHandle { + ffmpeg_handle: Some(ffmpeg_handle), + server_handle, + tunnel_handle, + access_code: access_code.clone(), + port, + tunnel_url: tunnel_url.clone(), + tunnel_domain: tunnel_domain.clone(), + public_dir: public_dir.clone(), + viewers: hls_state.viewers.clone(), + }); + } + + let mut response = serde_json::json!({ + "code": access_code, + "port": port, + "url": format!("http://localhost:{}", port), + }); + + if let (Some(ref url), Some(ref domain)) = (tunnel_url, tunnel_domain) { + response["tunnelUrl"] = serde_json::Value::String(url.clone()); + response["tunnelDomain"] = serde_json::Value::String(domain.clone()); + } + + Ok(response) +} + +// Tauri command to stop HLS server +#[tauri::command] +async fn stop_hls_server_cmd( + state: tauri::State<'_, Arc>>>, +) -> Result<(), String> { + let handle_opt = { + let mut guard = state.lock().unwrap(); + guard.take() + }; + + if let Some(mut handle) = handle_opt { + // Kill FFmpeg + if let Some(mut ffmpeg) = handle.ffmpeg_handle.take() { + let _ = ffmpeg.kill().await; + } + // Kill tunnel + if let Some(mut tunnel) = handle.tunnel_handle.take() { + let _ = tunnel.kill().await; + } + // Abort server task + handle.server_handle.abort(); + + // Clean up HLS directory + eprintln!("๐Ÿงน Cleaning up HLS directory on server stop..."); + if let Err(e) = cleanup_hls_directory(&handle.public_dir) { + eprintln!("โš ๏ธ Warning: Failed to cleanup HLS directory: {}", e); + } + + Ok(()) + } else { + Err("HLS server is not running".to_string()) + } +} + +// Tauri command to get HLS server info +#[tauri::command] +async fn get_hls_server_info( + state: tauri::State<'_, Arc>>>, +) -> Result, String> { + let handle_opt = state.lock().unwrap(); + if let Some(handle) = handle_opt.as_ref() { + // Get viewer count + let viewer_count = { + let viewers = handle.viewers.lock().unwrap(); + viewers.len() + }; + + let mut info = serde_json::json!({ + "running": true, + "code": handle.access_code, + "port": handle.port, + "url": format!("http://localhost:{}", handle.port), + "viewers": viewer_count, + }); + + if let Some(ref tunnel_url) = handle.tunnel_url { + info["tunnelUrl"] = serde_json::Value::String(tunnel_url.clone()); + } + if let Some(ref tunnel_domain) = handle.tunnel_domain { + info["tunnelDomain"] = serde_json::Value::String(tunnel_domain.clone()); + } + + Ok(Some(info)) + } else { + Ok(None) + } +} + +// Tauri command to get viewer count +#[tauri::command] +async fn get_hls_viewer_count( + state: tauri::State<'_, Arc>>>, +) -> Result { + let handle_opt = state.lock().unwrap(); + if let Some(handle) = handle_opt.as_ref() { + let viewers = handle.viewers.lock().unwrap(); + Ok(viewers.len()) + } else { + Ok(0) + } +} + pub fn run() { // --- FIX 1: Define the handler logic --- // This handler will be attached to the main builder. @@ -599,6 +1539,10 @@ pub fn run() { // Start clipboard monitor start_clipboard_monitor(app.handle().clone(), db.clone()); + // Initialize HLS server state + let hls_server_state = Arc::new(Mutex::new(None::)); + app.manage(hls_server_state); + #[cfg(desktop)] { // --- FIX 2: Register the shortcut --- @@ -623,6 +1567,12 @@ pub fn run() { open_file, refresh_file_index, hide_window, + check_ffmpeg, + list_ffmpeg_devices, + start_hls_server_cmd, + stop_hls_server_cmd, + get_hls_server_info, + get_hls_viewer_count, ]) .run(tauri::generate_context!()) .expect("error while running tauri"); diff --git a/src/App.css b/src/App.css index 6c9af0e..3e46c63 100644 --- a/src/App.css +++ b/src/App.css @@ -382,4 +382,131 @@ .file-item { animation: slideIn 0.2s ease; +} + +/* Screen Share Page Styles */ +.screen-share-page { + width: 100%; + max-width: 900px; + margin: 0 auto; + padding: 2rem; +} + +.screen-share-container { + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 2rem; + color: white; +} + +.screen-share-container h2 { + margin: 0 0 1.5rem 0; + font-size: 1.5rem; +} + +.code-section { + margin: 1.5rem 0; +} + +.code-display { + font-size: 2rem; + font-weight: bold; + letter-spacing: 8px; + text-align: center; + padding: 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin: 0.5rem 0; + font-family: monospace; +} + +.btn-primary, +.btn-secondary, +.btn-danger { + padding: 10px 20px; + font-size: 1rem; + border: none; + border-radius: 6px; + cursor: pointer; + margin: 5px; + transition: all 0.2s; +} + +.btn-primary { + background: #007AFF; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #0056CC; +} + +.btn-primary:disabled { + background: #555; + cursor: not-allowed; + opacity: 0.5; +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); +} + +.btn-danger { + background: #FF3B30; + color: white; +} + +.btn-danger:hover { + background: #CC2E25; +} + +.mode-section { + margin: 1.5rem 0; +} + +.mode-section label { + display: inline-flex; + align-items: center; + margin-right: 1rem; + cursor: pointer; + color: rgba(255, 255, 255, 0.8); +} + +.mode-section input[type="radio"] { + margin-right: 0.5rem; + cursor: pointer; +} + +.code-input { + padding: 10px; + font-size: 1.2rem; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.1); + color: white; + text-align: center; + letter-spacing: 4px; + text-transform: uppercase; +} + +.code-input:focus { + outline: none; + border-color: #007AFF; + background: rgba(255, 255, 255, 0.15); +} + +.video-container { + margin-top: 1rem; + width: 100%; +} + +.video-container video { + width: 100%; + border-radius: 8px; } \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 81ea9e6..281437c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import HomeOptions from "./components/HomeOptions"; import ClipboardPage from "./components/ClipboardPage"; import OnlineSearchPage from "./components/OnlineSearchPage"; import OpenFilePage from "./components/OpenFilePage"; +import HlsScreenSharePage from "./components/HlsScreenSharePage"; import GuidePage from "./components/guidePages/GuidePage"; @@ -78,6 +79,7 @@ function App() { )} {currentPage === "open-app" && } + {currentPage === "hls-screen-share" && } {currentPage === "open-guide" && } diff --git a/src/components/HlsScreenSharePage.jsx b/src/components/HlsScreenSharePage.jsx new file mode 100644 index 0000000..e79da42 --- /dev/null +++ b/src/components/HlsScreenSharePage.jsx @@ -0,0 +1,379 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; + +export default function HlsScreenSharePage({ query }) { + const [hasFfmpeg, setHasFfmpeg] = useState(null); + const [serverInfo, setServerInfo] = useState(null); + const [error, setError] = useState(''); + const [isServerRunning, setIsServerRunning] = useState(false); + const [devices, setDevices] = useState({ video: [], audio: [] }); + const [selectedVideoDevice, setSelectedVideoDevice] = useState('2'); + const [selectedAudioDevice, setSelectedAudioDevice] = useState('0'); + const [viewerCount, setViewerCount] = useState(0); + + useEffect(() => { + // Check FFmpeg availability + checkFfmpeg(); + // Check if server is already running + checkServerStatus(); + // Load devices + loadDevices(); + }, []); + + // Log device state changes + useEffect(() => { + console.log('๐Ÿ”„ Frontend: Device state changed:', { + videoDevices: devices.video?.length || 0, + audioDevices: devices.audio?.length || 0, + selectedVideo: selectedVideoDevice, + selectedAudio: selectedAudioDevice, + shouldShowDeviceUI: devices.video.length > 0 || devices.audio.length > 0 + }); + }, [devices, selectedVideoDevice, selectedAudioDevice]); + + const checkFfmpeg = async () => { + try { + const available = await invoke('check_ffmpeg'); + setHasFfmpeg(available); + if (!available) { + setError('FFmpeg is not installed. Please install FFmpeg to use screen sharing.'); + } + } catch (err) { + setError(`Failed to check FFmpeg: ${err}`); + setHasFfmpeg(false); + } + }; + + const loadDevices = async () => { + console.log('๐Ÿ” Frontend: Starting to load devices...'); + try { + console.log('๐Ÿ“ž Frontend: Calling list_ffmpeg_devices...'); + const deviceList = await invoke('list_ffmpeg_devices'); + console.log('โœ… Frontend: Received device list:', deviceList); + console.log('๐Ÿ“Š Frontend: Video devices:', deviceList.video); + console.log('๐Ÿ“Š Frontend: Audio devices:', deviceList.audio); + + setDevices(deviceList); + + // Set default video device to index 2 if available + if (deviceList.video && deviceList.video.length > 0) { + console.log(`๐Ÿ“น Frontend: Found ${deviceList.video.length} video devices`); + const device2 = deviceList.video.find(d => d.index === 2); + if (device2) { + console.log('โœ… Frontend: Setting default video device to index 2'); + setSelectedVideoDevice('2'); + } else if (deviceList.video.length > 0) { + console.log(`โš ๏ธ Frontend: Device 2 not found, using first device: ${deviceList.video[0].index}`); + setSelectedVideoDevice(deviceList.video[0].index.toString()); + } + } else { + console.log('โš ๏ธ Frontend: No video devices found'); + } + + // Set default audio device to index 0 if available + if (deviceList.audio && deviceList.audio.length > 0) { + console.log(`๐Ÿ”Š Frontend: Found ${deviceList.audio.length} audio devices`); + const device0 = deviceList.audio.find(d => d.index === 0); + if (device0) { + console.log('โœ… Frontend: Setting default audio device to index 0'); + setSelectedAudioDevice('0'); + } else if (deviceList.audio.length > 0) { + console.log(`โš ๏ธ Frontend: Device 0 not found, using first device: ${deviceList.audio[0].index}`); + setSelectedAudioDevice(deviceList.audio[0].index.toString()); + } + } else { + console.log('โš ๏ธ Frontend: No audio devices found'); + } + + console.log('โœ… Frontend: Device loading complete. Final state:', { + videoDevices: deviceList.video?.length || 0, + audioDevices: deviceList.audio?.length || 0, + selectedVideo: selectedVideoDevice, + selectedAudio: selectedAudioDevice + }); + } catch (err) { + console.error('โŒ Frontend: Failed to load devices:', err); + console.error('โŒ Frontend: Error details:', JSON.stringify(err, null, 2)); + // Don't show error, just use defaults + } + }; + + const checkServerStatus = async () => { + try { + const info = await invoke('get_hls_server_info'); + if (info && info.running) { + setIsServerRunning(true); + setServerInfo({ + code: info.code, + port: info.port, + url: info.url, + tunnelUrl: info.tunnelUrl || null, + tunnelDomain: info.tunnelDomain || null, + }); + setViewerCount(info.viewers || 0); + } + } catch (err) { + // Server not running, ignore + } + }; + + // Poll for viewer count when server is running + useEffect(() => { + if (!isServerRunning) { + setViewerCount(0); + return; + } + + const interval = setInterval(async () => { + try { + const count = await invoke('get_hls_viewer_count'); + setViewerCount(count); + } catch (err) { + // Ignore errors + } + }, 2000); // Poll every 2 seconds + + return () => clearInterval(interval); + }, [isServerRunning]); + + const startServer = async () => { + try { + setError(''); + const device = `${selectedVideoDevice}:${selectedAudioDevice}`; + const info = await invoke('start_hls_server_cmd', { device }); + setServerInfo(info); + setIsServerRunning(true); + console.log('HLS server started:', info); + } catch (err) { + setError(`Failed to start server: ${err}`); + } + }; + + const stopServer = async () => { + try { + setError(''); + await invoke('stop_hls_server_cmd'); + setIsServerRunning(false); + setServerInfo(null); + setViewerCount(0); + console.log('HLS server stopped'); + } catch (err) { + setError(`Failed to stop server: ${err}`); + } + }; + + return ( +
+
+

๐Ÿ“บ HLS Screen Share Server

+ + {hasFfmpeg === false && ( +
+

FFmpeg Not Found

+

FFmpeg is required for screen sharing. Please install FFmpeg:

+
    +
  • macOS: brew install ffmpeg
  • +
  • Windows: choco install ffmpeg
  • +
  • Linux: sudo apt-get install ffmpeg
  • +
+ +
+ )} + + {hasFfmpeg === null && ( +
+

Checking FFmpeg availability...

+
+ )} + + {hasFfmpeg === true && !isServerRunning && ( +
+

Start the HLS streaming server to begin screen sharing.

+

+ The server will capture your screen and stream it via HLS. Use your Vercel client to view the stream. +

+ + {(() => { + const shouldShow = devices.video.length > 0 || devices.audio.length > 0; + console.log('๐ŸŽจ Frontend: Rendering device selection UI check:', { + shouldShow, + videoCount: devices.video.length, + audioCount: devices.audio.length, + devices: devices + }); + return shouldShow; + })() && ( +
+

Device Selection

+ + {devices.video.length > 0 && ( +
+ + +
+ )} + + {devices.audio.length > 0 && ( +
+ + +
+ )} + +

+ Selected: {selectedVideoDevice}:{selectedAudioDevice} +

+
+ )} + + +
+ )} + + {isServerRunning && serverInfo && ( + <> +
+

Access Code:

+
{serverInfo.code}
+ + +
+

+ ๐Ÿ‘ฅ Viewers: {viewerCount} +

+

+ {viewerCount === 0 ? 'No active viewers' : viewerCount === 1 ? '1 person watching' : `${viewerCount} people watching`} +

+
+
+ +
+

Server Information:

+
+

Local URL: {serverInfo.url}

+ {serverInfo.tunnelUrl && ( + <> +

๐ŸŒ Tunnel URL:

+ + {serverInfo.tunnelUrl} + + {serverInfo.tunnelDomain && ( +

+ Domain: {serverInfo.tunnelDomain} +

+ )} + + )} +

Stream Endpoint:

+ + {serverInfo.tunnelUrl || serverInfo.url}/stream.m3u8?code={serverInfo.code} + +
+ + {serverInfo.tunnelDomain && ( + + )} + +
+ +
+

Instructions:

+
    +
  1. The server is now capturing your screen and streaming via HLS
  2. +
  3. Use your Vercel client to connect to this server
  4. + {serverInfo.tunnelDomain ? ( + <> +
  5. Enter the Domain: {serverInfo.tunnelDomain}
  6. +
  7. Enter the Access Code: {serverInfo.code}
  8. +
  9. The client will connect via the tunnel URL
  10. + + ) : ( + <> +
  11. Provide the access code and localhost URL to your client
  12. +
  13. The client will connect to: {serverInfo.url}/stream.m3u8?code={serverInfo.code}
  14. +
  15. โš ๏ธ Tunnel not available - using localhost only
  16. + + )} +
+
+ +
+

โœ… Server running at: {serverInfo.url}

+
+ + )} + + {error && ( +
+

โŒ {error}

+
+ )} +
+
+ ); +} + diff --git a/src/components/HomeOptions.jsx b/src/components/HomeOptions.jsx index 9ed8a8f..697c984 100644 --- a/src/components/HomeOptions.jsx +++ b/src/components/HomeOptions.jsx @@ -7,6 +7,7 @@ const OPTIONS = [ { title: "Clipboard", icon: "๐Ÿ“‹", page: "clipboard" }, { title: "Online Search", icon: "๐Ÿ”", page: "online-search" }, { title: "Open App", icon: "๐Ÿ“", page: "open-app" }, + { title: "HLS Screen Share", icon: "๐Ÿ“บ", page: "hls-screen-share" }, { title: "Tutorial", icon: "๐Ÿงญ", page: "open-guide" }, ];