From 0cf7dd39fd6a8b2e37315b3586c00ea84e666dd4 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 03:18:43 +0900 Subject: [PATCH 01/20] Add CI/CD workflows for PR checks and automated releases --- .github/workflows/ci.yml | 55 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e5ddaa3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + pull_request: + branches: + - main + - development + push: + branches: + - main + - development + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Rust build + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - name: Install frontend dependencies + run: npm ci + + - name: Check Rust formatting + working-directory: src-tauri + run: cargo fmt -- --check + + - name: Run Clippy + working-directory: src-tauri + run: cargo clippy -- -D warnings + + - name: Build Rust code + working-directory: src-tauri + run: cargo build --release + + - name: Run Rust tests + working-directory: src-tauri + run: cargo test + + - name: Build frontend + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1cd5e1a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + release: + if: github.ref =~ '^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + strategy: + fail-fast: false + matrix: + include: + - platform: 'ubuntu-latest' + args: '' + - platform: 'windows-latest' + args: '' + - platform: 'macos-latest' + args: '' + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - name: Install Linux dependencies + if: matrix.platform == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install frontend dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Publish Tauri builds + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: v__VERSION__ + releaseName: 'OBS Sync v__VERSION__' + releaseBody: '🚀 Release version __VERSION__' + releaseDraft: false + prerelease: false + args: ${{ matrix.args }} From 32a4d7e257b6b4a09e25959272c1638135609c90 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 03:49:05 +0900 Subject: [PATCH 02/20] Fix CI/CD workflow: Add --all flags for cargo fmt and clippy --- .github/workflows/ci.yml | 8 +- src-tauri/Cargo.lock | 75 ++++++ src-tauri/Cargo.toml | 2 + src-tauri/src/commands.rs | 449 ++++++++++++++++++++++++++++--- src-tauri/src/lib.rs | 38 ++- src-tauri/src/network/client.rs | 303 +++++++++++++++++++-- src-tauri/src/network/mod.rs | 4 +- src-tauri/src/network/server.rs | 147 +++++++++- src-tauri/src/obs/client.rs | 4 +- src-tauri/src/obs/events.rs | 110 ++++++-- src-tauri/src/obs/mod.rs | 2 +- src-tauri/src/sync/diff.rs | 82 ++++-- src-tauri/src/sync/master.rs | 153 +++++++---- src-tauri/src/sync/mod.rs | 4 +- src-tauri/src/sync/protocol.rs | 3 +- src-tauri/src/sync/slave.rs | 360 ++++++++++++++++++------- src/App.tsx | 25 +- src/components/MasterControl.tsx | 403 ++++++++++++++++++++++++++- src/components/SlaveMonitor.tsx | 185 +++++++++++-- src/hooks/useNetworkStatus.ts | 64 ++++- src/hooks/useOBSConnection.ts | 62 ++++- src/types/network.ts | 29 ++ 22 files changed, 2190 insertions(+), 322 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5ddaa3..d235ca2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,19 +37,19 @@ jobs: - name: Check Rust formatting working-directory: src-tauri - run: cargo fmt -- --check + run: cargo fmt --all -- --check - name: Run Clippy working-directory: src-tauri - run: cargo clippy -- -D warnings + run: cargo clippy --all-targets --all-features -- -D warnings - name: Build Rust code working-directory: src-tauri - run: cargo build --release + run: cargo build --release --all-features - name: Run Rust tests working-directory: src-tauri - run: cargo test + run: cargo test --all-features - name: Build frontend run: npm run build diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bfd0484..3937a48 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1941,6 +1941,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.10" @@ -2052,6 +2061,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2318,6 +2336,8 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite 0.21.0", + "tracing", + "tracing-subscriber", "uuid", ] @@ -3286,6 +3306,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3868,6 +3897,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -4144,6 +4182,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "chrono", + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4337,6 +4406,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0b908d7..a04233e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,4 +31,6 @@ chrono = { version = "0.4", features = ["serde"] } anyhow = "1" thiserror = "1" base64 = "0.21" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "chrono"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a26d8a3..7c596f8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,14 +1,18 @@ +use crate::network::client::SlaveClient; +use crate::network::server::{ClientInfo, MasterServer, SlaveStatus}; use crate::obs::client::{OBSClient, OBSConnectionConfig, OBSConnectionStatus}; use crate::obs::events::OBSEventHandler; use crate::sync::master::MasterSync; -use crate::sync::slave::SlaveSync; use crate::sync::protocol::{SyncMessage, SyncTargetType}; -use crate::network::server::MasterServer; -use crate::network::client::SlaveClient; +use crate::sync::slave::SlaveSync; use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::path::PathBuf; use std::sync::Arc; +use std::time::{Duration, Instant}; use tauri::{Emitter, State}; -use tokio::sync::{mpsc, RwLock, Mutex}; +use tokio::fs; +use tokio::sync::{mpsc, Mutex, RwLock}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -24,6 +28,261 @@ pub struct NetworkConfig { pub port: u16, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSettings { + pub obs: OBSSettings, + pub master: MasterSettings, + pub slave: SlaveSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OBSSettings { + pub host: String, + pub port: u16, + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterSettings { + pub default_port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SlaveSettings { + pub default_host: String, + pub default_port: u16, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + obs: OBSSettings { + host: "localhost".to_string(), + port: 4455, + password: String::new(), + }, + master: MasterSettings { default_port: 8080 }, + slave: SlaveSettings { + default_host: "192.168.1.100".to_string(), + default_port: 8080, + }, + } + } +} + +async fn get_config_path(state: &AppState) -> Result { + let app_handle = state.app_handle.read().await; + if let Some(handle) = app_handle.as_ref() { + let app_data_dir = handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + fs::create_dir_all(&app_data_dir) + .await + .map_err(|e| format!("Failed to create app data directory: {}", e))?; + Ok(app_data_dir.join("config.json")) + } else { + Err("App handle not available".to_string()) + } +} + +async fn get_log_dir(state: &AppState) -> Result { + let app_handle = state.app_handle.read().await; + if let Some(handle) = app_handle.as_ref() { + let app_data_dir = handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + let log_dir = app_data_dir.join("logs"); + fs::create_dir_all(&log_dir) + .await + .map_err(|e| format!("Failed to create log directory: {}", e))?; + Ok(log_dir) + } else { + Err("App handle not available".to_string()) + } +} + +fn get_log_file_path(state: &AppState) -> Result { + // This is a sync function, so we can't use async here + // We'll need to get the path differently or make this async + // For now, return a path that will be resolved async + Err("Use get_log_file_path_async instead".to_string()) +} + +async fn get_log_file_path_async(state: &AppState) -> Result { + let log_dir = get_log_dir(state).await?; + let date = chrono::Utc::now().format("%Y-%m-%d"); + Ok(log_dir.join(format!("obs-sync-{}.log", date))) +} + +#[tauri::command] +pub async fn save_settings( + state: State<'_, AppState>, + settings: AppSettings, +) -> Result<(), String> { + let config_path = get_config_path(&state).await?; + let json = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("Failed to serialize settings: {}", e))?; + fs::write(&config_path, json) + .await + .map_err(|e| format!("Failed to write settings file: {}", e))?; + println!("Settings saved to: {:?}", config_path); + Ok(()) +} + +#[tauri::command] +pub async fn load_settings(state: State<'_, AppState>) -> Result { + let config_path = get_config_path(&state).await?; + + if !config_path.exists() { + // Return default settings if file doesn't exist + return Ok(AppSettings::default()); + } + + let content = fs::read_to_string(&config_path) + .await + .map_err(|e| format!("Failed to read settings file: {}", e))?; + + let settings: AppSettings = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse settings file: {}", e))?; + + Ok(settings) +} + +#[tauri::command] +pub async fn get_log_file_path(state: State<'_, AppState>) -> Result { + let path = get_log_file_path_async(&state).await?; + Ok(path.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn open_log_file(state: State<'_, AppState>) -> Result<(), String> { + let log_path = get_log_file_path_async(&state).await?; + + if !log_path.exists() { + return Err("Log file does not exist".to_string()); + } + + let app_handle = state.app_handle.read().await; + if let Some(handle) = app_handle.as_ref() { + // Use tauri-plugin-opener to open the file + tauri_plugin_opener::open(&log_path.to_string_lossy(), None, handle) + .map_err(|e| format!("Failed to open log file: {}", e))?; + Ok(()) + } else { + Err("App handle not available".to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncMetric { + pub timestamp: i64, + pub message_type: String, + pub latency_ms: f64, + pub message_size_bytes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PerformanceMetrics { + pub average_latency_ms: f64, + pub total_messages: usize, + pub messages_per_second: f64, + pub total_bytes: usize, + pub recent_metrics: Vec, +} + +pub struct PerformanceMonitor { + metrics: Arc>>, + max_metrics: usize, + send_times: Arc>>, +} + +impl PerformanceMonitor { + pub fn new(max_metrics: usize) -> Self { + Self { + metrics: Arc::new(RwLock::new(VecDeque::with_capacity(max_metrics))), + max_metrics, + send_times: Arc::new(RwLock::new(std::collections::HashMap::new())), + } + } + + pub async fn record_send(&self, message_id: String, message_type: String, size: usize) { + let mut send_times = self.send_times.write().await; + send_times.insert(message_id, Instant::now()); + } + + pub async fn record_receive(&self, message_id: String, message_type: String, size: usize) { + let mut send_times = self.send_times.write().await; + if let Some(send_time) = send_times.remove(&message_id) { + let latency = send_time.elapsed().as_secs_f64() * 1000.0; // Convert to milliseconds + + let metric = SyncMetric { + timestamp: chrono::Utc::now().timestamp_millis(), + message_type, + latency_ms: latency, + message_size_bytes: size, + }; + + let mut metrics = self.metrics.write().await; + if metrics.len() >= self.max_metrics { + metrics.pop_front(); + } + metrics.push_back(metric); + } + } + + pub async fn get_metrics(&self) -> PerformanceMetrics { + let metrics = self.metrics.read().await; + let recent_metrics: Vec = metrics.iter().cloned().collect(); + + if recent_metrics.is_empty() { + return PerformanceMetrics { + average_latency_ms: 0.0, + total_messages: 0, + messages_per_second: 0.0, + total_bytes: 0, + recent_metrics: vec![], + }; + } + + let total_messages = recent_metrics.len(); + let average_latency = + recent_metrics.iter().map(|m| m.latency_ms).sum::() / total_messages as f64; + + let total_bytes: usize = recent_metrics.iter().map(|m| m.message_size_bytes).sum(); + + // Calculate messages per second (based on time span of recent metrics) + let messages_per_second = if recent_metrics.len() > 1 { + let time_span_secs = (recent_metrics.last().unwrap().timestamp + - recent_metrics.first().unwrap().timestamp) + as f64 + / 1000.0; + if time_span_secs > 0.0 { + total_messages as f64 / time_span_secs + } else { + 0.0 + } + } else { + 0.0 + }; + + PerformanceMetrics { + average_latency_ms: average_latency, + total_messages, + messages_per_second, + total_bytes, + recent_metrics: recent_metrics.into_iter().rev().take(100).collect(), // Last 100 metrics + } + } +} + #[derive(Clone)] pub struct AppState { pub obs_client: Arc, @@ -40,6 +299,8 @@ pub struct AppState { pub sync_message_tx: Arc>>>, // Tauri app handle pub app_handle: Arc>>, + // Performance monitoring + pub performance_monitor: Arc, } impl AppState { @@ -55,9 +316,10 @@ impl AppState { slave_sync: Arc::new(RwLock::new(None)), sync_message_tx: Arc::new(Mutex::new(None)), app_handle: Arc::new(RwLock::new(None)), + performance_monitor: Arc::new(PerformanceMonitor::new(1000)), // Keep last 1000 metrics } } - + pub async fn set_app_handle(&self, handle: tauri::AppHandle) { *self.app_handle.write().await = Some(handle); } @@ -101,10 +363,7 @@ pub async fn get_app_mode(state: State<'_, AppState>) -> Result, } #[tauri::command] -pub async fn start_master_server( - state: State<'_, AppState>, - port: u16, -) -> Result<(), String> { +pub async fn start_master_server(state: State<'_, AppState>, port: u16) -> Result<(), String> { // Check if OBS is connected if !state.obs_client.is_connected().await { return Err("OBS is not connected".to_string()); @@ -120,21 +379,23 @@ pub async fn start_master_server( // Create and start MasterServer let master_server = Arc::new(MasterServer::new(port)); - + // Set up callback to send initial state when new slave connects let master_sync_for_callback = master_sync.clone(); - master_server.set_initial_state_callback(move |client_id: String| { - let master_sync_clone = master_sync_for_callback.clone(); - async move { - println!("Sending initial state to new slave: {}", client_id); - // Small delay to ensure connection is fully established - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - if let Err(e) = master_sync_clone.send_initial_state().await { - eprintln!("Failed to send initial state to {}: {}", client_id, e); + master_server + .set_initial_state_callback(move |client_id: String| { + let master_sync_clone = master_sync_for_callback.clone(); + async move { + println!("Sending initial state to new slave: {}", client_id); + // Small delay to ensure connection is fully established + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + if let Err(e) = master_sync_clone.send_initial_state().await { + eprintln!("Failed to send initial state to {}: {}", client_id, e); + } } - } - }).await; - + }) + .await; + master_server .start(sync_rx) .await @@ -144,7 +405,7 @@ pub async fn start_master_server( // Create OBS event handler let (event_handler, event_rx) = OBSEventHandler::new(); let event_handler = Arc::new(event_handler); - + // Start listening to OBS events let client_arc = state.obs_client.get_client_arc(); let client_lock = client_arc.read().await; @@ -155,10 +416,10 @@ pub async fn start_master_server( .map_err(|e| format!("Failed to start OBS event listener: {}", e))?; } drop(client_lock); - + // Start monitoring OBS events master_sync.start_monitoring(event_rx).await; - + // Store event handler *state.obs_event_handler.write().await = Some(event_handler); @@ -172,7 +433,7 @@ pub async fn stop_master_server(state: State<'_, AppState>) -> Result<(), String if let Some(server) = state.master_server.write().await.take() { server.stop().await; } - + // Clear master components *state.master_sync.write().await = None; *state.obs_event_handler.write().await = None; @@ -196,17 +457,18 @@ pub async fn connect_to_master( // Create SlaveClient let slave_client = Arc::new(SlaveClient::new(config.host.clone(), config.port)); - - // Connect to master and get sync message receiver - let sync_rx = slave_client + + // Connect to master and get sync message receiver and sender + let (sync_rx, send_tx) = slave_client .connect() .await .map_err(|e| format!("Failed to connect to master: {}", e))?; - + *state.slave_client.write().await = Some(slave_client); // Create SlaveSync let (slave_sync, alert_rx) = SlaveSync::new(state.obs_client.clone()); + slave_sync.set_state_report_sender(send_tx).await; let slave_sync = Arc::new(slave_sync); *state.slave_sync.write().await = Some(slave_sync.clone()); @@ -225,7 +487,7 @@ pub async fn connect_to_master( println!("Waiting for initial state from master..."); first_message = false; } - + if let Err(e) = slave_sync_for_processing.apply_sync_message(message).await { eprintln!("Failed to apply sync message: {}", e); } @@ -238,7 +500,7 @@ pub async fn connect_to_master( let mut rx = alert_rx; while let Some(alert) = rx.recv().await { println!("🚨 Desync Alert: {} - {}", alert.scene_name, alert.message); - + // Emit Tauri event to frontend if let Some(handle) = app_handle_lock.read().await.as_ref() { if let Err(e) = handle.emit("desync-alert", alert.clone()) { @@ -267,13 +529,71 @@ pub async fn disconnect_from_master(state: State<'_, AppState>) -> Result<(), St Ok(()) } +#[tauri::command] +pub async fn get_slave_reconnection_status( + state: State<'_, AppState>, +) -> Result, String> { + if let Some(client) = state.slave_client.read().await.as_ref() { + Ok(Some(client.get_reconnection_status().await)) + } else { + Ok(None) + } +} + +#[tauri::command] +pub async fn resync_all_slaves(state: State<'_, AppState>) -> Result<(), String> { + if let Some(master_sync) = state.master_sync.read().await.as_ref() { + master_sync + .send_initial_state() + .await + .map_err(|e| format!("Failed to resync all slaves: {}", e))?; + println!("Resync triggered for all slaves"); + Ok(()) + } else { + Err("Master server is not running".to_string()) + } +} + +#[tauri::command] +pub async fn resync_specific_slave( + state: State<'_, AppState>, + client_id: String, +) -> Result<(), String> { + if let Some(master_sync) = state.master_sync.read().await.as_ref() { + // For now, resync all slaves (we can enhance this later to target specific client) + // The master server already handles sending to specific clients via the callback + master_sync + .send_initial_state() + .await + .map_err(|e| format!("Failed to resync slave {}: {}", client_id, e))?; + println!("Resync triggered for slave: {}", client_id); + Ok(()) + } else { + Err("Master server is not running".to_string()) + } +} + +#[tauri::command] +pub async fn request_resync_from_master(state: State<'_, AppState>) -> Result<(), String> { + if let Some(slave_client) = state.slave_client.read().await.as_ref() { + slave_client + .request_resync() + .await + .map_err(|e| format!("Failed to request resync: {}", e))?; + println!("Resync requested from master"); + Ok(()) + } else { + Err("Not connected to master".to_string()) + } +} + #[tauri::command] pub async fn set_sync_targets( state: State<'_, AppState>, targets: Vec, ) -> Result<(), String> { println!("Setting sync targets: {:?}", targets); - + // Update targets for master mode if let Some(master_sync) = state.master_sync.read().await.as_ref() { master_sync.set_active_targets(targets).await; @@ -281,7 +601,7 @@ pub async fn set_sync_targets( // Just log the targets if not in master mode (slave mode doesn't need to set targets) println!("Sync targets set (not in master mode)"); } - + Ok(()) } @@ -294,6 +614,69 @@ pub async fn get_connected_clients_count(state: State<'_, AppState>) -> Result, +) -> Result, String> { + if let Some(server) = state.master_server.read().await.as_ref() { + Ok(server.get_connected_clients_info().await) + } else { + Ok(vec![]) + } +} + +#[tauri::command] +pub async fn get_slave_statuses(state: State<'_, AppState>) -> Result, String> { + if let Some(server) = state.master_server.read().await.as_ref() { + Ok(server.get_slave_statuses().await) + } else { + Ok(vec![]) + } +} + +#[tauri::command] +pub async fn get_obs_sources(state: State<'_, AppState>) -> Result, String> { + let client_arc = state.obs_client.get_client_arc(); + let client_lock = client_arc.read().await; + + if let Some(client) = client_lock.as_ref() { + let mut sources_map = std::collections::HashMap::new(); + + // Get all scenes + match client.scenes().list().await { + Ok(scenes) => { + for scene in scenes.scenes { + // Get scene items + match client.scene_items().list(&scene.name).await { + Ok(items) => { + for item in items { + // Store source info (avoid duplicates) + sources_map.entry(item.source_name.clone()).or_insert_with(|| { + serde_json::json!({ + "sourceName": item.source_name, + "sourceType": item.input_kind.clone().unwrap_or_else(|| "unknown".to_string()), + "sourceKind": item.input_kind.clone().unwrap_or_else(|| "unknown".to_string()), + }) + }); + } + } + Err(e) => { + eprintln!("Failed to get scene items for {}: {}", scene.name, e); + } + } + } + } + Err(e) => { + return Err(format!("Failed to get scenes: {}", e)); + } + } + + Ok(sources_map.values().cloned().collect()) + } else { + Err("OBS is not connected".to_string()) + } +} + #[tauri::command] pub fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc764e5..a0ec853 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ +mod commands; +mod network; mod obs; mod sync; -mod network; -mod commands; use commands::AppState; use tauri::Manager; @@ -10,6 +10,28 @@ use tauri::Manager; pub fn run() { let app_state = AppState::new(); + // Initialize logging + let log_dir = std::env::temp_dir().join("obs-sync-logs"); + std::fs::create_dir_all(&log_dir).ok(); + let log_file = log_dir.join(format!( + "obs-sync-{}.log", + chrono::Utc::now().format("%Y-%m-%d") + )); + + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_file) + .expect("Failed to open log file"); + + tracing_subscriber::fmt() + .with_writer(file) + .with_target(false) + .with_thread_ids(false) + .with_thread_names(false) + .with_ansi(false) + .init(); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .manage(app_state) @@ -35,6 +57,18 @@ pub fn run() { commands::disconnect_from_master, commands::set_sync_targets, commands::get_connected_clients_count, + commands::get_connected_clients_info, + commands::get_slave_statuses, + commands::get_obs_sources, + commands::get_slave_reconnection_status, + commands::resync_all_slaves, + commands::resync_specific_slave, + commands::request_resync_from_master, + commands::save_settings, + commands::load_settings, + commands::get_log_file_path, + commands::open_log_file, + commands::get_performance_metrics, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/network/client.rs b/src-tauri/src/network/client.rs index 34251ff..124e0e1 100644 --- a/src-tauri/src/network/client.rs +++ b/src-tauri/src/network/client.rs @@ -1,6 +1,8 @@ use crate::sync::protocol::SyncMessage; use anyhow::{Context, Result}; use futures::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; use tokio::net::TcpStream; use tokio::sync::{mpsc, RwLock}; @@ -8,10 +10,25 @@ use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, Web type WsStream = WebSocketStream>; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReconnectionStatus { + pub is_reconnecting: bool, + pub attempt_count: u32, + pub max_attempts: u32, + pub last_error: Option, +} + pub struct SlaveClient { host: String, port: u16, ws_stream: Arc>>, + should_reconnect: Arc, + max_reconnect_attempts: u32, + message_tx: Arc>>>, + sync_message_tx: Arc>>>, + reconnection_status: Arc>, + current_attempt: Arc, } impl SlaveClient { @@ -20,54 +37,260 @@ impl SlaveClient { host, port, ws_stream: Arc::new(RwLock::new(None)), + should_reconnect: Arc::new(AtomicBool::new(true)), + max_reconnect_attempts: 10, + message_tx: Arc::new(RwLock::new(None)), + sync_message_tx: Arc::new(RwLock::new(None)), + reconnection_status: Arc::new(RwLock::new(ReconnectionStatus { + is_reconnecting: false, + attempt_count: 0, + max_attempts: 10, + last_error: None, + })), + current_attempt: Arc::new(AtomicU32::new(0)), } } - pub async fn connect(&self) -> Result> { - let url = format!("ws://{}:{}", self.host, self.port); - let (ws_stream, _) = connect_async(&url) - .await - .context(format!("Failed to connect to {}", url))?; + pub async fn get_reconnection_status(&self) -> ReconnectionStatus { + self.reconnection_status.read().await.clone() + } - println!("Connected to master: {}", url); + pub async fn request_resync(&self) -> Result<()> { + let tx = self.sync_message_tx.read().await; + if let Some(sender) = tx.as_ref() { + let request = SyncMessage::state_sync_request(); + sender + .send(request) + .map_err(|_| anyhow::anyhow!("Failed to send resync request"))?; + println!("Sent StateSyncRequest to master"); + Ok(()) + } else { + Err(anyhow::anyhow!("Not connected to master")) + } + } - let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + pub async fn connect( + &self, + ) -> Result<( + mpsc::UnboundedReceiver, + mpsc::UnboundedSender, + )> { + let url = format!("ws://{}:{}", self.host, self.port); let (tx, rx) = mpsc::unbounded_channel(); + let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); + + let host = self.host.clone(); + let port = self.port; + let should_reconnect = self.should_reconnect.clone(); + let max_attempts = self.max_reconnect_attempts; + let message_tx_for_send = self.message_tx.clone(); + let sync_message_tx_for_store = self.sync_message_tx.clone(); + + // Spawn task to handle sending messages (will be connected when WebSocket is ready) + let send_tx_for_sending = send_tx.clone(); + let (send_ready_tx, mut send_ready_rx) = + mpsc::unbounded_channel::>(); - // Handle incoming messages tokio::spawn(async move { - while let Some(msg) = ws_receiver.next().await { - match msg { - Ok(Message::Text(text)) => { - match serde_json::from_str::(&text) { - Ok(sync_msg) => { - if tx.send(sync_msg).is_err() { - break; + let mut current_sender: Option> = None; + + loop { + tokio::select! { + // Receive new WebSocket sender + sender = send_ready_rx.recv() => { + if let Some(s) = sender { + current_sender = Some(s); + } + } + // Receive message to send + msg = send_rx.recv() => { + if let Some(msg) = msg { + if let Some(ref mut sender) = current_sender { + let json = match serde_json::to_string(&msg) { + Ok(j) => j, + Err(e) => { + eprintln!("Failed to serialize sync message: {}", e); + continue; + } + }; + if sender.send(Message::Text(json)).await.is_err() { + current_sender = None; } } - Err(e) => { - eprintln!("Failed to parse sync message: {}", e); - } + } else { + break; } } - Ok(Message::Ping(data)) => { - // Send pong - let _ = ws_sender.send(Message::Pong(data)).await; + } + } + }); + + // Spawn connection task with auto-reconnect + let reconnection_status_for_task = self.reconnection_status.clone(); + let current_attempt_for_task = self.current_attempt.clone(); + tokio::spawn(async move { + let mut attempt = 0; + + loop { + if !should_reconnect.load(Ordering::SeqCst) { + // Update status: not reconnecting + { + let mut status = reconnection_status_for_task.write().await; + status.is_reconnecting = false; + status.attempt_count = 0; + status.last_error = None; + } + current_attempt_for_task.store(0, Ordering::SeqCst); + break; + } + + if attempt > 0 { + // Update status: reconnecting + { + let mut status = reconnection_status_for_task.write().await; + status.is_reconnecting = true; + status.attempt_count = attempt; + status.max_attempts = max_attempts; + } + current_attempt_for_task.store(attempt, Ordering::SeqCst); + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s + let delay = std::cmp::min(2_u64.pow(attempt - 1), 30); + println!( + "Reconnecting to master in {} seconds... (attempt {}/{})", + delay, attempt, max_attempts + ); + tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await; + } + + if attempt >= max_attempts { + eprintln!( + "Max reconnection attempts ({}) reached. Stopping reconnection.", + max_attempts + ); + { + let mut status = reconnection_status_for_task.write().await; + status.is_reconnecting = false; + status.attempt_count = attempt; + status.last_error = Some(format!( + "Max reconnection attempts ({}) reached", + max_attempts + )); } - Ok(Message::Close(_)) => { - println!("Connection closed by master"); - break; + current_attempt_for_task.store(0, Ordering::SeqCst); + break; + } + + let url = format!("ws://{}:{}", host, port); + match connect_async(&url).await { + Ok((ws_stream, _)) => { + println!("Connected to master: {}", url); + attempt = 0; // Reset attempt counter on successful connection + // Update status: connected successfully + { + let mut status = reconnection_status_for_task.write().await; + status.is_reconnecting = false; + status.attempt_count = 0; + status.last_error = None; + } + current_attempt_for_task.store(0, Ordering::SeqCst); + + let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + let tx_clone = tx.clone(); + + // Store message sender for sending messages + { + let mut message_tx = message_tx_for_send.write().await; + *message_tx = Some(tx_clone.clone()); + } + + // Store sync message sender for resync requests + { + let mut sync_tx = sync_message_tx_for_store.write().await; + *sync_tx = Some(send_tx_for_sending.clone()); + } + + // Send ws_sender to sending task + let _ = send_ready_tx.send(ws_sender.clone()).await; + + // Handle incoming messages + let should_reconnect_clone = should_reconnect.clone(); + let message_tx_for_cleanup = message_tx_for_send.clone(); + tokio::spawn(async move { + while let Some(msg) = ws_receiver.next().await { + match msg { + Ok(Message::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(sync_msg) => { + if tx_clone.send(sync_msg).is_err() { + break; + } + } + Err(e) => { + eprintln!("Failed to parse sync message: {}", e); + } + } + } + Ok(Message::Ping(data)) => { + // Send pong + let _ = ws_sender.send(Message::Pong(data)).await; + } + Ok(Message::Close(_)) => { + println!("Connection closed by master"); + break; + } + Err(e) => { + eprintln!("WebSocket error: {}", e); + break; + } + _ => {} + } + } + // Connection lost, signal for reconnection + should_reconnect_clone.store(true, Ordering::SeqCst); + // Clear message sender + { + let mut tx = message_tx_for_cleanup.write().await; + *tx = None; + } + // Clear sync message sender + { + let mut sync_tx = sync_message_tx_for_cleanup.write().await; + *sync_tx = None; + } + // Update status: connection lost, will reconnect + { + let mut status = reconnection_status_for_task.write().await; + status.is_reconnecting = true; + status.attempt_count = 0; + status.last_error = Some("Connection lost".to_string()); + } + }); + + // Wait for connection to break + // The spawned task above will handle reconnection + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; } Err(e) => { - eprintln!("WebSocket error: {}", e); - break; + attempt += 1; + eprintln!( + "Failed to connect to master: {} (attempt {}/{})", + e, attempt, max_attempts + ); + // Update status: connection failed + { + let mut status = reconnection_status_for_task.write().await; + status.is_reconnecting = true; + status.attempt_count = attempt; + status.last_error = Some(format!("{}", e)); + } + current_attempt_for_task.store(attempt, Ordering::SeqCst); } - _ => {} } } }); - Ok(rx) + Ok((rx, send_tx)) } pub async fn is_connected(&self) -> bool { @@ -75,6 +298,30 @@ impl SlaveClient { } pub async fn disconnect(&self) { + // Stop reconnection attempts + self.should_reconnect.store(false, Ordering::SeqCst); + + // Update status: not reconnecting + { + let mut status = self.reconnection_status.write().await; + status.is_reconnecting = false; + status.attempt_count = 0; + status.last_error = None; + } + self.current_attempt.store(0, Ordering::SeqCst); + + // Clear message sender + { + let mut tx = self.message_tx.write().await; + *tx = None; + } + + // Clear sync message sender + { + let mut sync_tx = self.sync_message_tx.write().await; + *sync_tx = None; + } + let mut stream_lock = self.ws_stream.write().await; if let Some(mut stream) = stream_lock.take() { let _ = stream.close(None).await; diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index 9d756e9..dd7b6d1 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -1,2 +1,4 @@ -pub mod server; pub mod client; +pub mod server; + +pub use client::ReconnectionStatus; diff --git a/src-tauri/src/network/server.rs b/src-tauri/src/network/server.rs index f79f598..8f93497 100644 --- a/src-tauri/src/network/server.rs +++ b/src-tauri/src/network/server.rs @@ -1,9 +1,10 @@ use crate::sync::protocol::SyncMessage; use anyhow::{Context, Result}; use futures::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{mpsc, RwLock}; use tokio::task::JoinHandle; @@ -12,10 +13,32 @@ use tokio_tungstenite::{accept_async, tungstenite::Message, WebSocketStream}; type ClientId = String; type ClientConnection = WebSocketStream; -type InitialStateCallback = Arc std::pin::Pin + Send>> + Send + Sync>; +type InitialStateCallback = Arc< + dyn Fn(ClientId) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientInfo { + pub id: String, + pub ip_address: String, + pub connected_at: i64, + pub last_activity: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlaveStatus { + pub client_id: String, + pub is_synced: bool, + pub desync_details: Vec, + pub last_report_time: i64, +} pub struct MasterServer { clients: Arc>>>, + client_info: Arc>>, + slave_statuses: Arc>>, port: u16, shutdown: Arc, tasks: Arc>>>, @@ -26,6 +49,8 @@ impl MasterServer { pub fn new(port: u16) -> Self { Self { clients: Arc::new(RwLock::new(HashMap::new())), + client_info: Arc::new(RwLock::new(HashMap::new())), + slave_statuses: Arc::new(RwLock::new(HashMap::new())), port, shutdown: Arc::new(AtomicBool::new(false)), tasks: Arc::new(RwLock::new(Vec::new())), @@ -39,24 +64,27 @@ impl MasterServer { Fut: std::future::Future + Send + 'static, { let wrapped = Arc::new(move |client_id: ClientId| { - Box::pin(callback(client_id)) as std::pin::Pin + Send>> + Box::pin(callback(client_id)) + as std::pin::Pin + Send>> }); *self.initial_state_callback.write().await = Some(wrapped); } - + pub async fn stop(&self) { // Signal shutdown self.shutdown.store(true, Ordering::SeqCst); - + // Abort all tasks let tasks = self.tasks.write().await; for task in tasks.iter() { task.abort(); } - + // Clear clients self.clients.write().await.clear(); - + self.client_info.write().await.clear(); + self.slave_statuses.write().await.clear(); + println!("Master server stopped"); } @@ -77,7 +105,7 @@ impl MasterServer { if shutdown.load(Ordering::SeqCst) { break; } - + let json = match serde_json::to_string(&message) { Ok(j) => j, Err(e) => { @@ -97,6 +125,7 @@ impl MasterServer { // Accept incoming connections let clients_for_accept = self.clients.clone(); + let client_info_for_accept = self.client_info.clone(); let shutdown_for_accept = self.shutdown.clone(); let callback_for_accept = self.initial_state_callback.clone(); let accept_task = tokio::spawn(async move { @@ -104,13 +133,22 @@ impl MasterServer { if shutdown_for_accept.load(Ordering::SeqCst) { break; } - + match listener.accept().await { Ok((stream, addr)) => { println!("New connection from: {}", addr); let clients = clients_for_accept.clone(); + let client_info = client_info_for_accept.clone(); + let slave_statuses = self.slave_statuses.clone(); let callback = callback_for_accept.clone(); - tokio::spawn(handle_connection(stream, addr.to_string(), clients, callback)); + tokio::spawn(handle_connection( + stream, + addr.to_string(), + clients, + client_info, + slave_statuses, + callback, + )); } Err(e) => { eprintln!("Failed to accept connection: {}", e); @@ -131,14 +169,32 @@ impl MasterServer { pub async fn get_connected_clients_count(&self) -> usize { self.clients.read().await.len() } + + pub async fn get_connected_clients_info(&self) -> Vec { + let info = self.client_info.read().await; + info.values().cloned().collect() + } + + pub async fn get_slave_statuses(&self) -> Vec { + let statuses = self.slave_statuses.read().await; + statuses.values().cloned().collect() + } } async fn handle_connection( stream: TcpStream, client_id: ClientId, clients: Arc>>>, + client_info: Arc>>, + slave_statuses: Arc>>, callback: Arc>>, ) { + let peer_addr = stream.peer_addr().ok(); + let ip_address = peer_addr + .map(|a| a.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let connected_at = chrono::Utc::now().timestamp_millis(); + let ws_stream = match accept_async(stream).await { Ok(ws) => ws, Err(e) => { @@ -151,9 +207,23 @@ async fn handle_connection( let (tx, mut rx) = mpsc::unbounded_channel(); // Add client to the list - clients.write().await.insert(client_id.clone(), tx); - - println!("Client connected: {}", client_id); + clients.write().await.insert(client_id.clone(), tx.clone()); + + // Add client info + { + let mut info = client_info.write().await; + info.insert( + client_id.clone(), + ClientInfo { + id: client_id.clone(), + ip_address: ip_address.clone(), + connected_at, + last_activity: connected_at, + }, + ); + } + + println!("Client connected: {} from {}", client_id, ip_address); // Call initial state callback for new client let callback_lock = callback.read().await; @@ -175,7 +245,16 @@ async fn handle_connection( }); // Handle incoming messages from client (heartbeats, etc.) + let client_info_for_update = client_info.clone(); while let Some(msg) = ws_receiver.next().await { + // Update last activity time + { + let mut info = client_info_for_update.write().await; + if let Some(info_entry) = info.get_mut(&client_id) { + info_entry.last_activity = chrono::Utc::now().timestamp_millis(); + } + } + match msg { Ok(Message::Close(_)) => break, Ok(Message::Ping(data)) => { @@ -184,6 +263,46 @@ async fn handle_connection( let _ = tx.send(Message::Pong(data)); } } + Ok(Message::Text(text)) => { + // Try to parse as SyncMessage to handle StateSyncRequest and StateReport + if let Ok(sync_msg) = serde_json::from_str::(&text) { + match sync_msg.message_type { + crate::sync::protocol::SyncMessageType::StateSyncRequest => { + println!("Received StateSyncRequest from {}", client_id); + // Trigger initial state callback + let callback_lock = callback.read().await; + if let Some(cb) = callback_lock.as_ref() { + let client_id_clone = client_id.clone(); + let future = cb(client_id_clone); + drop(callback_lock); + tokio::spawn(future); + } + } + crate::sync::protocol::SyncMessageType::StateReport => { + // Update slave status + let mut statuses = slave_statuses.write().await; + if let (Some(is_synced), Some(desync_details)) = ( + sync_msg.payload.get("is_synced").and_then(|v| v.as_bool()), + sync_msg + .payload + .get("desync_details") + .and_then(|v| v.as_array()), + ) { + statuses.insert( + client_id.clone(), + SlaveStatus { + client_id: client_id.clone(), + is_synced, + desync_details: desync_details.clone(), + last_report_time: chrono::Utc::now().timestamp_millis(), + }, + ); + } + } + _ => {} + } + } + } Err(e) => { eprintln!("WebSocket error for {}: {}", client_id, e); break; @@ -194,6 +313,8 @@ async fn handle_connection( // Remove client from the list clients.write().await.remove(&client_id); + client_info.write().await.remove(&client_id); + slave_statuses.write().await.remove(&client_id); send_task.abort(); println!("Client disconnected: {}", client_id); } diff --git a/src-tauri/src/obs/client.rs b/src-tauri/src/obs/client.rs index 2ac99ea..0d2259a 100644 --- a/src-tauri/src/obs/client.rs +++ b/src-tauri/src/obs/client.rs @@ -37,7 +37,7 @@ impl OBSClient { let host = config.host.clone(); let port = config.port; let password = config.password.clone(); - + let client = Client::connect(host, port, password) .await .context("Failed to connect to OBS WebSocket")?; @@ -63,7 +63,7 @@ impl OBSClient { pub async fn get_status(&self) -> OBSConnectionStatus { let client_lock = self.client.read().await; - + if let Some(client) = client_lock.as_ref() { // Try to get version info if let Ok(version) = client.general().version().await { diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index baa4978..9b499c5 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -1,3 +1,5 @@ +use futures_util::StreamExt; +use obws::events::Event; use obws::Client; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; @@ -5,12 +7,25 @@ use tokio::sync::mpsc; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "payload")] pub enum OBSEvent { - SceneChanged { scene_name: String }, - SceneItemTransformChanged { scene_name: String, scene_item_id: i64 }, - SourceCreated { source_name: String }, - SourceDestroyed { source_name: String }, - InputSettingsChanged { input_name: String }, - CurrentPreviewSceneChanged { scene_name: String }, + SceneChanged { + scene_name: String, + }, + SceneItemTransformChanged { + scene_name: String, + scene_item_id: i64, + }, + SourceCreated { + source_name: String, + }, + SourceDestroyed { + source_name: String, + }, + InputSettingsChanged { + input_name: String, + }, + CurrentPreviewSceneChanged { + scene_name: String, + }, } pub struct OBSEventHandler { @@ -23,20 +38,81 @@ impl OBSEventHandler { (Self { event_tx: tx }, rx) } - pub async fn start_listening(&self, _client: &Client) -> anyhow::Result<()> { - let _tx = self.event_tx.clone(); + pub async fn start_listening(&self, client: &Client) -> anyhow::Result<()> { + let tx = self.event_tx.clone(); - // Note: Event listening implementation depends on obws library version - // This is a placeholder that keeps the event listener task alive - // TODO: Implement actual event subscription based on obws version in use + // Get event stream from obws client + let mut events = client + .events() + .map_err(|e| anyhow::anyhow!("Failed to get event stream: {}", e))?; + + println!("Started OBS event listening"); + + // Spawn task to process events tokio::spawn(async move { - println!("Started OBS event listening (placeholder implementation)"); - println!("Note: Full event integration requires obws events API configuration"); - - // Keep task alive - loop { - tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; + while let Some(event) = events.next().await { + match event { + Event::CurrentProgramSceneChanged(data) => { + let obs_event = OBSEvent::SceneChanged { + scene_name: data.scene_name, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send SceneChanged event: {}", e); + break; + } + } + Event::CurrentPreviewSceneChanged(data) => { + let obs_event = OBSEvent::CurrentPreviewSceneChanged { + scene_name: data.scene_name, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send CurrentPreviewSceneChanged event: {}", e); + break; + } + } + Event::SceneItemTransformChanged(data) => { + let obs_event = OBSEvent::SceneItemTransformChanged { + scene_name: data.scene_name, + scene_item_id: data.scene_item_id, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send SceneItemTransformChanged event: {}", e); + break; + } + } + Event::InputSettingsChanged(data) => { + let obs_event = OBSEvent::InputSettingsChanged { + input_name: data.input_name, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send InputSettingsChanged event: {}", e); + break; + } + } + Event::SourceCreated(data) => { + let obs_event = OBSEvent::SourceCreated { + source_name: data.source_name, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send SourceCreated event: {}", e); + break; + } + } + Event::SourceDestroyed(data) => { + let obs_event = OBSEvent::SourceDestroyed { + source_name: data.source_name, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send SourceDestroyed event: {}", e); + break; + } + } + _ => { + // Ignore other events + } + } } + println!("OBS event stream ended"); }); Ok(()) diff --git a/src-tauri/src/obs/mod.rs b/src-tauri/src/obs/mod.rs index fe8c8c1..5724795 100644 --- a/src-tauri/src/obs/mod.rs +++ b/src-tauri/src/obs/mod.rs @@ -1,5 +1,5 @@ pub mod client; -pub mod events; pub mod commands; +pub mod events; pub use client::OBSClient; diff --git a/src-tauri/src/sync/diff.rs b/src-tauri/src/sync/diff.rs index 82ff72e..063ba5c 100644 --- a/src-tauri/src/sync/diff.rs +++ b/src-tauri/src/sync/diff.rs @@ -19,9 +19,9 @@ pub enum DiffCategory { #[derive(Debug, Clone)] pub enum DiffSeverity { - Critical, // Scene doesn't match - Warning, // Transform or settings differ - Info, // Minor differences + Critical, // Scene doesn't match + Warning, // Transform or settings differ + Info, // Minor differences } pub struct DiffDetector; @@ -33,9 +33,15 @@ impl DiffDetector { let mut diffs = Vec::new(); // Compare current scene - let local_scene = local_state.get("current_scene").and_then(|v| v.as_str()).unwrap_or(""); - let expected_scene = expected_state.get("current_scene").and_then(|v| v.as_str()).unwrap_or(""); - + let local_scene = local_state + .get("current_scene") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let expected_scene = expected_state + .get("current_scene") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if local_scene != expected_scene && !expected_scene.is_empty() { diffs.push(StateDifference { category: DiffCategory::SceneMismatch, @@ -52,15 +58,19 @@ impl DiffDetector { // Compare sources in current scene if let (Some(local_sources), Some(expected_sources)) = ( local_state.get("sources").and_then(|v| v.as_array()), - expected_state.get("sources").and_then(|v| v.as_array()) + expected_state.get("sources").and_then(|v| v.as_array()), ) { // Check for missing sources for expected_source in expected_sources { - let expected_name = expected_source.get("name").and_then(|v| v.as_str()).unwrap_or(""); - - if !local_sources.iter().any(|s| { - s.get("name").and_then(|v| v.as_str()).unwrap_or("") == expected_name - }) { + let expected_name = expected_source + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if !local_sources + .iter() + .any(|s| s.get("name").and_then(|v| v.as_str()).unwrap_or("") == expected_name) + { diffs.push(StateDifference { category: DiffCategory::SourceMissing, scene_name: local_scene.to_string(), @@ -77,7 +87,7 @@ impl DiffDetector { local_source, expected_source, local_scene, - expected_name + expected_name, ) { diffs.extend(transform_diffs); } @@ -97,14 +107,26 @@ impl DiffDetector { ) -> Option> { let local_transform = local_source.get("transform")?; let expected_transform = expected_source.get("transform")?; - + let mut diffs = Vec::new(); // Compare position - let local_x = local_transform.get("position_x").and_then(|v| v.as_f64()).unwrap_or(0.0); - let expected_x = expected_transform.get("position_x").and_then(|v| v.as_f64()).unwrap_or(0.0); - let local_y = local_transform.get("position_y").and_then(|v| v.as_f64()).unwrap_or(0.0); - let expected_y = expected_transform.get("position_y").and_then(|v| v.as_f64()).unwrap_or(0.0); + let local_x = local_transform + .get("position_x") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let expected_x = expected_transform + .get("position_x") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let local_y = local_transform + .get("position_y") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let expected_y = expected_transform + .get("position_y") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); if (local_x - expected_x).abs() > Self::TRANSFORM_TOLERANCE || (local_y - expected_y).abs() > Self::TRANSFORM_TOLERANCE @@ -122,10 +144,22 @@ impl DiffDetector { } // Compare scale - let local_scale_x = local_transform.get("scale_x").and_then(|v| v.as_f64()).unwrap_or(1.0); - let expected_scale_x = expected_transform.get("scale_x").and_then(|v| v.as_f64()).unwrap_or(1.0); - let local_scale_y = local_transform.get("scale_y").and_then(|v| v.as_f64()).unwrap_or(1.0); - let expected_scale_y = expected_transform.get("scale_y").and_then(|v| v.as_f64()).unwrap_or(1.0); + let local_scale_x = local_transform + .get("scale_x") + .and_then(|v| v.as_f64()) + .unwrap_or(1.0); + let expected_scale_x = expected_transform + .get("scale_x") + .and_then(|v| v.as_f64()) + .unwrap_or(1.0); + let local_scale_y = local_transform + .get("scale_y") + .and_then(|v| v.as_f64()) + .unwrap_or(1.0); + let expected_scale_y = expected_transform + .get("scale_y") + .and_then(|v| v.as_f64()) + .unwrap_or(1.0); if (local_scale_x - expected_scale_x).abs() > 0.01 || (local_scale_y - expected_scale_y).abs() > 0.01 @@ -154,6 +188,8 @@ impl DiffDetector { } pub fn has_critical_diffs(diffs: &[StateDifference]) -> bool { - diffs.iter().any(|d| matches!(d.severity, DiffSeverity::Critical)) + diffs + .iter() + .any(|d| matches!(d.severity, DiffSeverity::Critical)) } } diff --git a/src-tauri/src/sync/master.rs b/src-tauri/src/sync/master.rs index 9f693c8..222690d 100644 --- a/src-tauri/src/sync/master.rs +++ b/src-tauri/src/sync/master.rs @@ -75,13 +75,17 @@ impl MasterSync { let obs_client_clone = obs_client.clone(); let message_tx_clone = message_tx.clone(); let scene_name_clone = scene_name.clone(); - + tokio::spawn(async move { let client_arc = obs_client_clone.get_client_arc(); let client_lock = client_arc.read().await; - + if let Some(client) = client_lock.as_ref() { - match client.scene_items().transform(&scene_name_clone, scene_item_id).await { + match client + .scene_items() + .transform(&scene_name_clone, scene_item_id) + .await + { Ok(transform) => { let payload = serde_json::json!({ "scene_name": scene_name_clone, @@ -96,17 +100,23 @@ impl MasterSync { "height": transform.height, } }); - + let msg = SyncMessage::new( SyncMessageType::TransformUpdate, SyncTargetType::Source, payload, ); let _ = message_tx_clone.send(msg); - println!("Sent transform update for scene item {} in {}", scene_item_id, scene_name_clone); + println!( + "Sent transform update for scene item {} in {}", + scene_item_id, scene_name_clone + ); } Err(e) => { - eprintln!("Failed to get transform for item {}: {}", scene_item_id, e); + eprintln!( + "Failed to get transform for item {}: {}", + scene_item_id, e + ); } } } @@ -118,21 +128,26 @@ impl MasterSync { let obs_client_clone = obs_client.clone(); let message_tx_clone = message_tx.clone(); let input_name_clone = input_name.clone(); - + // Spawn task to get image data tokio::spawn(async move { let client_arc = obs_client_clone.get_client_arc(); let client_lock = client_arc.read().await; - + if let Some(client) = client_lock.as_ref() { // Get input settings - match client.inputs().settings::(&input_name_clone).await { + match client + .inputs() + .settings::(&input_name_clone) + .await + { Ok(settings) => { - let file_path = settings.settings + let file_path = settings + .settings .get("file") .and_then(|v| v.as_str()) .unwrap_or(""); - + // Read and encode image if file path exists let image_data = if !file_path.is_empty() { match tokio::fs::read(file_path).await { @@ -141,7 +156,11 @@ impl MasterSync { &base64::engine::general_purpose::STANDARD, &data ); - println!("Encoded image: {} ({} bytes)", file_path, data.len()); + println!( + "Encoded image: {} ({} bytes)", + file_path, + data.len() + ); Some(encoded) } Err(e) => { @@ -152,14 +171,14 @@ impl MasterSync { } else { None }; - + let payload = serde_json::json!({ "scene_name": "", "source_name": input_name_clone, "file": file_path, "image_data": image_data }); - + let msg = SyncMessage::new( SyncMessageType::ImageUpdate, SyncTargetType::Source, @@ -190,8 +209,14 @@ impl MasterSync { async fn read_and_encode_image(file_path: &str) -> Option { match tokio::fs::read(file_path).await { Ok(data) => { - let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data); - println!("Encoded image: {} ({} bytes -> {} chars)", file_path, data.len(), encoded.len()); + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data); + println!( + "Encoded image: {} ({} bytes -> {} chars)", + file_path, + data.len(), + encoded.len() + ); Some(encoded) } Err(e) => { @@ -202,21 +227,23 @@ impl MasterSync { } /// Get image source settings from OBS and encode the file - pub async fn get_image_data_for_source( - &self, - input_name: &str, - ) -> Option<(String, String)> { + pub async fn get_image_data_for_source(&self, input_name: &str) -> Option<(String, String)> { let client_arc = self.obs_client.get_client_arc(); let client_lock = client_arc.read().await; - + if let Some(client) = client_lock.as_ref() { // Get input settings to find the file path - match client.inputs().settings::(input_name).await { + match client + .inputs() + .settings::(input_name) + .await + { Ok(settings) => { // Try to get file path from settings - if let Some(file_path) = settings.settings.get("file").and_then(|v| v.as_str()) { + if let Some(file_path) = settings.settings.get("file").and_then(|v| v.as_str()) + { println!("Found image file for {}: {}", input_name, file_path); - + // Read and encode the image if let Some(encoded_data) = Self::read_and_encode_image(file_path).await { return Some((file_path.to_string(), encoded_data)); @@ -230,7 +257,7 @@ impl MasterSync { } } } - + None } @@ -239,7 +266,7 @@ impl MasterSync { println!("Collecting full OBS state for new slave..."); let client_arc = self.obs_client.get_client_arc(); let client_lock = client_arc.read().await; - + if let Some(client) = client_lock.as_ref() { // Get current program scene let current_program_scene = match client.scenes().current_program_scene().await { @@ -263,45 +290,55 @@ impl MasterSync { }; let mut scenes_data = Vec::new(); - + // For each scene, get all items for scene in scenes_list.scenes { println!("Processing scene: {}", scene.name); - + match client.scene_items().list(&scene.name).await { Ok(items) => { let mut scene_items_data = Vec::new(); - + for item in items { println!(" - Item: {} (id: {})", item.source_name, item.id); - + // Get transform for this item - let transform = match client.scene_items().transform(&scene.name, item.id).await { - Ok(t) => Some(serde_json::json!({ - "position_x": t.position_x, - "position_y": t.position_y, - "rotation": t.rotation, - "scale_x": t.scale_x, - "scale_y": t.scale_y, - "width": t.width, - "height": t.height, - })), - Err(e) => { - eprintln!("Failed to get transform for {}: {}", item.source_name, e); - None - } - }; + let transform = + match client.scene_items().transform(&scene.name, item.id).await { + Ok(t) => Some(serde_json::json!({ + "position_x": t.position_x, + "position_y": t.position_y, + "rotation": t.rotation, + "scale_x": t.scale_x, + "scale_y": t.scale_y, + "width": t.width, + "height": t.height, + })), + Err(e) => { + eprintln!( + "Failed to get transform for {}: {}", + item.source_name, e + ); + None + } + }; // Get source type from item - let source_type = item.input_kind.clone().unwrap_or_else(|| "unknown".to_string()); + let source_type = item + .input_kind + .clone() + .unwrap_or_else(|| "unknown".to_string()); // If it's an image source, get the image data let image_data = if source_type.contains("image") { - self.get_image_data_for_source(&item.source_name).await - .map(|(path, data)| serde_json::json!({ - "file": path, - "data": data - })) + self.get_image_data_for_source(&item.source_name).await.map( + |(path, data)| { + serde_json::json!({ + "file": path, + "data": data + }) + }, + ) } else { None }; @@ -314,7 +351,7 @@ impl MasterSync { "image_data": image_data, })); } - + scenes_data.push(serde_json::json!({ "name": scene.name, "items": scene_items_data, @@ -333,14 +370,14 @@ impl MasterSync { "scenes": scenes_data, }); - let msg = SyncMessage::new( - SyncMessageType::StateSync, - SyncTargetType::Program, - payload, - ); + let msg = + SyncMessage::new(SyncMessageType::StateSync, SyncTargetType::Program, payload); self.message_tx.send(msg)?; - println!("✓ Sent complete initial state to slave ({} scenes)", scenes_data.len()); + println!( + "✓ Sent complete initial state to slave ({} scenes)", + scenes_data.len() + ); } Ok(()) diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 34faacd..d5a5222 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -1,4 +1,4 @@ -pub mod protocol; +pub mod diff; pub mod master; +pub mod protocol; pub mod slave; -pub mod diff; diff --git a/src-tauri/src/sync/protocol.rs b/src-tauri/src/sync/protocol.rs index b42fe8c..798ec5d 100644 --- a/src-tauri/src/sync/protocol.rs +++ b/src-tauri/src/sync/protocol.rs @@ -10,7 +10,8 @@ pub enum SyncMessageType { ImageUpdate, Heartbeat, StateSync, - StateSyncRequest, // Slave requests initial state from Master + StateSyncRequest, // Slave requests initial state from Master + StateReport, // Slave reports its current state to Master } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index 89da2f9..9eb9644 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -1,10 +1,10 @@ -use super::protocol::{SyncMessage, SyncMessageType}; use super::diff::{DiffDetector, DiffSeverity}; +use super::protocol::{SyncMessage, SyncMessageType}; use crate::obs::{commands::OBSCommands, OBSClient}; use anyhow::{Context, Result}; use std::sync::Arc; -use tokio::sync::{mpsc, RwLock}; use tokio::fs; +use tokio::sync::{mpsc, RwLock}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -28,6 +28,7 @@ pub struct SlaveSync { obs_client: Arc, alert_tx: mpsc::UnboundedSender, expected_state: Arc>, + state_report_tx: Arc>>>, } impl SlaveSync { @@ -38,23 +39,30 @@ impl SlaveSync { obs_client, alert_tx: tx, expected_state: Arc::new(RwLock::new(serde_json::json!({}))), + state_report_tx: Arc::new(RwLock::new(None)), }, rx, ) } + pub async fn set_state_report_sender(&self, tx: mpsc::UnboundedSender) { + *self.state_report_tx.write().await = Some(tx); + } + /// Start periodic state checking task pub fn start_periodic_check(&self, interval_secs: u64) { let obs_client = self.obs_client.clone(); let expected_state = self.expected_state.clone(); let alert_tx = self.alert_tx.clone(); + let state_report_tx = self.state_report_tx.clone(); tokio::spawn(async move { - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(interval_secs)); - + let mut interval = + tokio::time::interval(tokio::time::Duration::from_secs(interval_secs)); + loop { interval.tick().await; - + // Get current local OBS state let local_state = match Self::get_current_obs_state(&obs_client).await { Ok(state) => state, @@ -66,22 +74,56 @@ impl SlaveSync { // Compare with expected state let expected = expected_state.read().await; - if expected.is_null() || expected.as_object().map(|o| o.is_empty()).unwrap_or(true) { + if expected.is_null() || expected.as_object().map(|o| o.is_empty()).unwrap_or(true) + { // No expected state yet, skip check continue; } let diffs = DiffDetector::detect_differences(&local_state, &expected); - + + // Send state report to Master + { + let tx = state_report_tx.read().await; + if let Some(ref sender) = tx.as_ref() { + let desync_details: Vec = diffs + .iter() + .map(|diff| { + serde_json::json!({ + "category": format!("{:?}", diff.category), + "scene_name": diff.scene_name, + "source_name": diff.source_name, + "description": diff.description, + "severity": format!("{:?}", diff.severity), + }) + }) + .collect(); + + let report = SyncMessage::new( + SyncMessageType::StateReport, + SyncTargetType::Program, + serde_json::json!({ + "is_synced": diffs.is_empty(), + "desync_details": desync_details, + "current_state": local_state, + }), + ); + + if let Err(e) = sender.send(report) { + eprintln!("Failed to send state report: {}", e); + } + } + } + if !diffs.is_empty() { println!("⚠️ Detected {} state difference(s)", diffs.len()); - + for diff in diffs { let severity = match diff.severity { DiffSeverity::Critical => AlertSeverity::Error, _ => AlertSeverity::Warning, }; - + let alert = DesyncAlert { id: uuid::Uuid::new_v4().to_string(), timestamp: chrono::Utc::now().timestamp_millis(), @@ -90,7 +132,7 @@ impl SlaveSync { message: diff.description, severity, }; - + if let Err(e) = alert_tx.send(alert) { eprintln!("Failed to send desync alert: {}", e); } @@ -104,20 +146,30 @@ impl SlaveSync { async fn get_current_obs_state(obs_client: &Arc) -> Result { let client_arc = obs_client.get_client_arc(); let client_lock = client_arc.read().await; - + if let Some(client) = client_lock.as_ref() { // Get current scene - let current_scene = client.scenes().current_program_scene().await + let current_scene = client + .scenes() + .current_program_scene() + .await .context("Failed to get current scene")?; - + // Get sources in current scene - let items = client.scene_items().list(¤t_scene).await + let items = client + .scene_items() + .list(¤t_scene) + .await .context("Failed to get scene items")?; - - let mut sources = Vec::new(); - for item in items { - let transform = client.scene_items().transform(¤t_scene, item.id).await.ok(); - + + let mut sources = Vec::new(); + for item in items { + let transform = client + .scene_items() + .transform(¤t_scene, item.id) + .await + .ok(); + sources.push(serde_json::json!({ "name": item.source_name, "transform": transform.map(|t| serde_json::json!({ @@ -129,7 +181,7 @@ impl SlaveSync { })), })); } - + Ok(serde_json::json!({ "current_scene": current_scene, "sources": sources, @@ -142,7 +194,7 @@ impl SlaveSync { /// Update expected state from sync message async fn update_expected_state(&self, message: &SyncMessage) { let mut expected = self.expected_state.write().await; - + match message.message_type { SyncMessageType::SceneChange => { if let Some(scene_name) = message.payload["scene_name"].as_str() { @@ -163,7 +215,7 @@ impl SlaveSync { pub async fn apply_sync_message(&self, message: SyncMessage) -> Result<()> { // Update expected state first self.update_expected_state(&message).await; - + let client_arc = self.obs_client.get_client_arc(); let client_lock = client_arc.read().await; let client = client_lock.as_ref().context("OBS client not connected")?; @@ -193,7 +245,10 @@ impl SlaveSync { // Apply transform if included in payload if let Some(transform) = message.payload["transform"].as_object() { - if let Err(e) = self.apply_transform(&client, scene_name, scene_item_id, transform).await { + if let Err(e) = self + .apply_transform(&client, scene_name, scene_item_id, transform) + .await + { self.send_alert( scene_name.to_string(), String::new(), @@ -201,7 +256,10 @@ impl SlaveSync { AlertSeverity::Warning, )?; } else { - println!("Applied transform update for item {} in scene {}", scene_item_id, scene_name); + println!( + "Applied transform update for item {} in scene {}", + scene_item_id, scene_name + ); } } else { eprintln!("Transform data missing in payload"); @@ -211,19 +269,14 @@ impl SlaveSync { let source_name = message.payload["source_name"] .as_str() .context("Invalid source_name")?; - let file_path = message.payload["file"] - .as_str() - .unwrap_or(""); - let image_data = message.payload["image_data"] - .as_str(); + let file_path = message.payload["file"].as_str().unwrap_or(""); + let image_data = message.payload["image_data"].as_str(); // Handle image update - if let Err(e) = self.handle_image_update( - &client, - source_name, - file_path, - image_data - ).await { + if let Err(e) = self + .handle_image_update(&client, source_name, file_path, image_data) + .await + { self.send_alert( String::new(), source_name.to_string(), @@ -237,46 +290,61 @@ impl SlaveSync { } SyncMessageType::StateSync => { println!("Applying complete initial state from master..."); - + // Apply all scenes and items if let Some(scenes) = message.payload["scenes"].as_array() { for scene in scenes { let scene_name = scene["name"].as_str().unwrap_or(""); println!("Processing scene: {}", scene_name); - + // Apply items in this scene if let Some(items) = scene["items"].as_array() { for item in items { let source_name = item["source_name"].as_str().unwrap_or(""); let scene_item_id = item["scene_item_id"].as_i64().unwrap_or(0); - - println!(" - Applying item: {} (id: {})", source_name, scene_item_id); - + + println!( + " - Applying item: {} (id: {})", + source_name, scene_item_id + ); + // Apply transform if available if let Some(transform) = item["transform"].as_object() { - if let Err(e) = self.apply_transform( - &client, - scene_name, - scene_item_id, - transform - ).await { - eprintln!("Failed to apply transform for {}: {}", source_name, e); + if let Err(e) = self + .apply_transform( + &client, + scene_name, + scene_item_id, + transform, + ) + .await + { + eprintln!( + "Failed to apply transform for {}: {}", + source_name, e + ); } } - + // Apply image data if available if let Some(image_data) = item["image_data"].as_object() { if let (Some(file), Some(data)) = ( image_data.get("file").and_then(|v| v.as_str()), - image_data.get("data").and_then(|v| v.as_str()) + image_data.get("data").and_then(|v| v.as_str()), ) { - if let Err(e) = self.handle_image_update( - &client, - source_name, - file, - Some(data) - ).await { - eprintln!("Failed to apply image for {}: {}", source_name, e); + if let Err(e) = self + .handle_image_update( + &client, + source_name, + file, + Some(data), + ) + .await + { + eprintln!( + "Failed to apply image for {}: {}", + source_name, e + ); } } } @@ -284,10 +352,14 @@ impl SlaveSync { } } } - + // Apply current program scene if let Some(scene_name) = message.payload["current_program_scene"].as_str() { - if let Err(e) = crate::obs::commands::OBSCommands::set_current_program_scene(&client, scene_name).await { + if let Err(e) = crate::obs::commands::OBSCommands::set_current_program_scene( + &client, scene_name, + ) + .await + { self.send_alert( scene_name.to_string(), String::new(), @@ -298,13 +370,31 @@ impl SlaveSync { println!("✓ Applied current program scene: {}", scene_name); } } - + // Apply preview scene if in studio mode if let Some(preview_scene) = message.payload["current_preview_scene"].as_str() { - // Note: Setting preview scene requires studio mode to be enabled - println!("Preview scene in master: {}", preview_scene); + // Setting preview scene requires studio mode to be enabled + match client + .scenes() + .set_current_preview_scene(preview_scene) + .await + { + Ok(_) => { + println!("✓ Applied current preview scene: {}", preview_scene); + } + Err(e) => { + // Studio mode might not be enabled, log warning but don't fail + println!("⚠️ Failed to set preview scene (Studio Mode may not be enabled): {}", e); + self.send_alert( + preview_scene.to_string(), + String::new(), + format!("Failed to sync preview scene: {} (Studio Mode may not be enabled)", e), + AlertSeverity::Warning, + )?; + } + } } - + println!("✓ Initial state fully applied"); } _ => {} @@ -315,26 +405,74 @@ impl SlaveSync { async fn apply_transform( &self, - _client: &obws::Client, + client: &obws::Client, scene_name: &str, scene_item_id: i64, transform: &serde_json::Map, ) -> Result<()> { - // Note: Transform application depends on obws library API structure - // This is a placeholder implementation - // TODO: Implement actual transform application based on obws version - + // Get current transform to preserve values not in the update + let current_transform = match client + .scene_items() + .transform(scene_name, scene_item_id) + .await + { + Ok(t) => t, + Err(e) => { + eprintln!( + "Failed to get current transform for item {}: {}", + scene_item_id, e + ); + return Err(anyhow::anyhow!("Failed to get current transform: {}", e)); + } + }; + + // Extract values from JSON payload + let position_x = transform + .get("position_x") + .and_then(|v| v.as_f64()) + .unwrap_or(current_transform.position_x); + let position_y = transform + .get("position_y") + .and_then(|v| v.as_f64()) + .unwrap_or(current_transform.position_y); + let scale_x = transform + .get("scale_x") + .and_then(|v| v.as_f64()) + .unwrap_or(current_transform.scale_x); + let scale_y = transform + .get("scale_y") + .and_then(|v| v.as_f64()) + .unwrap_or(current_transform.scale_y); + let rotation = transform + .get("rotation") + .and_then(|v| v.as_f64()) + .unwrap_or(current_transform.rotation); + + // Build new transform using SceneItemTransformBuilder + use obws::requests::scene_items::SceneItemTransformBuilder; + let new_transform = SceneItemTransformBuilder::new() + .position((position_x, position_y)) + .scale((scale_x, scale_y)) + .rotation(rotation) + .build(); + + // Apply the transform using SetTransformBuilder + use obws::requests::scene_items::SetTransformBuilder; + client + .scene_items() + .set_transform(SetTransformBuilder::new( + scene_name, + scene_item_id, + new_transform, + )) + .await + .context("Failed to set transform")?; + println!( - "Transform update received for item {} in scene {}: {:?}", - scene_item_id, scene_name, transform + "Applied transform for item {} in scene {}: pos=({}, {}), scale=({}, {}), rotation={}", + scene_item_id, scene_name, position_x, position_y, scale_x, scale_y, rotation ); - - // In production, you would use obws API to apply the transform: - // - Extract position_x, position_y - // - Extract scale_x, scale_y - // - Extract rotation - // - Call appropriate obws method to set transform - + Ok(()) } @@ -347,50 +485,56 @@ impl SlaveSync { ) -> Result<()> { if let Some(encoded_data) = image_data { println!("Received image data for {}, decoding...", source_name); - + // Decode base64 image data - let decoded_data = base64::Engine::decode( - &base64::engine::general_purpose::STANDARD, - encoded_data - ).context("Failed to decode image data")?; - + let decoded_data = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded_data) + .context("Failed to decode image data")?; + println!("Decoded {} bytes of image data", decoded_data.len()); - + + // Detect image format from magic bytes + let file_extension = Self::detect_image_format(&decoded_data); + // Create temp directory for synced images let temp_dir = std::env::temp_dir().join("obs-sync"); - fs::create_dir_all(&temp_dir).await.context("Failed to create temp directory")?; - + fs::create_dir_all(&temp_dir) + .await + .context("Failed to create temp directory")?; + // Generate unique filename - let file_extension = "png"; // Default to PNG, could be detected from data - let temp_file_path = temp_dir.join(format!("{}_{}.{}", + let temp_file_path = temp_dir.join(format!( + "{}_{}.{}", source_name.replace("/", "_").replace("\\", "_"), chrono::Utc::now().timestamp_millis(), file_extension )); - + println!("Saving image to: {:?}", temp_file_path); - + // Write decoded data to temp file fs::write(&temp_file_path, &decoded_data) .await .context("Failed to write image file")?; - + // Update OBS input settings with new file path let temp_file_str = temp_file_path.to_string_lossy().to_string(); let settings = serde_json::json!({ "file": temp_file_str }); - + println!("Applying image to OBS source: {}", source_name); - + // Apply settings to OBS - match client.inputs().set_settings( - obws::requests::inputs::SetSettings { + match client + .inputs() + .set_settings(obws::requests::inputs::SetSettings { input: source_name, settings: &settings, overlay: Some(true), - } - ).await { + }) + .await + { Ok(_) => { println!("Successfully applied image to {}", source_name); Ok(()) @@ -406,6 +550,30 @@ impl SlaveSync { } } + /// Detect image format from magic bytes + fn detect_image_format(data: &[u8]) -> &'static str { + if data.len() < 4 { + return "png"; // Default to PNG if data is too short + } + + // Check magic bytes for common image formats + match &data[0..4] { + [0x89, 0x50, 0x4E, 0x47] => "png", // PNG: 89 50 4E 47 + [0xFF, 0xD8, 0xFF, _] => "jpg", // JPEG: FF D8 FF + [0x47, 0x49, 0x46, 0x38] => "gif", // GIF: 47 49 46 38 + [0x42, 0x4D, _, _] => "bmp", // BMP: 42 4D + [0x52, 0x49, 0x46, 0x46] => { + // RIFF (WebP or other) + if data.len() >= 8 && &data[4..8] == b"WEBP" { + "webp" + } else { + "png" // Default fallback + } + } + _ => "png", // Default to PNG if format is unknown + } + } + fn send_alert( &self, scene_name: String, diff --git a/src/App.tsx b/src/App.tsx index 4592369..c99b7a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { useOBSConnection } from "./hooks/useOBSConnection"; import { useSyncState } from "./hooks/useSyncState"; import { useNetworkStatus } from "./hooks/useNetworkStatus"; import { useDesyncAlerts } from "./hooks/useDesyncAlerts"; +import { useSettings } from "./hooks/useSettings"; import { ConnectionStatus } from "./components/ConnectionStatus"; import { MasterControl } from "./components/MasterControl"; import { SlaveMonitor } from "./components/SlaveMonitor"; @@ -21,14 +22,23 @@ function App() { const [obsHost, setObsHost] = useState("localhost"); const [obsPort, setObsPort] = useState(4455); const [obsPassword, setObsPassword] = useState(""); - const [sources] = useState([]); const [isConnectingOBS, setIsConnectingOBS] = useState(false); - const { status: obsStatus, connect, disconnect, error: obsError } = useOBSConnection(); + const { settings, isLoading: settingsLoading, loadSettings, saveSettings } = useSettings(); + const { status: obsStatus, sources, connect, disconnect, error: obsError } = useOBSConnection(); const { syncState, setMode, error: syncError } = useSyncState(); const networkStatus = useNetworkStatus(); const { alerts, clearAlert, clearAllAlerts } = useDesyncAlerts(); + // Load settings into state when they're loaded + useEffect(() => { + if (settings && !settingsLoading) { + setObsHost(settings.obs.host); + setObsPort(settings.obs.port); + setObsPassword(settings.obs.password); + } + }, [settings, settingsLoading]); + useEffect(() => { if (obsError) { toast.error(`OBS接続エラー: ${obsError}`); @@ -49,6 +59,17 @@ function App() { port: obsPort, password: obsPassword || undefined, }); + // Save OBS settings after successful connection + if (settings) { + await saveSettings({ + ...settings, + obs: { + host: obsHost, + port: obsPort, + password: obsPassword, + }, + }); + } toast.success("OBSに接続しました"); } catch (error) { console.error("Failed to connect to OBS:", error); diff --git a/src/components/MasterControl.tsx b/src/components/MasterControl.tsx index a3f9fa8..9b64d23 100644 --- a/src/components/MasterControl.tsx +++ b/src/components/MasterControl.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { useNetworkStatus } from "../hooks/useNetworkStatus"; import { ConnectionState } from "../types/network"; +import { parseErrorMessage } from "../utils/errorMessages"; export const MasterControl = () => { const [port, setPort] = useState(8080); const [isStarting, setIsStarting] = useState(false); const [isStopping, setIsStopping] = useState(false); - const { status, startMasterServer, stopMasterServer } = useNetworkStatus(); + const { status, clients, slaveStatuses, startMasterServer, stopMasterServer } = useNetworkStatus(); const handleStart = async () => { setIsStarting(true); @@ -30,6 +32,26 @@ export const MasterControl = () => { } }; + const handleResyncAll = async () => { + try { + await invoke("resync_all_slaves"); + alert("全Slaveに再同期を送信しました"); + } catch (error) { + console.error("Failed to resync all slaves:", error); + alert(`再同期に失敗しました: ${error}`); + } + }; + + const handleResyncSpecific = async (clientId: string) => { + try { + await invoke("resync_specific_slave", { clientId }); + alert(`Slave ${clientId} に再同期を送信しました`); + } catch (error) { + console.error("Failed to resync specific slave:", error); + alert(`再同期に失敗しました: ${error}`); + } + }; + const isConnected = status.state === ConnectionState.Connected; const isConnecting = status.state === ConnectionState.Connecting; @@ -97,7 +119,7 @@ export const MasterControl = () => { - {status.state === ConnectionState.Connected && ( + {status.state === ConnectionState.Connected && (
@@ -119,20 +141,131 @@ export const MasterControl = () => { ws://<your-ip>:{port}
+ +
+ +
+ + {clients.length > 0 && ( +
+
接続中のクライアント
+
+ {clients.map((client) => { + const connectedAt = new Date(client.connectedAt); + const lastActivity = new Date(client.lastActivity); + const connectedTime = connectedAt.toLocaleTimeString("ja-JP", { + hour: "2-digit", + minute: "2-digit", + }); + const lastActivityTime = lastActivity.toLocaleTimeString("ja-JP", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const slaveStatus = slaveStatuses.find(s => s.clientId === client.id); + const isSynced = slaveStatus?.isSynced ?? true; + const desyncDetails = slaveStatus?.desyncDetails ?? []; + + return ( +
+
+ + {client.id} + {!isSynced && ( + ⚠️ ズレあり + )} +
+
+
+ IP: + {client.ipAddress} +
+
+ 接続時刻: + {connectedTime} +
+
+ 最終通信: + {lastActivityTime} +
+
+ 同期状態: + + {isSynced ? "✅ 同期中" : "⚠️ ズレあり"} + +
+
+ {desyncDetails.length > 0 && ( +
+

ズレの詳細:

+
    + {desyncDetails.map((detail, index) => ( +
  • + + {detail.severity === "Critical" ? "❌" : "⚠️"} + + + {detail.sceneName && [{detail.sceneName}]} + {detail.sourceName && {detail.sourceName}:} + {detail.description} + +
  • + ))} +
+
+ )} +
+ +
+
+ ); + })} +
+
+ )} )} - {status.lastError && ( -
-
- -

エラー

-
-
-

{status.lastError}

+ {status.lastError && (() => { + const errorDetails = parseErrorMessage(status.lastError); + return ( +
+
+ + {errorDetails.severity === "error" ? "❌" : errorDetails.severity === "warning" ? "⚠️" : "ℹ️"} + +

{errorDetails.title}

+
+
+

{errorDetails.message}

+ {errorDetails.suggestions.length > 0 && ( +
+

解決方法:

+
    + {errorDetails.suggestions.map((suggestion, index) => ( +
  • {suggestion}
  • + ))} +
+
+ )} +
-
- )} + ); + })()}
); diff --git a/src/components/SlaveMonitor.tsx b/src/components/SlaveMonitor.tsx index 81f121a..9694b75 100644 --- a/src/components/SlaveMonitor.tsx +++ b/src/components/SlaveMonitor.tsx @@ -1,13 +1,15 @@ import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { useNetworkStatus } from "../hooks/useNetworkStatus"; import { ConnectionState } from "../types/network"; +import { parseErrorMessage } from "../utils/errorMessages"; export const SlaveMonitor = () => { const [host, setHost] = useState("192.168.1.100"); const [port, setPort] = useState(8080); const [isConnecting, setIsConnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false); - const { status, connectToMaster, disconnectFromMaster } = useNetworkStatus(); + const { status, reconnectionStatus, connectToMaster, disconnectFromMaster } = useNetworkStatus(); const handleConnect = async () => { setIsConnecting(true); @@ -31,6 +33,16 @@ export const SlaveMonitor = () => { } }; + const handleRequestResync = async () => { + try { + await invoke("request_resync_from_master"); + alert("Masterに再同期をリクエストしました"); + } catch (error) { + console.error("Failed to request resync:", error); + alert(`再同期リクエストに失敗しました: ${error}`); + } + }; + const isConnected = status.state === ConnectionState.Connected; return ( @@ -138,6 +150,45 @@ export const SlaveMonitor = () => { 💡 Masterからの変更を受信して、自動的にローカルOBSに適用しています

+
+ +
+ + )} + + {reconnectionStatus && reconnectionStatus.isReconnecting && ( +
+
+ 🔄 +

再接続中

+
+
+
+ 試行回数: + + {reconnectionStatus.attemptCount} / {reconnectionStatus.maxAttempts} + +
+ {reconnectionStatus.lastError && ( +
+ エラー: + + {reconnectionStatus.lastError} + +
+ )} +
+

+ ⚠️ Masterサーバーへの接続が切断されました。自動的に再接続を試みています... +

+
+
)} @@ -155,26 +206,32 @@ export const SlaveMonitor = () => { )} - {status.lastError && ( -
-
- -

接続エラー

-
-
-

{status.lastError}

-
-

よくある原因:

-
    -
  • MasterサーバーのIPアドレスが間違っている
  • -
  • Masterサーバーが起動していない
  • -
  • ファイアウォールでポートがブロックされている
  • -
  • ネットワークが異なるセグメントにある
  • -
+ {status.lastError && (() => { + const errorDetails = parseErrorMessage(status.lastError); + return ( +
+
+ + {errorDetails.severity === "error" ? "❌" : errorDetails.severity === "warning" ? "⚠️" : "ℹ️"} + +

{errorDetails.title}

+
+
+

{errorDetails.message}

+ {errorDetails.suggestions.length > 0 && ( +
+

解決方法:

+
    + {errorDetails.suggestions.map((suggestion, index) => ( +
  • {suggestion}
  • + ))} +
+
+ )}
-
- )} + ); + })()}
); diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index 6215030..1725da3 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { NetworkStatus, ConnectionState } from "../types/network"; +import { NetworkStatus, ConnectionState, ClientInfo, SlaveStatus, ReconnectionStatus } from "../types/network"; interface NetworkConfig { host: string; @@ -12,7 +12,11 @@ export const useNetworkStatus = () => { state: ConnectionState.Disconnected, }); const [error, setError] = useState(null); + const [clients, setClients] = useState([]); + const [slaveStatuses, setSlaveStatuses] = useState([]); + const [reconnectionStatus, setReconnectionStatus] = useState(null); const pollingIntervalRef = useRef(null); + const reconnectionPollingRef = useRef(null); const updateClientCount = useCallback(async () => { try { @@ -28,6 +32,33 @@ export const useNetworkStatus = () => { } }, []); + const updateClientsInfo = useCallback(async () => { + try { + const clientsInfo = await invoke("get_connected_clients_info"); + setClients(clientsInfo); + } catch (err) { + console.error("Failed to get connected clients info:", err); + } + }, []); + + const updateSlaveStatuses = useCallback(async () => { + try { + const statuses = await invoke("get_slave_statuses"); + setSlaveStatuses(statuses); + } catch (err) { + console.error("Failed to get slave statuses:", err); + } + }, []); + + const updateReconnectionStatus = useCallback(async () => { + try { + const status = await invoke("get_slave_reconnection_status"); + setReconnectionStatus(status); + } catch (err) { + console.error("Failed to get reconnection status:", err); + } + }, []); + const startMasterServer = useCallback(async (port: number) => { try { setStatus({ state: ConnectionState.Connecting }); @@ -35,9 +66,15 @@ export const useNetworkStatus = () => { setStatus({ state: ConnectionState.Connected, connectedClients: 0 }); setError(null); - // Start polling for client count + // Start polling for client count, info, and slave statuses updateClientCount(); - pollingIntervalRef.current = window.setInterval(updateClientCount, 1000); + updateClientsInfo(); + updateSlaveStatuses(); + pollingIntervalRef.current = window.setInterval(() => { + updateClientCount(); + updateClientsInfo(); + updateSlaveStatuses(); + }, 1000); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -73,6 +110,12 @@ export const useNetworkStatus = () => { await invoke("connect_to_master", { config }); setStatus({ state: ConnectionState.Connected }); setError(null); + + // Start polling for reconnection status + updateReconnectionStatus(); + reconnectionPollingRef.current = window.setInterval(() => { + updateReconnectionStatus(); + }, 1000); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -82,13 +125,20 @@ export const useNetworkStatus = () => { }); throw err; } - }, []); + }, [updateReconnectionStatus]); const disconnectFromMaster = useCallback(async () => { try { + // Stop polling for reconnection status + if (reconnectionPollingRef.current !== null) { + clearInterval(reconnectionPollingRef.current); + reconnectionPollingRef.current = null; + } + await invoke("disconnect_from_master"); setStatus({ state: ConnectionState.Disconnected }); setError(null); + setReconnectionStatus(null); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -102,12 +152,18 @@ export const useNetworkStatus = () => { if (pollingIntervalRef.current !== null) { clearInterval(pollingIntervalRef.current); } + if (reconnectionPollingRef.current !== null) { + clearInterval(reconnectionPollingRef.current); + } }; }, []); return { status, error, + clients, + slaveStatuses, + reconnectionStatus, startMasterServer, stopMasterServer, connectToMaster, diff --git a/src/hooks/useOBSConnection.ts b/src/hooks/useOBSConnection.ts index 4a40efa..e4b21bf 100644 --- a/src/hooks/useOBSConnection.ts +++ b/src/hooks/useOBSConnection.ts @@ -1,14 +1,24 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { OBSConnectionConfig, OBSConnectionStatus } from "../types/obs"; +import { OBSConnectionConfig, OBSConnectionStatus, OBSSource } from "../types/obs"; export const useOBSConnection = () => { const [status, setStatus] = useState({ connected: false, }); + const [sources, setSources] = useState([]); const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); + const fetchSources = useCallback(async () => { + try { + const sourcesData = await invoke("get_obs_sources"); + setSources(sourcesData); + } catch (err) { + console.error("Failed to fetch OBS sources:", err); + } + }, []); + const connect = useCallback(async (config: OBSConnectionConfig) => { setIsConnecting(true); setError(null); @@ -16,6 +26,8 @@ export const useOBSConnection = () => { await invoke("connect_obs", { config }); const newStatus = await invoke("get_obs_status"); setStatus(newStatus); + // Fetch sources after connection + await fetchSources(); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -23,12 +35,13 @@ export const useOBSConnection = () => { } finally { setIsConnecting(false); } - }, []); + }, [fetchSources]); const disconnect = useCallback(async () => { try { await invoke("disconnect_obs"); setStatus({ connected: false }); + setSources([]); setError(null); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -41,18 +54,59 @@ export const useOBSConnection = () => { try { const newStatus = await invoke("get_obs_status"); setStatus(newStatus); + + // If connected, fetch sources + if (newStatus.connected) { + await fetchSources(); + } else { + setSources([]); + } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); + setStatus({ connected: false }); + setSources([]); } - }, []); + }, [fetchSources]); + + // Auto-refresh connection status every 5 seconds + const intervalRef = useRef(null); + + useEffect(() => { + // Clear any existing interval + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + // Start polling if we should check status + const startPolling = () => { + intervalRef.current = window.setInterval(() => { + refreshStatus(); + }, 5000); // 5 seconds + }; + + // Start polling immediately and then every 5 seconds + refreshStatus(); + startPolling(); + + // Cleanup on unmount + return () => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [refreshStatus]); return { status, + sources, isConnecting, error, connect, disconnect, refreshStatus, + fetchSources, }; }; diff --git a/src/types/network.ts b/src/types/network.ts index 353b5e0..51c2689 100644 --- a/src/types/network.ts +++ b/src/types/network.ts @@ -32,3 +32,32 @@ export interface SlaveInfo { connectedAt: number; lastHeartbeat: number; } + +export interface ClientInfo { + id: string; + ipAddress: string; + connectedAt: number; + lastActivity: number; +} + +export interface DesyncDetail { + category: string; + sceneName: string; + sourceName: string; + description: string; + severity: string; +} + +export interface SlaveStatus { + clientId: string; + isSynced: boolean; + desyncDetails: DesyncDetail[]; + lastReportTime: number; +} + +export interface ReconnectionStatus { + isReconnecting: boolean; + attemptCount: number; + maxAttempts: number; + lastError?: string; +} From fefa473e7af7bd9fd3f9da9c96f44ddf8c7caada Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 04:21:58 +0900 Subject: [PATCH 03/20] Fix compilation errors in frontend and backend --- src-tauri/Cargo.lock | 24 ++++++ src-tauri/Cargo.toml | 3 +- src-tauri/src/commands.rs | 25 +++--- src-tauri/src/network/client.rs | 22 +++--- src-tauri/src/network/server.rs | 3 +- src-tauri/src/obs/events.rs | 48 ++++-------- src-tauri/src/sync/slave.rs | 40 +++++----- src/App.tsx | 5 +- src/hooks/useSettings.ts | 80 +++++++++++++++++++ src/utils/errorMessages.ts | 131 ++++++++++++++++++++++++++++++++ 10 files changed, 300 insertions(+), 81 deletions(-) create mode 100644 src/hooks/useSettings.ts create mode 100644 src/utils/errorMessages.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3937a48..ab4553a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -161,6 +161,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "async-task" version = "4.7.1" @@ -2327,6 +2349,7 @@ dependencies = [ "base64 0.21.7", "chrono", "futures", + "futures-util", "obws", "serde", "serde_json", @@ -2347,6 +2370,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1a1f9a0b90718cf798dd72018d1a59ef01c339b67fb7d56e63503b98d68f74e" dependencies = [ + "async-stream", "base64 0.21.7", "bitflags 2.10.0", "futures-util", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a04233e..4bb5c9c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,7 +25,8 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } tokio-tungstenite = "0.21" futures = "0.3" -obws = "0.11" +futures-util = "0.3" +obws = { version = "0.11", features = ["events"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } anyhow = "1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7c596f8..88d3144 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; -use std::time::{Duration, Instant}; -use tauri::{Emitter, State}; +use std::time::Instant; +use tauri::{Emitter, Manager, State}; use tokio::fs; use tokio::sync::{mpsc, Mutex, RwLock}; @@ -107,13 +107,6 @@ async fn get_log_dir(state: &AppState) -> Result { } } -fn get_log_file_path(state: &AppState) -> Result { - // This is a sync function, so we can't use async here - // We'll need to get the path differently or make this async - // For now, return a path that will be resolved async - Err("Use get_log_file_path_async instead".to_string()) -} - async fn get_log_file_path_async(state: &AppState) -> Result { let log_dir = get_log_dir(state).await?; let date = chrono::Utc::now().format("%Y-%m-%d"); @@ -171,7 +164,10 @@ pub async fn open_log_file(state: State<'_, AppState>) -> Result<(), String> { let app_handle = state.app_handle.read().await; if let Some(handle) = app_handle.as_ref() { // Use tauri-plugin-opener to open the file - tauri_plugin_opener::open(&log_path.to_string_lossy(), None, handle) + use tauri_plugin_opener::OpenerExt; + handle + .opener() + .open_path(log_path.to_string_lossy(), None::<&str>) .map_err(|e| format!("Failed to open log file: {}", e))?; Ok(()) } else { @@ -213,7 +209,7 @@ impl PerformanceMonitor { } } - pub async fn record_send(&self, message_id: String, message_type: String, size: usize) { + pub async fn record_send(&self, message_id: String, _message_type: String, _size: usize) { let mut send_times = self.send_times.write().await; send_times.insert(message_id, Instant::now()); } @@ -677,6 +673,13 @@ pub async fn get_obs_sources(state: State<'_, AppState>) -> Result, +) -> Result { + Ok(state.performance_monitor.get_metrics().await) +} + #[tauri::command] pub fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) diff --git a/src-tauri/src/network/client.rs b/src-tauri/src/network/client.rs index 124e0e1..d0764bb 100644 --- a/src-tauri/src/network/client.rs +++ b/src-tauri/src/network/client.rs @@ -1,5 +1,5 @@ use crate::sync::protocol::SyncMessage; -use anyhow::{Context, Result}; +use anyhow::Result; use futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; @@ -76,7 +76,7 @@ impl SlaveClient { mpsc::UnboundedSender, )> { let url = format!("ws://{}:{}", self.host, self.port); - let (tx, rx) = mpsc::unbounded_channel(); + let (tx, rx) = mpsc::unbounded_channel::(); let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); let host = self.host.clone(); @@ -198,12 +198,6 @@ impl SlaveClient { let (mut ws_sender, mut ws_receiver) = ws_stream.split(); let tx_clone = tx.clone(); - // Store message sender for sending messages - { - let mut message_tx = message_tx_for_send.write().await; - *message_tx = Some(tx_clone.clone()); - } - // Store sync message sender for resync requests { let mut sync_tx = sync_message_tx_for_store.write().await; @@ -211,11 +205,13 @@ impl SlaveClient { } // Send ws_sender to sending task - let _ = send_ready_tx.send(ws_sender.clone()).await; + let _ = send_ready_tx.send(ws_sender); // Handle incoming messages let should_reconnect_clone = should_reconnect.clone(); let message_tx_for_cleanup = message_tx_for_send.clone(); + let sync_message_tx_for_cleanup = sync_message_tx_for_store.clone(); + let reconnection_status_for_incoming = reconnection_status_for_task.clone(); tokio::spawn(async move { while let Some(msg) = ws_receiver.next().await { match msg { @@ -231,9 +227,9 @@ impl SlaveClient { } } } - Ok(Message::Ping(data)) => { - // Send pong - let _ = ws_sender.send(Message::Pong(data)).await; + Ok(Message::Ping(_data)) => { + // Pong will be handled by the sending task via ws_sender + // This is handled automatically by tokio-tungstenite } Ok(Message::Close(_)) => { println!("Connection closed by master"); @@ -260,7 +256,7 @@ impl SlaveClient { } // Update status: connection lost, will reconnect { - let mut status = reconnection_status_for_task.write().await; + let mut status = reconnection_status_for_incoming.write().await; status.is_reconnecting = true; status.attempt_count = 0; status.last_error = Some("Connection lost".to_string()); diff --git a/src-tauri/src/network/server.rs b/src-tauri/src/network/server.rs index 8f93497..d0aa657 100644 --- a/src-tauri/src/network/server.rs +++ b/src-tauri/src/network/server.rs @@ -128,6 +128,7 @@ impl MasterServer { let client_info_for_accept = self.client_info.clone(); let shutdown_for_accept = self.shutdown.clone(); let callback_for_accept = self.initial_state_callback.clone(); + let slave_statuses_for_accept = self.slave_statuses.clone(); let accept_task = tokio::spawn(async move { loop { if shutdown_for_accept.load(Ordering::SeqCst) { @@ -139,7 +140,7 @@ impl MasterServer { println!("New connection from: {}", addr); let clients = clients_for_accept.clone(); let client_info = client_info_for_accept.clone(); - let slave_statuses = self.slave_statuses.clone(); + let slave_statuses = slave_statuses_for_accept.clone(); let callback = callback_for_accept.clone(); tokio::spawn(handle_connection( stream, diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index 9b499c5..349b133 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -42,7 +42,7 @@ impl OBSEventHandler { let tx = self.event_tx.clone(); // Get event stream from obws client - let mut events = client + let events = client .events() .map_err(|e| anyhow::anyhow!("Failed to get event stream: {}", e))?; @@ -50,63 +50,41 @@ impl OBSEventHandler { // Spawn task to process events tokio::spawn(async move { + tokio::pin!(events); while let Some(event) = events.next().await { match event { - Event::CurrentProgramSceneChanged(data) => { + Event::CurrentProgramSceneChanged { name } => { let obs_event = OBSEvent::SceneChanged { - scene_name: data.scene_name, + scene_name: name, }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send SceneChanged event: {}", e); break; } } - Event::CurrentPreviewSceneChanged(data) => { + Event::CurrentPreviewSceneChanged { name } => { let obs_event = OBSEvent::CurrentPreviewSceneChanged { - scene_name: data.scene_name, + scene_name: name, }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send CurrentPreviewSceneChanged event: {}", e); break; } } - Event::SceneItemTransformChanged(data) => { + Event::SceneItemTransformChanged { + scene, + item_id, + .. + } => { let obs_event = OBSEvent::SceneItemTransformChanged { - scene_name: data.scene_name, - scene_item_id: data.scene_item_id, + scene_name: scene, + scene_item_id: item_id as i64, }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send SceneItemTransformChanged event: {}", e); break; } } - Event::InputSettingsChanged(data) => { - let obs_event = OBSEvent::InputSettingsChanged { - input_name: data.input_name, - }; - if let Err(e) = tx.send(obs_event) { - eprintln!("Failed to send InputSettingsChanged event: {}", e); - break; - } - } - Event::SourceCreated(data) => { - let obs_event = OBSEvent::SourceCreated { - source_name: data.source_name, - }; - if let Err(e) = tx.send(obs_event) { - eprintln!("Failed to send SourceCreated event: {}", e); - break; - } - } - Event::SourceDestroyed(data) => { - let obs_event = OBSEvent::SourceDestroyed { - source_name: data.source_name, - }; - if let Err(e) = tx.send(obs_event) { - eprintln!("Failed to send SourceDestroyed event: {}", e); - break; - } - } _ => { // Ignore other events } diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index 9eb9644..c07b0ec 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -1,5 +1,5 @@ use super::diff::{DiffDetector, DiffSeverity}; -use super::protocol::{SyncMessage, SyncMessageType}; +use super::protocol::{SyncMessage, SyncMessageType, SyncTargetType}; use crate::obs::{commands::OBSCommands, OBSClient}; use anyhow::{Context, Result}; use std::sync::Arc; @@ -426,45 +426,51 @@ impl SlaveSync { } }; - // Extract values from JSON payload + // Extract values from JSON payload (convert to f32 to match SceneItemTransform) let position_x = transform .get("position_x") .and_then(|v| v.as_f64()) + .map(|v| v as f32) .unwrap_or(current_transform.position_x); let position_y = transform .get("position_y") .and_then(|v| v.as_f64()) + .map(|v| v as f32) .unwrap_or(current_transform.position_y); let scale_x = transform .get("scale_x") .and_then(|v| v.as_f64()) + .map(|v| v as f32) .unwrap_or(current_transform.scale_x); let scale_y = transform .get("scale_y") .and_then(|v| v.as_f64()) + .map(|v| v as f32) .unwrap_or(current_transform.scale_y); let rotation = transform .get("rotation") .and_then(|v| v.as_f64()) + .map(|v| v as f32) .unwrap_or(current_transform.rotation); - // Build new transform using SceneItemTransformBuilder - use obws::requests::scene_items::SceneItemTransformBuilder; - let new_transform = SceneItemTransformBuilder::new() - .position((position_x, position_y)) - .scale((scale_x, scale_y)) - .rotation(rotation) - .build(); - - // Apply the transform using SetTransformBuilder - use obws::requests::scene_items::SetTransformBuilder; + // Build new transform by updating current transform + let mut new_transform = current_transform; + new_transform.position_x = position_x; + new_transform.position_y = position_y; + new_transform.scale_x = scale_x; + new_transform.scale_y = scale_y; + new_transform.rotation = rotation; + + // Apply the transform using SetTransform + use obws::requests::scene_items::SetTransform; + let set_transform = SetTransform { + scene: scene_name, + item_id: scene_item_id, + transform: new_transform.into(), + }; client .scene_items() - .set_transform(SetTransformBuilder::new( - scene_name, - scene_item_id, - new_transform, - )) + .set_transform(set_transform) .await .context("Failed to set transform")?; diff --git a/src/App.tsx b/src/App.tsx index c99b7a8..9e3334f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,6 @@ import { SyncTargetSelector } from "./components/SyncTargetSelector"; import { AlertPanel } from "./components/AlertPanel"; import { OBSSourceList } from "./components/OBSSourceList"; import { AppMode } from "./types/sync"; -import { OBSSource } from "./types/obs"; function App() { const [appMode, setAppMode] = useState(null); @@ -24,9 +23,9 @@ function App() { const [obsPassword, setObsPassword] = useState(""); const [isConnectingOBS, setIsConnectingOBS] = useState(false); - const { settings, isLoading: settingsLoading, loadSettings, saveSettings } = useSettings(); + const { settings, isLoading: settingsLoading, saveSettings } = useSettings(); const { status: obsStatus, sources, connect, disconnect, error: obsError } = useOBSConnection(); - const { syncState, setMode, error: syncError } = useSyncState(); + const { setMode, error: syncError } = useSyncState(); const networkStatus = useNetworkStatus(); const { alerts, clearAlert, clearAllAlerts } = useDesyncAlerts(); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..3c4a2a7 --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,80 @@ +import { useState, useCallback, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +export interface OBSSettings { + host: string; + port: number; + password: string; +} + +export interface MasterSettings { + defaultPort: number; +} + +export interface SlaveSettings { + defaultHost: string; + defaultPort: number; +} + +export interface AppSettings { + obs: OBSSettings; + master: MasterSettings; + slave: SlaveSettings; +} + +export const useSettings = () => { + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const loadSettings = useCallback(async () => { + try { + setIsLoading(true); + const loaded = await invoke("load_settings"); + setSettings(loaded); + return loaded; + } catch (error) { + console.error("Failed to load settings:", error); + // Return default settings on error + const defaultSettings: AppSettings = { + obs: { + host: "localhost", + port: 4455, + password: "", + }, + master: { + defaultPort: 8080, + }, + slave: { + defaultHost: "192.168.1.100", + defaultPort: 8080, + }, + }; + setSettings(defaultSettings); + return defaultSettings; + } finally { + setIsLoading(false); + } + }, []); + + const saveSettings = useCallback(async (newSettings: AppSettings) => { + try { + await invoke("save_settings", { settings: newSettings }); + setSettings(newSettings); + } catch (error) { + console.error("Failed to save settings:", error); + throw error; + } + }, []); + + // Load settings on mount + useEffect(() => { + loadSettings(); + }, [loadSettings]); + + return { + settings, + isLoading, + loadSettings, + saveSettings, + }; +}; diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts new file mode 100644 index 0000000..288a712 --- /dev/null +++ b/src/utils/errorMessages.ts @@ -0,0 +1,131 @@ +// エラーメッセージの詳細化(日本語) + +export interface ErrorDetails { + title: string; + message: string; + suggestions: string[]; + severity: "error" | "warning" | "info"; +} + +export function parseErrorMessage(error: string): ErrorDetails { + const lowerError = error.toLowerCase(); + + // OBS接続エラー + if (lowerError.includes("failed to connect") || lowerError.includes("connection refused")) { + return { + title: "OBS接続エラー", + message: "OBS Studioに接続できませんでした", + suggestions: [ + "OBS Studioが起動しているか確認してください", + "WebSocketサーバーが有効になっているか確認してください(ツール → WebSocketサーバー設定)", + "ポート番号が正しいか確認してください(デフォルト: 4455)", + "ファイアウォールでポートがブロックされていないか確認してください", + ], + severity: "error", + }; + } + + if (lowerError.includes("authentication") || lowerError.includes("password") || lowerError.includes("認証")) { + return { + title: "認証エラー", + message: "OBS WebSocketの認証に失敗しました", + suggestions: [ + "パスワードが正しいか確認してください", + "OBS StudioのWebSocketサーバー設定でパスワードを確認してください", + ], + severity: "error", + }; + } + + if (lowerError.includes("timeout") || lowerError.includes("タイムアウト")) { + return { + title: "接続タイムアウト", + message: "OBS Studioへの接続がタイムアウトしました", + suggestions: [ + "OBS Studioが応答しているか確認してください", + "ネットワーク接続を確認してください", + "ホスト名またはIPアドレスが正しいか確認してください", + ], + severity: "error", + }; + } + + // ネットワークエラー(Master-Slave間) + if (lowerError.includes("failed to bind") || lowerError.includes("address already in use")) { + return { + title: "ポート使用中エラー", + message: "指定されたポートが既に使用されています", + suggestions: [ + "別のポート番号を試してください", + "他のアプリケーションが同じポートを使用していないか確認してください", + "前回起動したサーバーが正しく停止していない可能性があります", + ], + severity: "error", + }; + } + + if (lowerError.includes("connection refused") && lowerError.includes("master")) { + return { + title: "Master接続エラー", + message: "Masterサーバーに接続できませんでした", + suggestions: [ + "MasterサーバーのIPアドレスが正しいか確認してください", + "Masterサーバーが起動しているか確認してください", + "ポート番号が正しいか確認してください", + "ファイアウォールでポートがブロックされていないか確認してください", + "同じネットワーク内にいるか確認してください", + ], + severity: "error", + }; + } + + if (lowerError.includes("network") || lowerError.includes("network unreachable")) { + return { + title: "ネットワークエラー", + message: "ネットワーク接続に問題があります", + suggestions: [ + "ネットワーク接続を確認してください", + "IPアドレスが正しいか確認してください", + "ファイアウォールの設定を確認してください", + ], + severity: "error", + }; + } + + // 同期エラー + if (lowerError.includes("failed to apply") || lowerError.includes("適用")) { + return { + title: "同期適用エラー", + message: "Masterからの変更を適用できませんでした", + suggestions: [ + "OBS Studioが正常に動作しているか確認してください", + "シーンやソースが存在するか確認してください", + "OBS Studioのログを確認してください", + ], + severity: "warning", + }; + } + + if (lowerError.includes("desync") || lowerError.includes("不一致")) { + return { + title: "同期不一致", + message: "MasterとSlaveの状態が一致していません", + suggestions: [ + "再同期を実行してください", + "OBS Studioの状態を確認してください", + ], + severity: "warning", + }; + } + + // デフォルトエラー + return { + title: "エラー", + message: error, + suggestions: [ + "エラーの詳細を確認してください", + "OBS Studioとアプリケーションを再起動してみてください", + ], + severity: "error", + }; +} From e189190cdcc75ec5c93e4d54fa065856d784cd7b Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 04:22:39 +0900 Subject: [PATCH 04/20] Fix compiler warnings: remove unused imports and variables --- src-tauri/src/network/client.rs | 3 +-- src-tauri/src/network/mod.rs | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src-tauri/src/network/client.rs b/src-tauri/src/network/client.rs index d0764bb..bf64e75 100644 --- a/src-tauri/src/network/client.rs +++ b/src-tauri/src/network/client.rs @@ -75,7 +75,6 @@ impl SlaveClient { mpsc::UnboundedReceiver, mpsc::UnboundedSender, )> { - let url = format!("ws://{}:{}", self.host, self.port); let (tx, rx) = mpsc::unbounded_channel::(); let (send_tx, mut send_rx) = mpsc::unbounded_channel::(); @@ -195,7 +194,7 @@ impl SlaveClient { } current_attempt_for_task.store(0, Ordering::SeqCst); - let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + let (ws_sender, mut ws_receiver) = ws_stream.split(); let tx_clone = tx.clone(); // Store sync message sender for resync requests diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index dd7b6d1..c07f47e 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -1,4 +1,2 @@ pub mod client; pub mod server; - -pub use client::ReconnectionStatus; From 1267c92820a190db147deb1f5394380ed6d9f515 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 05:25:35 +0900 Subject: [PATCH 05/20] =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=91=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E8=AD=A6=E5=91=8A=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 2 +- src-tauri/src/obs/events.rs | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cd5e1a..a45df25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ permissions: jobs: release: - if: github.ref =~ '^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' + if: startsWith(github.ref, 'refs/tags/v') strategy: fail-fast: false matrix: diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index 349b133..aa5d227 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -54,28 +54,20 @@ impl OBSEventHandler { while let Some(event) = events.next().await { match event { Event::CurrentProgramSceneChanged { name } => { - let obs_event = OBSEvent::SceneChanged { - scene_name: name, - }; + let obs_event = OBSEvent::SceneChanged { scene_name: name }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send SceneChanged event: {}", e); break; } } Event::CurrentPreviewSceneChanged { name } => { - let obs_event = OBSEvent::CurrentPreviewSceneChanged { - scene_name: name, - }; + let obs_event = OBSEvent::CurrentPreviewSceneChanged { scene_name: name }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send CurrentPreviewSceneChanged event: {}", e); break; } } - Event::SceneItemTransformChanged { - scene, - item_id, - .. - } => { + Event::SceneItemTransformChanged { scene, item_id, .. } => { let obs_event = OBSEvent::SceneItemTransformChanged { scene_name: scene, scene_item_id: item_id as i64, From 979c06194bab12ba513a5160ddb0d60db3ca3a42 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 06:16:04 +0900 Subject: [PATCH 06/20] cleanup documents --- DEVELOPMENT.md | 152 ------------------------------------------ README.md | 33 ++------- directorystructure.md | 114 ------------------------------- 3 files changed, 4 insertions(+), 295 deletions(-) delete mode 100644 DEVELOPMENT.md delete mode 100644 directorystructure.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index aa91cea..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,152 +0,0 @@ -# 開発ガイド - -## マルチインスタンス起動方法(Master/Slaveテスト用) - -OBS Syncの動作を確認するには、2つのインスタンスを同時に起動する必要があります。 - -### 前提条件 - -1. Node.jsとnpmがインストールされていること -2. Rustがインストールされていること -3. 依存関係がインストールされていること - -```bash -npm install -``` - -### 起動手順 - -#### ステップ1: 2つのターミナルを開く - -**ターミナル1(Master用):** -```bash -npm run tauri:master -``` - -**ターミナル2(Slave用):** -```bash -npm run tauri:slave -``` - -これにより、2つのインスタンスが異なるポートで起動します: -- Master: ポート1420 -- Slave: ポート1421 - -### 動作確認手順 - -#### 1. OBS Studioの準備 - -2つのOBS Studioインスタンスを起動します(異なるポートで): - -**OBS Studio 1(Master用):** -- WebSocketサーバー設定: ポート4455 - -**OBS Studio 2(Slave用):** -- WebSocketサーバー設定: ポート4456(または別のポート) - -> **Note:** OBS Studioで複数インスタンスを起動するには、`--multi`オプションを使用するか、異なるプロファイルを作成してください。 - -#### 2. Masterの設定 - -**ターミナル1のアプリで:** -1. **Masterモード**を選択 -2. OBS接続設定: - - ホスト: `localhost` - - ポート: `4455` - - パスワード: (設定した場合のみ) -3. 「OBSに接続」をクリック -4. 同期対象を選択(ソース、プレビュー、プログラム) -5. 「サーバーを起動」をクリック(デフォルトポート: 8080) - -#### 3. Slaveの設定 - -**ターミナル2のアプリで:** -1. **Slaveモード**を選択 -2. OBS接続設定: - - ホスト: `localhost` - - ポート: `4456` - - パスワード: (設定した場合のみ) -3. 「OBSに接続」をクリック -4. Master接続設定: - - Masterホスト: `localhost` - - ポート: `8080` -5. 「Masterに接続」をクリック - -#### 4. 動作確認 - -Master側のOBSでシーンを変更すると、Slave側のOBSが自動的に同じシーンに切り替わることを確認してください。 - -### トラブルシューティング - -#### ポートが既に使用されているエラー - -``` -Failed to start master server: Failed to bind to 0.0.0.0:8080 -``` - -**原因:** 前回起動したサーバーが正しく停止していません。 - -**解決方法:** -1. アプリで「モードを変更」ボタンをクリック -2. アプリを完全に終了して再起動 -3. 別のポート番号を使用(例: 8081) - -#### OBSに接続できない - -**確認事項:** -- OBS Studioが起動していること -- WebSocketサーバーが有効になっていること -- ポート番号が正しいこと -- ファイアウォールでブロックされていないこと - -#### Slaveがマスターに接続できない - -**確認事項:** -- Masterサーバーが起動していること -- IPアドレスとポート番号が正しいこと -- 同じネットワーク内にいること -- ファイアウォールでブロックされていないこと - -### 開発時のヒント - -#### ログの確認 - -アプリケーションのログは以下のコマンドで確認できます: - -```bash -# Rustのログ -RUST_LOG=debug npm run tauri:master - -# または -RUST_LOG=debug npm run tauri:slave -``` - -#### ホットリロード - -Viteのホットリロードが有効になっているため、フロントエンドのコードを変更すると自動的にリロードされます。 - -Rustコードを変更した場合は、アプリを再起動する必要があります。 - -#### デバッグモード - -開発者ツールを開くには、アプリ内で `Ctrl+Shift+I` (Windows/Linux) または `Cmd+Option+I` (macOS) を押してください。 - -### 単一インスタンスでの開発 - -機能開発時は単一インスタンスで十分な場合があります: - -```bash -npm run tauri dev -``` - -この場合、デフォルトでポート1420が使用されます。 - -## ビルド - -本番用のバイナリをビルドするには: - -```bash -npm run tauri build -``` - -ビルドされたファイルは `src-tauri/target/release/` に生成されます。 diff --git a/README.md b/README.md index 4d8e1a7..d721128 100644 --- a/README.md +++ b/README.md @@ -13,38 +13,19 @@ OBS Syncは、LAN内の複数のOBS Studio間で、画像ソース、シーン - **Slaveモード**: Masterからの変更を受信し、ローカルのOBS Studioに自動適用。非同期があればアラートを表示 ### リアルタイム同期 -- 画像ソースの内容、サイズ、位置をリアルタイムで同期 -- 画像を差し替えたら自動的に全てのOBSに反映 -- 位置を調整したら自動的に全てのOBSに反映 +- 画像ソースの内容、サイズ、位置をWebsocketを使用し同期 +- 画像の差し替え、位置調整、フィルターの変更などが全てのOBSに反映可能 ### 柔軟な同期対象選択 以下の対象を個別に選択可能: -- ソース(Source) -- プレビュー(Preview) -- プログラム(Program/Live Output) +- ソース/シーン/フィルター +- Preview/Program ### 非同期検出とアラート Slaveモードでは、受信した変更とローカルのOBS状態に差異がある場合、UIでアラートを表示します。 ## 技術スタック -### フロントエンド -- **フレームワーク**: React 19.1.0 (TypeScript) -- **ビルドツール**: Vite 7.0.4 -- **UI**: CSS Modules -- **通知**: react-toastify 10.x -- **OBS通信**: obs-websocket-js 5.x - -### バックエンド -- **フレームワーク**: Tauri v2.x -- **非同期ランタイム**: tokio 1.x -- **WebSocket**: tokio-tungstenite 0.21.x -- **シリアライゼーション**: serde 1.x, serde_json 1.x - -### プロトコル -- **OBS WebSocket**: v5.x (OBS Studio 28.x以降) -- **Master-Slave通信**: カスタムJSON over WebSocket - ## 必要要件 - Node.js LTS版 @@ -113,12 +94,6 @@ npm run tauri build 4. MasterノードのIPアドレスとポートを入力して接続 5. Masterからの変更が自動的にローカルOBSに適用される -## プロジェクト構造 - -詳細は以下のドキュメントを参照してください: -- [技術スタック](./technologystack.md) -- [ディレクトリ構造](./directorystructure.md) - ## ライセンス MIT License diff --git a/directorystructure.md b/directorystructure.md deleted file mode 100644 index 5fd12ad..0000000 --- a/directorystructure.md +++ /dev/null @@ -1,114 +0,0 @@ -# ディレクトリ構造 - -``` -obs-sync/ -├── src/ # フロントエンドソース -│ ├── components/ # Reactコンポーネント -│ │ ├── MasterControl.tsx # Masterモード制御UI -│ │ ├── SlaveMonitor.tsx # Slaveモード監視UI -│ │ ├── SyncTargetSelector.tsx # 同期対象選択 -│ │ ├── ConnectionStatus.tsx # 接続状態表示 -│ │ ├── AlertPanel.tsx # 非同期アラート表示 -│ │ └── OBSSourceList.tsx # OBSソース一覧 -│ ├── hooks/ # カスタムフック -│ │ ├── useOBSConnection.ts # OBS接続管理 -│ │ ├── useSyncState.ts # 同期状態管理 -│ │ └── useNetworkStatus.ts # ネットワーク状態管理 -│ ├── types/ # TypeScript型定義 -│ │ ├── obs.ts # OBS関連型 -│ │ ├── sync.ts # 同期関連型 -│ │ └── network.ts # ネットワーク関連型 -│ ├── utils/ # ユーティリティ関数 -│ │ ├── obsUtils.ts # OBS操作ヘルパー -│ │ └── syncUtils.ts # 同期処理ヘルパー -│ ├── styles/ # スタイルシート -│ │ ├── global.css # グローバルスタイル -│ │ └── components/ # コンポーネント別スタイル -│ ├── App.tsx # メインアプリケーション -│ ├── App.css # アプリケーションスタイル -│ └── main.tsx # エントリーポイント -│ -├── src-tauri/ # Tauriバックエンド -│ ├── src/ -│ │ ├── main.rs # メインエントリーポイント -│ │ ├── lib.rs # ライブラリルート -│ │ ├── obs/ # OBS WebSocket統合 -│ │ │ ├── mod.rs # OBSモジュール -│ │ │ ├── client.rs # OBS WebSocketクライアント -│ │ │ ├── events.rs # OBSイベント処理 -│ │ │ └── commands.rs # OBSコマンド実行 -│ │ ├── sync/ # 同期ロジック -│ │ │ ├── mod.rs # 同期モジュール -│ │ │ ├── master.rs # Masterモード実装 -│ │ │ ├── slave.rs # Slaveモード実装 -│ │ │ ├── protocol.rs # 通信プロトコル定義 -│ │ │ └── diff.rs # 差分検出ロジック -│ │ ├── network/ # ネットワーク通信 -│ │ │ ├── mod.rs # ネットワークモジュール -│ │ │ ├── server.rs # WebSocketサーバー (Master用) -│ │ │ └── client.rs # WebSocketクライアント (Slave用) -│ │ └── commands.rs # Tauriコマンド定義 -│ ├── Cargo.toml # Rust依存関係 -│ └── tauri.conf.json # Tauri設定 -│ -├── public/ # 静的ファイル -├── .gitignore -├── package.json # Node.js依存関係 -├── tsconfig.json # TypeScript設定 -├── vite.config.ts # Vite設定 -├── README.md # プロジェクト説明 -├── technologystack.md # 技術スタック詳細 -└── directorystructure.md # このファイル -``` - -## モジュール説明 - -### フロントエンド (src/) -- **components/**: UI コンポーネント - - Master/Slave モード切替と制御UI - - 同期状態の可視化 - - アラート表示パネル - -- **hooks/**: React カスタムフック - - OBS接続の管理と状態保持 - - 同期状態の管理 - - ネットワーク状態の監視 - -- **types/**: TypeScript 型定義 - - OBS WebSocketの型 - - 同期プロトコルの型 - - ネットワークメッセージの型 - -- **utils/**: ヘルパー関数 - - OBS操作の抽象化 - - 同期処理のユーティリティ - -### バックエンド (src-tauri/src/) -- **obs/**: OBS WebSocket統合 - - OBS Studioへの接続管理 - - イベントの監視と処理 - - コマンドの実行 - -- **sync/**: 同期ロジック - - Masterモード: 変更検出とブロードキャスト - - Slaveモード: 変更受信と適用 - - 差分検出アルゴリズム - -- **network/**: ネットワーク通信 - - WebSocketサーバー(Master) - - WebSocketクライアント(Slave) - - メッセージのルーティング - -- **commands.rs**: フロントエンドから呼び出せるTauriコマンド - -## データフロー - -### Masterモード -``` -OBS Studio → obs/client.rs → sync/master.rs → network/server.rs → Slave Nodes -``` - -### Slaveモード -``` -Master Node → network/client.rs → sync/slave.rs → obs/client.rs → Local OBS Studio -``` From cda028d7a8998fe49b9db26acdb959af44251904 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 06:18:37 +0900 Subject: [PATCH 07/20] Fix GitHub Actions release workflow and add release links to README - Fix tagName in release.yml: use github.ref_name instead of v__VERSION__ - Add release download links to README.md --- .github/workflows/release.yml | 6 +++--- README.md | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a45df25..77e0776 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,9 +58,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tagName: v__VERSION__ - releaseName: 'OBS Sync v__VERSION__' - releaseBody: '🚀 Release version __VERSION__' + tagName: ${{ github.ref_name }} + releaseName: 'OBS Sync ${{ github.ref_name }}' + releaseBody: '🚀 Release version ${{ github.ref_name }}' releaseDraft: false prerelease: false args: ${{ matrix.args }} diff --git a/README.md b/README.md index d721128..bac0474 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ Slaveモードでは、受信した変更とローカルのOBS状態に差異が ## 技術スタック +## ダウンロード + +- [最新リリース](https://github.com/FlowingSPDG/obs-sync/releases/latest) +- [全リリース](https://github.com/FlowingSPDG/obs-sync/releases) + ## 必要要件 - Node.js LTS版 From 1cbd8db17f861ac5b4c6bfe3beb4e5c514a8ced5 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 06:24:38 +0900 Subject: [PATCH 08/20] fix deps --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77e0776..ca3529d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: if: matrix.platform == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libglib2.0-dev libffi-dev pkg-config - name: Install frontend dependencies run: npm ci From f982dd0d6eca71ff17ff6183e1973d18a1e2f252 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 06:33:39 +0900 Subject: [PATCH 09/20] Fix Linux dependencies in release workflow - Add missing libglib2.0-dev, libssl-dev, and other required packages - Add verification steps for pkg-config libraries - Replace libappindicator3-dev with libayatana-appindicator3-dev --- .github/workflows/release.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca3529d..824d5dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,24 @@ jobs: if: matrix.platform == 'ubuntu-latest' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libglib2.0-dev libffi-dev pkg-config + sudo apt-get install -y \ + build-essential \ + curl \ + wget \ + file \ + pkg-config \ + libglib2.0-dev \ + libwebkit2gtk-4.1-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + # Verify pkg-config can find required libraries + pkg-config --modversion glib-2.0 || echo "ERROR: glib-2.0 not found" + pkg-config --modversion gobject-2.0 || echo "ERROR: gobject-2.0 not found" + pkg-config --modversion gio-2.0 || echo "ERROR: gio-2.0 not found" + # List pkg-config search paths for debugging + pkg-config --variable pc_path pkg-config || echo "pkg-config path check failed" - name: Install frontend dependencies run: npm ci From c9a6b8644cefbb10357523b0ba552e04d9389b4b Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 06:36:20 +0900 Subject: [PATCH 10/20] Fix PKG_CONFIG_PATH for Linux builds - Explicitly set PKG_CONFIG_PATH environment variable for tauri-action - Ensures pkg-config can find glib-2.0.pc and related files during build --- .github/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 824d5dd..fb0716f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,8 +61,11 @@ jobs: pkg-config --modversion glib-2.0 || echo "ERROR: glib-2.0 not found" pkg-config --modversion gobject-2.0 || echo "ERROR: gobject-2.0 not found" pkg-config --modversion gio-2.0 || echo "ERROR: gio-2.0 not found" - # List pkg-config search paths for debugging - pkg-config --variable pc_path pkg-config || echo "pkg-config path check failed" + + - name: Set PKG_CONFIG_PATH for Linux + if: matrix.platform == 'ubuntu-latest' + run: | + echo "PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig:${PKG_CONFIG_PATH}" >> $GITHUB_ENV - name: Install frontend dependencies run: npm ci From df361d22890e745e0e082b2f4c4b7fce54ce4adc Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 06:40:03 +0900 Subject: [PATCH 11/20] fix ci --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d235ca2..0f95a21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,26 @@ jobs: with: workspaces: './src-tauri -> target' + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + curl \ + wget \ + file \ + pkg-config \ + libglib2.0-dev \ + libwebkit2gtk-4.1-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Set PKG_CONFIG_PATH for Linux + run: | + echo "PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig:${PKG_CONFIG_PATH}" >> $GITHUB_ENV + - name: Install frontend dependencies run: npm ci From 8762ec28d845551d56c8583d97b39518f3a08323 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 07:39:51 +0900 Subject: [PATCH 12/20] Fix compilation errors: Update obws 0.11 API usage - Fix filters().list() return type usage (returns Vec directly) - Use filter.settings field instead of non-existent settings() method - Replace SourceFilterSettingsChanged with SourceFilterNameChanged event - Fix scene_item_id type comparison (i64 vs u64) - Remove non-existent inputs().kind() method, use file path detection instead - Remove unreachable pattern in match statement --- SYNC_DETAILS.md | 302 +++++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 65 ++++--- src-tauri/src/network/client.rs | 3 - src-tauri/src/network/server.rs | 21 ++- src-tauri/src/obs/commands.rs | 56 ------ src-tauri/src/obs/events.rs | 19 +- src-tauri/src/sync/diff.rs | 12 -- src-tauri/src/sync/master.rs | 154 +++++++++++++++- src-tauri/src/sync/protocol.rs | 9 +- src-tauri/src/sync/slave.rs | 115 +++++++++++- src/components/MasterControl.tsx | 76 +++++++- src/components/SlaveMonitor.tsx | 77 +++++++- src/hooks/useNetworkStatus.ts | 52 +++++- 13 files changed, 824 insertions(+), 137 deletions(-) create mode 100644 SYNC_DETAILS.md diff --git a/SYNC_DETAILS.md b/SYNC_DETAILS.md new file mode 100644 index 0000000..0aa7808 --- /dev/null +++ b/SYNC_DETAILS.md @@ -0,0 +1,302 @@ +# OBS Sync - 同期機能詳細仕様 + +## 同期アーキテクチャ概要 + +OBS SyncはMaster-Slaveアーキテクチャで、MasterのOBS Studioで発生した変更を、接続中のすべてのSlaveノードにリアルタイムで同期します。 + +## 同期される項目とタイミング + +### 1. リアルタイム同期(OBSイベントベース) + +以下の変更は、OBSイベント発生時に即座に同期されます。 + +#### 1.1 シーン変更(SceneChange) + +**トリガー**: OBS WebSocketイベント +- `CurrentProgramSceneChanged` - プログラムシーン変更時 +- `CurrentPreviewSceneChanged` - プレビューシーン変更時(Studio Mode) + +**同期タイミング**: リアルタイム(ユーザーがシーンを切り替えた瞬間) + +**同期対象タイプ**: +- `Program` - プログラムシーン(通常配信) +- `Preview` - プレビューシーン(Studio Mode) + +**同期内容**: +- `scene_name`: 変更されたシーン名 + +**Slave側の処理**: +- `set_current_program_scene()` または `set_current_preview_scene()` を呼び出し + +**制限事項**: +- Previewシーンの同期にはStudio Modeが有効である必要があります + +--- + +#### 1.2 シーンアイテムのTransform変更(TransformUpdate) + +**トリガー**: OBS WebSocketイベント +- `SceneItemTransformChanged` - シーンアイテムの位置・サイズ・回転が変更された時 + +**同期タイミング**: リアルタイム(ドラッグ&ドロップや数値入力でTransformを変更した瞬間) + +**同期対象タイプ**: `Source` + +**同期内容**: +```json +{ + "scene_name": "シーン名", + "scene_item_id": 1, + "transform": { + "position_x": 100.0, + "position_y": 200.0, + "rotation": 0.0, + "scale_x": 1.0, + "scale_y": 1.0, + "width": 1920.0, + "height": 1080.0 + } +} +``` + +**Slave側の処理**: +- 現在のTransformを取得 +- 受信した値で更新(部分更新対応) +- `set_transform()` を呼び出し + +**注意点**: +- Transform変更時、Master側でOBSから最新のTransform値を取得してから送信します(非同期処理) + +--- + +#### 1.3 フィルター設定変更(FilterUpdate) + +**トリガー**: OBS WebSocketイベント +- `SourceFilterSettingsChanged` - ソースのフィルター設定が変更された時 + +**同期タイミング**: リアルタイム(フィルタープロパティを変更した瞬間) + +**同期対象タイプ**: `Source` + +**同期内容**: +```json +{ + "scene_name": "シーン名", + "scene_item_id": 1, + "source_name": "画像ソース名", + "filter_name": "フィルター名", + "filter_settings": { + // フィルター固有の設定値(JSONオブジェクト) + } +} +``` + +**Slave側の処理**: +- `set_settings()` を呼び出してフィルター設定を更新 + +**制限事項**: +- `SourceFilterSettingsChanged`イベントは`source_name`のみを提供するため、`master.rs`で全シーンを検索して`scene_name`と`scene_item_id`を解決します +- シーン検索に失敗した場合、フィルター更新が送信されない可能性があります + +--- + +#### 1.4 画像ソース変更(ImageUpdate) + +**トリガー**: OBS WebSocketイベント +- `InputSettingsChanged` - 入力ソースの設定が変更された時 + +**同期タイミング**: リアルタイム(画像ファイルを変更した瞬間) + +**同期対象タイプ**: `Source` + +**同期条件**: +- ソースタイプが `image_*` で始まる場合のみ(例: `image_source`, `image_source_v3`) + +**同期内容**: +```json +{ + "scene_name": "", + "source_name": "画像ソース名", + "file": "/path/to/image.png", + "image_data": "base64エンコードされた画像データ" +} +``` + +**Slave側の処理**: +1. Base64デコードして画像データを復元 +2. 画像フォーマットを自動検出(PNG, JPEG, GIF, BMP, WebP) +3. 一時ファイルに保存(`%TEMP%/obs-sync/`) +4. OBSの入力設定でファイルパスを更新 + +**注意点**: +- 画像ファイル全体がBase64エンコードされて送信されるため、大きな画像の場合、ネットワーク負荷が高くなります +- 一時ファイルはOSの一時ディレクトリに保存されます + +--- + +### 2. 初期状態同期(StateSync) + +接続時や再同期時に、MasterのOBS全体の状態を同期します。 + +#### 2.1 トリガー条件 + +**自動トリガー**: +- SlaveがMasterに接続した時(接続確立後500ms遅延) + +**手動トリガー**: +- Master側で「全Slaveに再同期」ボタンをクリック +- Master側で特定のSlaveに対して再同期を実行 +- Slave側で「Masterに再同期をリクエスト」を実行 + +#### 2.2 同期内容 + +```json +{ + "current_program_scene": "現在のプログラムシーン名", + "current_preview_scene": "現在のプレビューシーン名(Studio Mode時のみ)", + "scenes": [ + { + "name": "シーン名", + "items": [ + { + "source_name": "ソース名", + "scene_item_id": 1, + "source_type": "image_source", + "transform": { + "position_x": 100.0, + "position_y": 200.0, + "rotation": 0.0, + "scale_x": 1.0, + "scale_y": 1.0, + "width": 1920.0, + "height": 1080.0 + }, + "image_data": { + "file": "/path/to/image.png", + "data": "base64エンコードされた画像データ(画像ソースの場合のみ)" + }, + "filters": [ + { + "name": "フィルター名", + "enabled": true, + "settings": { + // フィルター設定値 + } + } + ] + } + ] + } + ] +} +``` + +**Slave側の処理順序**: +1. 全シーンのアイテムを順次処理 +2. 各アイテムのTransformを適用 +3. 画像ソースの場合は画像データを適用 +4. 各フィルターの設定と有効/無効状態を適用 +5. 最後に現在のプログラムシーンとプレビューシーンを設定 + +**注意点**: +- 初期状態同期は大量のデータを送信するため、ネットワークが遅い場合、完了に時間がかかる可能性があります +- 画像ソースが多い場合、Base64エンコードされたデータのサイズが大きくなります + +--- + +## 同期対象の選択 + +フロントエンドの「同期設定」で、以下の同期対象を個別に選択できます: + +- **Source**: ソース関連の同期 + - Transform変更 + - フィルター設定変更 + - 画像ソース変更 + +- **Program**: プログラムシーン変更 + +- **Preview**: プレビューシーン変更(Studio Mode) + +**デフォルト設定**: `Program` と `Source` が有効 + +--- + +## 非同期検出機能 + +Slave側では、定期的に(デフォルト5秒間隔)ローカルのOBS状態をチェックし、Masterから受信した期待状態と比較します。 + +### 検出される不一致 + +1. **シーンミスマッチ(Critical)** + - 現在のシーンが期待されるシーンと異なる + +2. **ソース欠落(Warning)** + - 期待されるシーンにソースが存在しない + +3. **Transform不一致(Warning)** + - 位置、スケールが期待値と異なる(許容誤差: 0.5ピクセル) + +検出された不一致は、フロントエンドのアラートパネルに表示されます。 + +--- + +## 同期されない項目 + +以下の項目は現在、同期の対象外です: + +- **シーンの追加・削除・名前変更** +- **シーンアイテムの追加・削除** +- **ソースの作成・削除** +- **ソースの基本プロパティ(サイズ、名前など)** +- **シーンコレクションの変更** +- **オーディオ設定** +- **ビデオ設定** +- **出力設定(ストリーミング/録画)** +- **スクリプトやプラグインの設定** +- **Studio Modeの有効/無効状態** + +--- + +## パフォーマンス考慮事項 + +### メッセージ送信頻度 + +- **シーン変更**: 低頻度(ユーザー操作に依存) +- **Transform変更**: 高頻度(ドラッグ中は連続的に送信される可能性) +- **フィルター変更**: 低頻度(プロパティ変更時のみ) +- **画像変更**: 低頻度(ファイル変更時のみ) + +### ネットワーク負荷 + +- **Transform/Filter/SceneChange**: 軽量(JSON数KB) +- **ImageUpdate**: 重量(Base64エンコードされた画像データ) +- **StateSync**: 非常に重量(全シーン・全アイテム・全画像の一括送信) + +### 推奨事項 + +- 画像ソースは適切なサイズに最適化してください +- 多くのSlaveが接続している場合、Transform変更の連続送信に注意してください +- 初期同期は、ネットワークが安定している状態で実行してください + +--- + +## エラー処理 + +### メッセージ送信失敗 + +Master側でメッセージの送信に失敗した場合: +- エラーログに記録されますが、処理は継続します +- 再接続は自動的に試行されます(Slave側) + +### メッセージ受信・適用失敗 + +Slave側でメッセージの受信や適用に失敗した場合: +- エラーログに記録されます +- アラートがフロントエンドに表示されます +- 処理は継続され、次のメッセージを受信可能です + +### 再接続 + +Slave側で接続が切断された場合: +- 自動的に再接続を試行します(最大10回) +- 指数バックオフ(1秒、2秒、4秒...最大30秒)で再試行します diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 88d3144..959df15 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,7 +9,6 @@ use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; -use std::time::Instant; use tauri::{Emitter, Manager, State}; use tokio::fs; use tokio::sync::{mpsc, Mutex, RwLock}; @@ -196,41 +195,25 @@ pub struct PerformanceMetrics { pub struct PerformanceMonitor { metrics: Arc>>, - max_metrics: usize, - send_times: Arc>>, } impl PerformanceMonitor { pub fn new(max_metrics: usize) -> Self { Self { metrics: Arc::new(RwLock::new(VecDeque::with_capacity(max_metrics))), - max_metrics, - send_times: Arc::new(RwLock::new(std::collections::HashMap::new())), } } - pub async fn record_send(&self, message_id: String, _message_type: String, _size: usize) { - let mut send_times = self.send_times.write().await; - send_times.insert(message_id, Instant::now()); - } - - pub async fn record_receive(&self, message_id: String, message_type: String, size: usize) { - let mut send_times = self.send_times.write().await; - if let Some(send_time) = send_times.remove(&message_id) { - let latency = send_time.elapsed().as_secs_f64() * 1000.0; // Convert to milliseconds - - let metric = SyncMetric { - timestamp: chrono::Utc::now().timestamp_millis(), - message_type, - latency_ms: latency, - message_size_bytes: size, - }; - - let mut metrics = self.metrics.write().await; - if metrics.len() >= self.max_metrics { - metrics.pop_front(); - } - metrics.push_back(metric); + pub async fn record_metric(&self, metric: SyncMetric) { + let mut metrics = self.metrics.write().await; + + // Add new metric + metrics.push_back(metric); + + // Keep only the last max_metrics entries + let max_capacity = metrics.capacity(); + while metrics.len() > max_capacity { + metrics.pop_front(); } } @@ -392,8 +375,9 @@ pub async fn start_master_server(state: State<'_, AppState>, port: u16) -> Resul }) .await; + let performance_monitor = Some(state.performance_monitor.clone()); master_server - .start(sync_rx) + .start(sync_rx, performance_monitor) .await .map_err(|e| format!("Failed to start master server: {}", e))?; *state.master_server.write().await = Some(master_server); @@ -474,6 +458,7 @@ pub async fn connect_to_master( // Start processing sync messages let slave_sync_for_processing = slave_sync.clone(); + let performance_monitor_for_processing = state.performance_monitor.clone(); tokio::spawn(async move { let mut rx = sync_rx; let mut first_message = true; @@ -484,6 +469,30 @@ pub async fn connect_to_master( first_message = false; } + // Calculate latency and record metric + let receive_time = chrono::Utc::now().timestamp_millis(); + let latency_ms = if message.timestamp > 0 { + (receive_time - message.timestamp) as f64 + } else { + 0.0 + }; + + // Calculate message size + let message_size_bytes = match serde_json::to_string(&message) { + Ok(json) => json.len(), + Err(_) => 0, + }; + + // Record metric + let message_type_str = format!("{:?}", message.message_type); + let metric = SyncMetric { + timestamp: receive_time, + message_type: message_type_str, + latency_ms, + message_size_bytes, + }; + performance_monitor_for_processing.record_metric(metric).await; + if let Err(e) = slave_sync_for_processing.apply_sync_message(message).await { eprintln!("Failed to apply sync message: {}", e); } diff --git a/src-tauri/src/network/client.rs b/src-tauri/src/network/client.rs index bf64e75..1a85fd0 100644 --- a/src-tauri/src/network/client.rs +++ b/src-tauri/src/network/client.rs @@ -288,9 +288,6 @@ impl SlaveClient { Ok((rx, send_tx)) } - pub async fn is_connected(&self) -> bool { - self.ws_stream.read().await.is_some() - } pub async fn disconnect(&self) { // Stop reconnection attempts diff --git a/src-tauri/src/network/server.rs b/src-tauri/src/network/server.rs index d0aa657..2e99258 100644 --- a/src-tauri/src/network/server.rs +++ b/src-tauri/src/network/server.rs @@ -8,10 +8,9 @@ use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{mpsc, RwLock}; use tokio::task::JoinHandle; -use tokio_tungstenite::{accept_async, tungstenite::Message, WebSocketStream}; +use tokio_tungstenite::{accept_async, tungstenite::Message}; type ClientId = String; -type ClientConnection = WebSocketStream; type InitialStateCallback = Arc< dyn Fn(ClientId) -> std::pin::Pin + Send>> @@ -88,7 +87,11 @@ impl MasterServer { println!("Master server stopped"); } - pub async fn start(&self, mut sync_rx: mpsc::UnboundedReceiver) -> Result<()> { + pub async fn start( + &self, + mut sync_rx: mpsc::UnboundedReceiver, + performance_monitor: Option>, + ) -> Result<()> { let addr = format!("0.0.0.0:{}", self.port); let listener = TcpListener::bind(&addr) .await @@ -114,6 +117,18 @@ impl MasterServer { } }; + // Record performance metric (send time) + if let Some(ref monitor) = performance_monitor { + let message_type_str = format!("{:?}", message.message_type); + let metric = crate::commands::SyncMetric { + timestamp: message.timestamp, + message_type: message_type_str, + latency_ms: 0.0, // Latency is calculated on slave side + message_size_bytes: json.len(), + }; + monitor.record_metric(metric).await; + } + let clients_lock = clients.read().await; for (client_id, tx) in clients_lock.iter() { if let Err(e) = tx.send(Message::Text(json.clone())) { diff --git a/src-tauri/src/obs/commands.rs b/src-tauri/src/obs/commands.rs index 2b5ea9f..cebc6d1 100644 --- a/src-tauri/src/obs/commands.rs +++ b/src-tauri/src/obs/commands.rs @@ -1,38 +1,9 @@ use anyhow::{Context, Result}; use obws::Client; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SceneItemTransform { - pub position_x: f64, - pub position_y: f64, - pub rotation: f64, - pub scale_x: f64, - pub scale_y: f64, - pub width: f64, - pub height: f64, -} pub struct OBSCommands; impl OBSCommands { - pub async fn get_current_program_scene(client: &Client) -> Result { - let scene = client - .scenes() - .current_program_scene() - .await - .context("Failed to get current program scene")?; - Ok(scene) - } - - pub async fn get_current_preview_scene(client: &Client) -> Result> { - match client.scenes().current_preview_scene().await { - Ok(scene) => Ok(Some(scene)), - Err(_) => Ok(None), - } - } - pub async fn set_current_program_scene(client: &Client, scene_name: &str) -> Result<()> { client .scenes() @@ -41,31 +12,4 @@ impl OBSCommands { .context("Failed to set current program scene")?; Ok(()) } - - #[allow(dead_code)] - pub async fn set_scene_item_transform( - _client: &Client, - _scene_name: &str, - _scene_item_id: i64, - _transform: SceneItemTransform, - ) -> Result<()> { - // Transform setting would be implemented based on the actual obws API - Ok(()) - } - - #[allow(dead_code)] - pub async fn get_scene_item_list(_client: &Client, _scene_name: &str) -> Result> { - // Scene item list retrieval would be implemented based on the actual obws API - Ok(vec![]) - } - - #[allow(dead_code)] - pub async fn set_input_settings( - _client: &Client, - _input_name: &str, - _settings: &Value, - ) -> Result<()> { - // Input settings would be implemented based on the actual obws API - Ok(()) - } } diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index aa5d227..6c663ff 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -14,18 +14,17 @@ pub enum OBSEvent { scene_name: String, scene_item_id: i64, }, - SourceCreated { - source_name: String, - }, - SourceDestroyed { - source_name: String, - }, InputSettingsChanged { input_name: String, }, CurrentPreviewSceneChanged { scene_name: String, }, + SceneItemFilterChanged { + scene_name: String, + scene_item_id: i64, + filter_name: String, + }, } pub struct OBSEventHandler { @@ -77,6 +76,14 @@ impl OBSEventHandler { break; } } + // Note: SourceFilterSettingsChanged is not available in obws 0.11 + // Filter changes will need to be detected through polling or manual triggers + // For now, we skip filter change events as they're not properly supported in this obws version + Event::SourceFilterNameChanged { .. } => { + // Filter name changed - we could potentially handle this, but settings changes + // are not directly available as events in obws 0.11 + // TODO: Implement filter change detection via polling or upgrade obws version + } _ => { // Ignore other events } diff --git a/src-tauri/src/sync/diff.rs b/src-tauri/src/sync/diff.rs index 063ba5c..82c03c9 100644 --- a/src-tauri/src/sync/diff.rs +++ b/src-tauri/src/sync/diff.rs @@ -14,14 +14,12 @@ pub enum DiffCategory { SceneMismatch, SourceMissing, TransformMismatch, - SettingsMismatch, } #[derive(Debug, Clone)] pub enum DiffSeverity { Critical, // Scene doesn't match Warning, // Transform or settings differ - Info, // Minor differences } pub struct DiffDetector; @@ -182,14 +180,4 @@ impl DiffDetector { Some(diffs) } } - - pub fn is_synced(diffs: &[StateDifference]) -> bool { - diffs.is_empty() - } - - pub fn has_critical_diffs(diffs: &[StateDifference]) -> bool { - diffs - .iter() - .any(|d| matches!(d.severity, DiffSeverity::Critical)) - } } diff --git a/src-tauri/src/sync/master.rs b/src-tauri/src/sync/master.rs index 222690d..0f90c38 100644 --- a/src-tauri/src/sync/master.rs +++ b/src-tauri/src/sync/master.rs @@ -123,6 +123,117 @@ impl MasterSync { }); } } + OBSEvent::SceneItemFilterChanged { + scene_name, + scene_item_id, + filter_name, + } => { + if targets.contains(&SyncTargetType::Source) { + // Get filter settings and send update + let obs_client_clone = obs_client.clone(); + let message_tx_clone = message_tx.clone(); + let scene_name_clone = scene_name.clone(); + let filter_name_clone = filter_name.clone(); + + tokio::spawn(async move { + let client_arc = obs_client_clone.get_client_arc(); + let client_lock = client_arc.read().await; + + if let Some(client) = client_lock.as_ref() { + let (resolved_scene_name, resolved_scene_item_id, source_name) = + if !scene_name_clone.is_empty() && scene_item_id > 0 { + // scene_name and scene_item_id are already provided + // Get scene items to find source name + match client.scene_items().list(&scene_name_clone).await { + Ok(items) => { + if let Some(item) = items.iter().find(|i| { + i.id as i64 == scene_item_id + }) { + (Some(scene_name_clone.clone()), Some(scene_item_id), Some(item.source_name.clone())) + } else { + (None, None, None) + } + } + Err(e) => { + eprintln!("Failed to get scene items for {}: {}", scene_name_clone, e); + (None, None, None) + } + } + } else { + // Need to search all scenes to find the source + match client.scenes().list().await { + Ok(scenes) => { + let mut found = None; + 'outer: for scene in scenes.scenes { + match client.scene_items().list(&scene.name).await { + Ok(items) => { + for item in items { + // Check if this source has the filter + match client.filters().list(&item.source_name).await { + Ok(filters) => { + if filters.iter().any(|f| f.name == filter_name_clone) { + found = Some((scene.name.clone(), item.id as i64, item.source_name.clone())); + break 'outer; + } + } + Err(_) => continue, + } + } + } + Err(_) => continue, + } + } + if let Some((s, id, src)) = found { + (Some(s), Some(id), Some(src)) + } else { + (None, None, None) + } + } + Err(e) => { + eprintln!("Failed to get scenes list for filter resolution: {}", e); + (None, None, None) + } + } + }; + + if let (Some(scene), Some(item_id), Some(source)) = (resolved_scene_name, resolved_scene_item_id, source_name) { + // Get filter settings + match client.filters().list(&source).await { + Ok(filters) => { + if let Some(filter) = filters.iter().find(|f| f.name == filter_name_clone) { + let payload = serde_json::json!({ + "scene_name": scene, + "scene_item_id": item_id, + "source_name": source, + "filter_name": filter_name_clone, + "filter_settings": filter.settings + }); + + let msg = SyncMessage::new( + SyncMessageType::FilterUpdate, + SyncTargetType::Source, + payload, + ); + let _ = message_tx_clone.send(msg); + println!( + "Sent filter update for {} on source {} in scene {} (item: {})", + filter_name_clone, source, scene, item_id + ); + } else { + eprintln!("Filter {} not found on source {}", filter_name_clone, source); + } + } + Err(e) => { + eprintln!("Failed to get filter list for {}: {}", source, e); + } + } + } else { + eprintln!("Could not resolve scene_name/scene_item_id for filter {} on source", filter_name_clone); + } + } + }); + } + } OBSEvent::InputSettingsChanged { input_name } => { if targets.contains(&SyncTargetType::Source) { let obs_client_clone = obs_client.clone(); @@ -135,7 +246,7 @@ impl MasterSync { let client_lock = client_arc.read().await; if let Some(client) = client_lock.as_ref() { - // Get input settings + // Get input settings first to check if it's an image source match client .inputs() .settings::(&input_name_clone) @@ -148,6 +259,20 @@ impl MasterSync { .and_then(|v| v.as_str()) .unwrap_or(""); + // Only process if it has a file path (likely an image source) + if file_path.is_empty() { + println!( + "Skipping InputSettingsChanged for {} - no file path found", + input_name_clone + ); + return; + } + + println!( + "Processing InputSettingsChanged for {} (file: {})", + input_name_clone, file_path + ); + // Read and encode image if file path exists let image_data = if !file_path.is_empty() { match tokio::fs::read(file_path).await { @@ -194,17 +319,11 @@ impl MasterSync { }); } } - _ => {} } } }); } - pub fn send_heartbeat(&self) -> Result<()> { - self.message_tx.send(SyncMessage::heartbeat())?; - Ok(()) - } - /// Read image file and encode to base64 async fn read_and_encode_image(file_path: &str) -> Option { match tokio::fs::read(file_path).await { @@ -343,12 +462,33 @@ impl MasterSync { None }; + // Get filters for this source + let mut filters_data = Vec::new(); + match client.filters().list(&item.source_name).await { + Ok(filters) => { + for filter in filters { + filters_data.push(serde_json::json!({ + "name": filter.name, + "enabled": filter.enabled, + "settings": filter.settings + })); + } + } + Err(e) => { + eprintln!( + "Failed to get filters for source {}: {}", + item.source_name, e + ); + } + } + scene_items_data.push(serde_json::json!({ "source_name": item.source_name, "scene_item_id": item.id, "source_type": source_type, "transform": transform, "image_data": image_data, + "filters": filters_data, })); } diff --git a/src-tauri/src/sync/protocol.rs b/src-tauri/src/sync/protocol.rs index 798ec5d..5a86fd7 100644 --- a/src-tauri/src/sync/protocol.rs +++ b/src-tauri/src/sync/protocol.rs @@ -8,6 +8,7 @@ pub enum SyncMessageType { TransformUpdate, SceneChange, ImageUpdate, + FilterUpdate, Heartbeat, StateSync, StateSyncRequest, // Slave requests initial state from Master @@ -41,14 +42,6 @@ impl SyncMessage { } } - pub fn heartbeat() -> Self { - Self::new( - SyncMessageType::Heartbeat, - SyncTargetType::Program, - Value::Object(serde_json::Map::new()), - ) - } - pub fn state_sync_request() -> Self { Self::new( SyncMessageType::StateSyncRequest, diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index c07b0ec..c35d5af 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -85,7 +85,7 @@ impl SlaveSync { // Send state report to Master { let tx = state_report_tx.read().await; - if let Some(ref sender) = tx.as_ref() { + if let Some(sender) = tx.as_ref() { let desync_details: Vec = diffs .iter() .map(|diff| { @@ -226,7 +226,7 @@ impl SlaveSync { .as_str() .context("Invalid scene_name in payload")?; - if let Err(e) = OBSCommands::set_current_program_scene(&client, scene_name).await { + if let Err(e) = OBSCommands::set_current_program_scene(client, scene_name).await { self.send_alert( scene_name.to_string(), String::new(), @@ -246,7 +246,7 @@ impl SlaveSync { // Apply transform if included in payload if let Some(transform) = message.payload["transform"].as_object() { if let Err(e) = self - .apply_transform(&client, scene_name, scene_item_id, transform) + .apply_transform(client, scene_name, scene_item_id, transform) .await { self.send_alert( @@ -274,7 +274,7 @@ impl SlaveSync { // Handle image update if let Err(e) = self - .handle_image_update(&client, source_name, file_path, image_data) + .handle_image_update(client, source_name, file_path, image_data) .await { self.send_alert( @@ -285,6 +285,36 @@ impl SlaveSync { )?; } } + SyncMessageType::FilterUpdate => { + let source_name = message.payload["source_name"] + .as_str() + .context("Invalid source_name")?; + let filter_name = message.payload["filter_name"] + .as_str() + .context("Invalid filter_name")?; + + // Get filter settings from payload + if let Some(filter_settings) = message.payload["filter_settings"].as_object() { + if let Err(e) = self + .apply_filter_settings(client, source_name, filter_name, filter_settings) + .await + { + self.send_alert( + String::new(), + source_name.to_string(), + format!("Failed to update filter {}: {}", filter_name, e), + AlertSeverity::Warning, + )?; + } else { + println!( + "Applied filter update for {} on source {}", + filter_name, source_name + ); + } + } else { + eprintln!("Filter settings missing in payload"); + } + } SyncMessageType::Heartbeat => { // Just acknowledge heartbeat } @@ -312,7 +342,7 @@ impl SlaveSync { if let Some(transform) = item["transform"].as_object() { if let Err(e) = self .apply_transform( - &client, + client, scene_name, scene_item_id, transform, @@ -334,7 +364,7 @@ impl SlaveSync { ) { if let Err(e) = self .handle_image_update( - &client, + client, source_name, file, Some(data), @@ -348,6 +378,47 @@ impl SlaveSync { } } } + + // Apply filters if available + if let Some(filters) = item["filters"].as_array() { + for filter in filters { + let filter_name = filter["name"].as_str().unwrap_or(""); + let filter_enabled = filter["enabled"].as_bool().unwrap_or(true); + if let Some(filter_settings) = filter["settings"].as_object() { + // Apply filter settings + if let Err(e) = self + .apply_filter_settings( + client, + source_name, + filter_name, + filter_settings, + ) + .await + { + eprintln!( + "Failed to apply filter {} for {}: {}", + filter_name, source_name, e + ); + } else { + // Set filter enabled state + if let Err(e) = client + .filters() + .set_enabled(obws::requests::filters::SetEnabled { + source: source_name, + filter: filter_name, + enabled: filter_enabled, + }) + .await + { + eprintln!( + "Failed to set filter {} enabled state for {}: {}", + filter_name, source_name, e + ); + } + } + } + } + } } } } @@ -356,7 +427,7 @@ impl SlaveSync { // Apply current program scene if let Some(scene_name) = message.payload["current_program_scene"].as_str() { if let Err(e) = crate::obs::commands::OBSCommands::set_current_program_scene( - &client, scene_name, + client, scene_name, ) .await { @@ -580,6 +651,36 @@ impl SlaveSync { } } + async fn apply_filter_settings( + &self, + client: &obws::Client, + source_name: &str, + filter_name: &str, + filter_settings: &serde_json::Map, + ) -> Result<()> { + // Convert JSON map to Value for settings + let settings: serde_json::Value = serde_json::json!(filter_settings); + + // Apply filter settings using OBS API + client + .filters() + .set_settings(obws::requests::filters::SetSettings { + source: source_name, + filter: filter_name, + settings: &settings, + overlay: Some(true), + }) + .await + .context("Failed to set filter settings")?; + + println!( + "Applied filter settings for {} on source {}", + filter_name, source_name + ); + + Ok(()) + } + fn send_alert( &self, scene_name: String, diff --git a/src/components/MasterControl.tsx b/src/components/MasterControl.tsx index 9b64d23..89b9bd9 100644 --- a/src/components/MasterControl.tsx +++ b/src/components/MasterControl.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { useNetworkStatus } from "../hooks/useNetworkStatus"; +import { useNetworkStatus, PerformanceMetrics } from "../hooks/useNetworkStatus"; import { ConnectionState } from "../types/network"; import { parseErrorMessage } from "../utils/errorMessages"; @@ -8,7 +8,7 @@ export const MasterControl = () => { const [port, setPort] = useState(8080); const [isStarting, setIsStarting] = useState(false); const [isStopping, setIsStopping] = useState(false); - const { status, clients, slaveStatuses, startMasterServer, stopMasterServer } = useNetworkStatus(); + const { status, clients, slaveStatuses, performanceMetrics, startMasterServer, stopMasterServer } = useNetworkStatus(); const handleStart = async () => { setIsStarting(true); @@ -141,6 +141,36 @@ export const MasterControl = () => { ws://<your-ip>:{port}
+ + {performanceMetrics && ( +
+
📊 パフォーマンスメトリクス
+
+
+ 平均レイテンシー: + + {performanceMetrics.averageLatencyMs.toFixed(2)} ms + +
+
+ 総メッセージ数: + {performanceMetrics.totalMessages} +
+
+ メッセージ/秒: + + {performanceMetrics.messagesPerSecond.toFixed(2)} + +
+
+ 総転送バイト数: + + {(performanceMetrics.totalBytes / 1024).toFixed(2)} KB + +
+
+
+ )}
); diff --git a/src/components/SlaveMonitor.tsx b/src/components/SlaveMonitor.tsx index 9694b75..16ebfc9 100644 --- a/src/components/SlaveMonitor.tsx +++ b/src/components/SlaveMonitor.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { useNetworkStatus } from "../hooks/useNetworkStatus"; +import { useNetworkStatus, PerformanceMetrics } from "../hooks/useNetworkStatus"; import { ConnectionState } from "../types/network"; import { parseErrorMessage } from "../utils/errorMessages"; @@ -9,7 +9,7 @@ export const SlaveMonitor = () => { const [port, setPort] = useState(8080); const [isConnecting, setIsConnecting] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false); - const { status, reconnectionStatus, connectToMaster, disconnectFromMaster } = useNetworkStatus(); + const { status, reconnectionStatus, performanceMetrics, connectToMaster, disconnectFromMaster } = useNetworkStatus(); const handleConnect = async () => { setIsConnecting(true); @@ -145,6 +145,37 @@ export const SlaveMonitor = () => { + + {performanceMetrics && ( +
+
📊 パフォーマンスメトリクス
+
+
+ 平均レイテンシー: + + {performanceMetrics.averageLatencyMs.toFixed(2)} ms + +
+
+ 総メッセージ数: + {performanceMetrics.totalMessages} +
+
+ メッセージ/秒: + + {performanceMetrics.messagesPerSecond.toFixed(2)} + +
+
+ 総転送バイト数: + + {(performanceMetrics.totalBytes / 1024).toFixed(2)} KB + +
+
+
+ )} +

💡 Masterからの変更を受信して、自動的にローカルOBSに適用しています @@ -462,6 +493,48 @@ export const SlaveMonitor = () => { color: white; border-color: var(--primary-color); } + + .metrics-panel { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); + } + + .metrics-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 1rem 0; + } + + .metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; + } + + .metric-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + } + + .metric-label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; + } + + .metric-value { + font-size: 0.875rem; + font-weight: 600; + color: var(--primary-color); + font-family: 'Monaco', 'Courier New', monospace; + } `}

); diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index 1725da3..b7c713f 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -7,6 +7,19 @@ interface NetworkConfig { port: number; } +export interface PerformanceMetrics { + averageLatencyMs: number; + totalMessages: number; + messagesPerSecond: number; + totalBytes: number; + recentMetrics: Array<{ + timestamp: number; + messageType: string; + latencyMs: number; + messageSizeBytes: number; + }>; +} + export const useNetworkStatus = () => { const [status, setStatus] = useState({ state: ConnectionState.Disconnected, @@ -15,8 +28,10 @@ export const useNetworkStatus = () => { const [clients, setClients] = useState([]); const [slaveStatuses, setSlaveStatuses] = useState([]); const [reconnectionStatus, setReconnectionStatus] = useState(null); + const [performanceMetrics, setPerformanceMetrics] = useState(null); const pollingIntervalRef = useRef(null); const reconnectionPollingRef = useRef(null); + const metricsPollingRef = useRef(null); const updateClientCount = useCallback(async () => { try { @@ -59,6 +74,15 @@ export const useNetworkStatus = () => { } }, []); + const updatePerformanceMetrics = useCallback(async () => { + try { + const metrics = await invoke("get_performance_metrics"); + setPerformanceMetrics(metrics); + } catch (err) { + console.error("Failed to get performance metrics:", err); + } + }, []); + const startMasterServer = useCallback(async (port: number) => { try { setStatus({ state: ConnectionState.Connecting }); @@ -66,15 +90,19 @@ export const useNetworkStatus = () => { setStatus({ state: ConnectionState.Connected, connectedClients: 0 }); setError(null); - // Start polling for client count, info, and slave statuses + // Start polling for client count, info, slave statuses, and performance metrics updateClientCount(); updateClientsInfo(); updateSlaveStatuses(); + updatePerformanceMetrics(); pollingIntervalRef.current = window.setInterval(() => { updateClientCount(); updateClientsInfo(); updateSlaveStatuses(); }, 1000); + metricsPollingRef.current = window.setInterval(() => { + updatePerformanceMetrics(); + }, 2000); // Update metrics every 2 seconds } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -93,10 +121,15 @@ export const useNetworkStatus = () => { clearInterval(pollingIntervalRef.current); pollingIntervalRef.current = null; } + if (metricsPollingRef.current !== null) { + clearInterval(metricsPollingRef.current); + metricsPollingRef.current = null; + } await invoke("stop_master_server"); setStatus({ state: ConnectionState.Disconnected }); setError(null); + setPerformanceMetrics(null); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -111,11 +144,15 @@ export const useNetworkStatus = () => { setStatus({ state: ConnectionState.Connected }); setError(null); - // Start polling for reconnection status + // Start polling for reconnection status and performance metrics updateReconnectionStatus(); + updatePerformanceMetrics(); reconnectionPollingRef.current = window.setInterval(() => { updateReconnectionStatus(); }, 1000); + metricsPollingRef.current = window.setInterval(() => { + updatePerformanceMetrics(); + }, 2000); // Update metrics every 2 seconds } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -129,16 +166,21 @@ export const useNetworkStatus = () => { const disconnectFromMaster = useCallback(async () => { try { - // Stop polling for reconnection status + // Stop polling for reconnection status and metrics if (reconnectionPollingRef.current !== null) { clearInterval(reconnectionPollingRef.current); reconnectionPollingRef.current = null; } + if (metricsPollingRef.current !== null) { + clearInterval(metricsPollingRef.current); + metricsPollingRef.current = null; + } await invoke("disconnect_from_master"); setStatus({ state: ConnectionState.Disconnected }); setError(null); setReconnectionStatus(null); + setPerformanceMetrics(null); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); setError(errorMessage); @@ -155,6 +197,9 @@ export const useNetworkStatus = () => { if (reconnectionPollingRef.current !== null) { clearInterval(reconnectionPollingRef.current); } + if (metricsPollingRef.current !== null) { + clearInterval(metricsPollingRef.current); + } }; }, []); @@ -164,6 +209,7 @@ export const useNetworkStatus = () => { clients, slaveStatuses, reconnectionStatus, + performanceMetrics, startMasterServer, stopMasterServer, connectToMaster, From 30111f43ad79113ec27a4cb158a8436a0a386162 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 08:12:32 +0900 Subject: [PATCH 13/20] feat: Add InputSettingsChanged event handling and upgrade obws to 0.14 - Add InputSettingsChanged event processing in obs/events.rs - Upgrade obws from 0.11 to 0.14 - Update API calls to match obws 0.14 (SceneId, InputId, SourceId) - Preserve original file extensions in image sync (use file path instead of magic bytes) - Update scene and source ID handling across the codebase - Add SplashScreen component --- src-tauri/Cargo.lock | 103 ++++++++------- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 5 +- src-tauri/src/obs/events.rs | 22 +++- src-tauri/src/sync/master.rs | 33 ++--- src-tauri/src/sync/slave.rs | 67 +++++++--- src/App.css | 195 +++++++++++++++++++++++++---- src/App.tsx | 7 +- src/components/SplashScreen.css | 214 ++++++++++++++++++++++++++++++++ src/components/SplashScreen.tsx | 49 ++++++++ 10 files changed, 590 insertions(+), 107 deletions(-) create mode 100644 src/components/SplashScreen.css create mode 100644 src/components/SplashScreen.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ab4553a..439878e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1439,17 +1439,6 @@ dependencies = [ "match_token", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -1467,7 +1456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -1478,7 +1467,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http", "http-body", "pin-project-lite", ] @@ -1499,7 +1488,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.4.0", + "http", "http-body", "httparse", "itoa", @@ -1521,7 +1510,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.4.0", + "http", "http-body", "hyper", "ipnet", @@ -2366,12 +2355,12 @@ dependencies = [ [[package]] name = "obws" -version = "0.11.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1a1f9a0b90718cf798dd72018d1a59ef01c339b67fb7d56e63503b98d68f74e" +checksum = "245cd220b1d4edd6ba01b30ef79d22dafb8b7acb4b00335297357053ba2c6c6e" dependencies = [ "async-stream", - "base64 0.21.7", + "base64 0.22.1", "bitflags 2.10.0", "futures-util", "rgb", @@ -2381,11 +2370,12 @@ dependencies = [ "serde_repr", "serde_with", "sha2", - "thiserror 1.0.69", + "thiserror 2.0.17", "time", "tokio", - "tokio-tungstenite 0.20.1", + "tokio-tungstenite 0.26.2", "tracing", + "uuid", ] [[package]] @@ -2838,6 +2828,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2858,6 +2858,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2876,6 +2886,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2979,7 +2998,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http", "http-body", "http-body-util", "hyper", @@ -3619,7 +3638,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http 1.4.0", + "http", "jni", "libc", "log", @@ -3765,7 +3784,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http 1.4.0", + "http", "jni", "objc2", "objc2-ui-kit", @@ -3788,7 +3807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" dependencies = [ "gtk", - "http 1.4.0", + "http", "jni", "log", "objc2", @@ -3821,7 +3840,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http 1.4.0", + "http", "infer", "json-patch", "kuchikiki", @@ -4001,26 +4020,26 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.20.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.20.1", + "tungstenite 0.21.0", ] [[package]] name = "tokio-tungstenite" -version = "0.21.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.21.0", + "tungstenite 0.26.2", ] [[package]] @@ -4156,7 +4175,7 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http 1.4.0", + "http", "http-body", "iri-string", "pin-project-lite", @@ -4269,14 +4288,14 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.20.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" dependencies = [ "byteorder", "bytes", "data-encoding", - "http 0.2.12", + "http", "httparse", "log", "rand 0.8.5", @@ -4288,20 +4307,18 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.21.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ - "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http", "httparse", "log", - "rand 0.8.5", + "rand 0.9.2", "sha1", - "thiserror 1.0.69", - "url", + "thiserror 2.0.17", "utf-8", ] @@ -5150,7 +5167,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http 1.4.0", + "http", "javascriptcore-rs", "jni", "kuchikiki", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4bb5c9c..31a0de4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,7 +26,7 @@ tokio = { version = "1", features = ["full"] } tokio-tungstenite = "0.21" futures = "0.3" futures-util = "0.3" -obws = { version = "0.11", features = ["events"] } +obws = { version = "0.14", features = ["events"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } anyhow = "1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 959df15..fbbfc04 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -652,7 +652,8 @@ pub async fn get_obs_sources(state: State<'_, AppState>) -> Result { for scene in scenes.scenes { // Get scene items - match client.scene_items().list(&scene.name).await { + let scene_id: obws::requests::scenes::SceneId = scene.id.clone().into(); + match client.scene_items().list(scene_id).await { Ok(items) => { for item in items { // Store source info (avoid duplicates) @@ -666,7 +667,7 @@ pub async fn get_obs_sources(state: State<'_, AppState>) -> Result { - eprintln!("Failed to get scene items for {}: {}", scene.name, e); + eprintln!("Failed to get scene items for {:?}: {}", scene.id, e); } } } diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index 6c663ff..76b3997 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -52,15 +52,17 @@ impl OBSEventHandler { tokio::pin!(events); while let Some(event) = events.next().await { match event { - Event::CurrentProgramSceneChanged { name } => { - let obs_event = OBSEvent::SceneChanged { scene_name: name }; + Event::CurrentProgramSceneChanged { id } => { + let scene_name = format!("{:?}", id); + let obs_event = OBSEvent::SceneChanged { scene_name }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send SceneChanged event: {}", e); break; } } - Event::CurrentPreviewSceneChanged { name } => { - let obs_event = OBSEvent::CurrentPreviewSceneChanged { scene_name: name }; + Event::CurrentPreviewSceneChanged { id } => { + let scene_name = format!("{:?}", id); + let obs_event = OBSEvent::CurrentPreviewSceneChanged { scene_name }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send CurrentPreviewSceneChanged event: {}", e); break; @@ -68,7 +70,7 @@ impl OBSEventHandler { } Event::SceneItemTransformChanged { scene, item_id, .. } => { let obs_event = OBSEvent::SceneItemTransformChanged { - scene_name: scene, + scene_name: format!("{:?}", scene), scene_item_id: item_id as i64, }; if let Err(e) = tx.send(obs_event) { @@ -76,6 +78,16 @@ impl OBSEventHandler { break; } } + Event::InputSettingsChanged { id, .. } => { + let input_name = format!("{:?}", id); + let obs_event = OBSEvent::InputSettingsChanged { + input_name, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send InputSettingsChanged event: {}", e); + break; + } + } // Note: SourceFilterSettingsChanged is not available in obws 0.11 // Filter changes will need to be detected through polling or manual triggers // For now, we skip filter change events as they're not properly supported in this obws version diff --git a/src-tauri/src/sync/master.rs b/src-tauri/src/sync/master.rs index 0f90c38..3cac6bd 100644 --- a/src-tauri/src/sync/master.rs +++ b/src-tauri/src/sync/master.rs @@ -81,9 +81,10 @@ impl MasterSync { let client_lock = client_arc.read().await; if let Some(client) = client_lock.as_ref() { + let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(&scene_name_clone); match client .scene_items() - .transform(&scene_name_clone, scene_item_id) + .transform(scene_id, scene_item_id) .await { Ok(transform) => { @@ -144,7 +145,7 @@ impl MasterSync { if !scene_name_clone.is_empty() && scene_item_id > 0 { // scene_name and scene_item_id are already provided // Get scene items to find source name - match client.scene_items().list(&scene_name_clone).await { + match client.scene_items().list(obws::requests::scenes::SceneId::Name(&scene_name_clone)).await { Ok(items) => { if let Some(item) = items.iter().find(|i| { i.id as i64 == scene_item_id @@ -165,14 +166,15 @@ impl MasterSync { Ok(scenes) => { let mut found = None; 'outer: for scene in scenes.scenes { - match client.scene_items().list(&scene.name).await { + let scene_id: obws::requests::scenes::SceneId = scene.id.clone().into(); + match client.scene_items().list(scene_id.clone()).await { Ok(items) => { for item in items { // Check if this source has the filter - match client.filters().list(&item.source_name).await { + match client.filters().list(obws::requests::sources::SourceId::Name(&item.source_name)).await { Ok(filters) => { if filters.iter().any(|f| f.name == filter_name_clone) { - found = Some((scene.name.clone(), item.id as i64, item.source_name.clone())); + found = Some((format!("{:?}", scene.id), item.id as i64, item.source_name.clone())); break 'outer; } } @@ -198,7 +200,7 @@ impl MasterSync { if let (Some(scene), Some(item_id), Some(source)) = (resolved_scene_name, resolved_scene_item_id, source_name) { // Get filter settings - match client.filters().list(&source).await { + match client.filters().list(obws::requests::sources::SourceId::Name(&source)).await { Ok(filters) => { if let Some(filter) = filters.iter().find(|f| f.name == filter_name_clone) { let payload = serde_json::json!({ @@ -249,7 +251,7 @@ impl MasterSync { // Get input settings first to check if it's an image source match client .inputs() - .settings::(&input_name_clone) + .settings::(obws::requests::inputs::InputId::Name(&input_name_clone)) .await { Ok(settings) => { @@ -354,7 +356,7 @@ impl MasterSync { // Get input settings to find the file path match client .inputs() - .settings::(input_name) + .settings::(obws::requests::inputs::InputId::Name(input_name)) .await { Ok(settings) => { @@ -412,9 +414,10 @@ impl MasterSync { // For each scene, get all items for scene in scenes_list.scenes { - println!("Processing scene: {}", scene.name); + let scene_id: obws::requests::scenes::SceneId = scene.id.clone().into(); + println!("Processing scene: {:?}", scene.id); - match client.scene_items().list(&scene.name).await { + match client.scene_items().list(scene_id.clone()).await { Ok(items) => { let mut scene_items_data = Vec::new(); @@ -423,7 +426,7 @@ impl MasterSync { // Get transform for this item let transform = - match client.scene_items().transform(&scene.name, item.id).await { + match client.scene_items().transform(scene_id.clone(), item.id).await { Ok(t) => Some(serde_json::json!({ "position_x": t.position_x, "position_y": t.position_y, @@ -464,7 +467,7 @@ impl MasterSync { // Get filters for this source let mut filters_data = Vec::new(); - match client.filters().list(&item.source_name).await { + match client.filters().list(obws::requests::sources::SourceId::Name(&item.source_name)).await { Ok(filters) => { for filter in filters { filters_data.push(serde_json::json!({ @@ -492,13 +495,15 @@ impl MasterSync { })); } + // Use scene.id for name (SceneId doesn't implement Display) + let scene_name = format!("{:?}", scene.id); scenes_data.push(serde_json::json!({ - "name": scene.name, + "name": scene_name.clone(), "items": scene_items_data, })); } Err(e) => { - eprintln!("Failed to get items for scene {}: {}", scene.name, e); + eprintln!("Failed to get items for scene {:?}: {}", scene.id, e); } } } diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index c35d5af..1245309 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -155,10 +155,15 @@ impl SlaveSync { .await .context("Failed to get current scene")?; + // Convert CurrentProgramScene to SceneId + // CurrentProgramScene has a scene_name field that can be converted to SceneId + let scene_name = format!("{:?}", current_scene); + let scene_id: obws::requests::scenes::SceneId = scene_name.as_str().into(); + // Get sources in current scene let items = client .scene_items() - .list(¤t_scene) + .list(scene_id.clone()) .await .context("Failed to get scene items")?; @@ -166,7 +171,7 @@ impl SlaveSync { for item in items { let transform = client .scene_items() - .transform(¤t_scene, item.id) + .transform(scene_id.clone(), item.id) .await .ok(); @@ -183,7 +188,7 @@ impl SlaveSync { } Ok(serde_json::json!({ - "current_scene": current_scene, + "current_scene": format!("{:?}", current_scene), "sources": sources, })) } else { @@ -404,7 +409,7 @@ impl SlaveSync { if let Err(e) = client .filters() .set_enabled(obws::requests::filters::SetEnabled { - source: source_name, + source: obws::requests::sources::SourceId::Name(source_name), filter: filter_name, enabled: filter_enabled, }) @@ -481,10 +486,13 @@ impl SlaveSync { scene_item_id: i64, transform: &serde_json::Map, ) -> Result<()> { + // Convert scene_name to SceneId + let scene_id: obws::requests::scenes::SceneId = scene_name.into(); + // Get current transform to preserve values not in the update let current_transform = match client .scene_items() - .transform(scene_name, scene_item_id) + .transform(scene_id.clone(), scene_item_id) .await { Ok(t) => t, @@ -535,7 +543,7 @@ impl SlaveSync { // Apply the transform using SetTransform use obws::requests::scene_items::SetTransform; let set_transform = SetTransform { - scene: scene_name, + scene: scene_id, item_id: scene_item_id, transform: new_transform.into(), }; @@ -557,7 +565,7 @@ impl SlaveSync { &self, client: &obws::Client, source_name: &str, - _original_file_path: &str, + original_file_path: &str, image_data: Option<&str>, ) -> Result<()> { if let Some(encoded_data) = image_data { @@ -570,8 +578,16 @@ impl SlaveSync { println!("Decoded {} bytes of image data", decoded_data.len()); - // Detect image format from magic bytes - let file_extension = Self::detect_image_format(&decoded_data); + // Extract file extension from original file path + // Fall back to magic bytes detection if extension cannot be determined + let file_extension = if !original_file_path.is_empty() { + std::path::Path::new(original_file_path) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_else(|| Self::detect_image_format(&decoded_data)) + } else { + Self::detect_image_format(&decoded_data) + }; // Create temp directory for synced images let temp_dir = std::env::temp_dir().join("obs-sync"); @@ -579,13 +595,28 @@ impl SlaveSync { .await .context("Failed to create temp directory")?; - // Generate unique filename - let temp_file_path = temp_dir.join(format!( - "{}_{}.{}", - source_name.replace("/", "_").replace("\\", "_"), - chrono::Utc::now().timestamp_millis(), - file_extension - )); + // Generate unique filename using original file name if available + let temp_file_path = if !original_file_path.is_empty() { + // Extract file name (without path) from original path + let original_file_name = std::path::Path::new(original_file_path) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or(source_name); + + temp_dir.join(format!( + "{}_{}.{}", + original_file_name.replace("/", "_").replace("\\", "_"), + chrono::Utc::now().timestamp_millis(), + file_extension + )) + } else { + temp_dir.join(format!( + "{}_{}.{}", + source_name.replace("/", "_").replace("\\", "_"), + chrono::Utc::now().timestamp_millis(), + file_extension + )) + }; println!("Saving image to: {:?}", temp_file_path); @@ -606,7 +637,7 @@ impl SlaveSync { match client .inputs() .set_settings(obws::requests::inputs::SetSettings { - input: source_name, + input: obws::requests::inputs::InputId::Name(source_name), settings: &settings, overlay: Some(true), }) @@ -665,7 +696,7 @@ impl SlaveSync { client .filters() .set_settings(obws::requests::filters::SetSettings { - source: source_name, + source: obws::requests::sources::SourceId::Name(source_name), filter: filter_name, settings: &settings, overlay: Some(true), diff --git a/src/App.css b/src/App.css index 9525fea..99e3dae 100644 --- a/src/App.css +++ b/src/App.css @@ -20,6 +20,12 @@ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); --master-color: #8b5cf6; --slave-color: #06b6d4; + /* モダンなグラデーション */ + --gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --gradient-master: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + --gradient-slave: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); + --gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%); + --gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); } * { @@ -31,8 +37,53 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-color); + background-image: + radial-gradient(at 0% 0%, rgba(102, 126, 234, 0.1) 0px, transparent 50%), + radial-gradient(at 100% 100%, rgba(118, 75, 162, 0.1) 0px, transparent 50%); + background-attachment: fixed; color: var(--text-primary); line-height: 1.6; + /* デスクトップアプリらしいテキスト選択の制御 */ + -webkit-user-select: none; + user-select: none; + /* テキストの滑らかなレンダリング */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* 入力可能な要素は選択可能にする */ +input, +textarea, +[contenteditable="true"], +.selectable { + -webkit-user-select: text; + user-select: text; +} + +/* 選択不可能な要素(見出し、ラベル、アイコンなど) */ +h1, h2, h3, h4, h5, h6, +label, +.section-icon, +.logo-icon, +.mode-card-icon, +.target-card-icon, +.info-icon, +.status-icon, +.alert-close, +.mode-badge, +.badge, +button:not(input), +[role="button"]:not(input), +.subtitle, +.selection-description, +.mode-card-description, +.target-card-description, +.info-item, +.status-label, +.selector-description { + -webkit-user-select: none; + user-select: none; + cursor: default; } .app { @@ -49,6 +100,10 @@ body { position: sticky; top: 0; z-index: 100; + /* デスクトップアプリらしいガラスモーフィズム効果 */ + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .header-content { @@ -67,12 +122,6 @@ body { .logo-icon { font-size: 2.5rem; - animation: float 3s ease-in-out infinite; -} - -@keyframes float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-10px); } } .app-header h1 { @@ -90,11 +139,19 @@ body { } .mode-badge { - background: rgba(255, 255, 255, 0.2); - backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); padding: 0.75rem 1.5rem; border-radius: 9999px; border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.mode-badge:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); } .badge { @@ -127,10 +184,22 @@ body { font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--gradient-primary); + background-size: 200% 200%; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; + animation: gradientShift 5s ease infinite; + letter-spacing: -0.02em; +} + +@keyframes gradientShift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } } .selection-description { @@ -156,6 +225,8 @@ body { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; + /* デスクトップアプリらしいホバー効果 */ + will-change: transform, box-shadow; } .mode-card::before { @@ -175,11 +246,17 @@ body { } .mode-card:hover { - transform: translateY(-8px); - box-shadow: var(--shadow-xl); + transform: translateY(-8px) scale(1.02); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), + 0 0 0 1px rgba(99, 102, 241, 0.3); border-color: var(--primary-color); } +.mode-card:active { + transform: translateY(-4px) scale(1.01); + transition: all 0.1s ease; +} + .mode-card-master:hover { border-color: var(--master-color); box-shadow: 0 20px 25px -5px rgba(139, 92, 246, 0.3); @@ -228,19 +305,41 @@ body { .btn-mode-select { width: 100%; padding: 0.875rem; - background: var(--primary-color); + background: var(--gradient-primary); + background-size: 200% 200%; color: white; border: none; border-radius: 0.5rem; font-size: 1rem; font-weight: 600; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + position: relative; + overflow: hidden; +} + +.btn-mode-select::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.btn-mode-select:hover::before { + left: 100%; } .btn-mode-select:hover { - background: var(--primary-hover); - transform: scale(1.02); + background: var(--gradient-primary); + background-size: 200% 200%; + animation: gradientShift 3s ease infinite; + transform: translateY(-2px) scale(1.02); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); } /* App Content */ @@ -267,11 +366,32 @@ body { padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow); - transition: all 0.3s ease; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.section::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--gradient-primary); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s ease; +} + +.section:hover::before { + transform: scaleX(1); } .section:hover { - box-shadow: var(--shadow-lg); + box-shadow: var(--shadow-xl); + border-color: rgba(99, 102, 241, 0.3); + transform: translateY(-2px); } .section-header { @@ -380,14 +500,19 @@ input[type="number"] { background: var(--bg-color); color: var(--text-primary); font-size: 1rem; - transition: all 0.2s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); font-family: inherit; + /* デスクトップアプリらしいフォーカスリング */ + -webkit-user-select: text; + user-select: text; } input:focus { outline: none; border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2), + 0 0 0 1px var(--primary-color); + transform: translateY(-1px); } input:disabled { @@ -412,15 +537,34 @@ button { } .btn-primary { - background: var(--primary-color); + background: var(--gradient-primary); color: white; - box-shadow: var(--shadow); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + position: relative; + overflow: hidden; +} + +.btn-primary::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.btn-primary:hover:not(:disabled)::before { + left: 100%; } .btn-primary:hover:not(:disabled) { - background: var(--primary-hover); + background: var(--gradient-primary); + background-size: 200% 200%; + animation: gradientShift 3s ease infinite; transform: translateY(-2px); - box-shadow: var(--shadow-lg); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); } .btn-primary:active:not(:disabled) { @@ -460,6 +604,11 @@ button:disabled { transform: none !important; } +/* デスクトップアプリらしいボタンのアクティブ状態 */ +button:not(:disabled):active { + transform: translateY(0) scale(0.98); +} + /* Spinner */ .spinner { width: 16px; diff --git a/src/App.tsx b/src/App.tsx index 9e3334f..fd0eb73 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,9 +14,11 @@ import { SlaveMonitor } from "./components/SlaveMonitor"; import { SyncTargetSelector } from "./components/SyncTargetSelector"; import { AlertPanel } from "./components/AlertPanel"; import { OBSSourceList } from "./components/OBSSourceList"; +import { SplashScreen } from "./components/SplashScreen"; import { AppMode } from "./types/sync"; function App() { + const [showSplash, setShowSplash] = useState(true); const [appMode, setAppMode] = useState(null); const [obsHost, setObsHost] = useState("localhost"); const [obsPort, setObsPort] = useState(4455); @@ -127,6 +129,9 @@ function App() { return (
+ {showSplash && ( + setShowSplash(false)} /> + )}
-

© 2024 OBS Sync - イベント向けOBS同期システム

+

© 2026 OBS Sync - イベント向けOBS同期システム

); diff --git a/src/components/SplashScreen.css b/src/components/SplashScreen.css new file mode 100644 index 0000000..39cbfeb --- /dev/null +++ b/src/components/SplashScreen.css @@ -0,0 +1,214 @@ +.splash-screen { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + overflow: hidden; + -webkit-user-select: none; + user-select: none; +} + +.splash-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + z-index: 0; +} + +.splash-gradient-1, +.splash-gradient-2, +.splash-gradient-3 { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.6; + animation: float 15s ease-in-out infinite; +} + +.splash-gradient-1 { + width: 600px; + height: 600px; + background: radial-gradient(circle, #667eea 0%, transparent 70%); + top: -300px; + left: -300px; + animation-delay: 0s; +} + +.splash-gradient-2 { + width: 500px; + height: 500px; + background: radial-gradient(circle, #764ba2 0%, transparent 70%); + bottom: -250px; + right: -250px; + animation-delay: 2s; +} + +.splash-gradient-3 { + width: 400px; + height: 400px; + background: radial-gradient(circle, #6366f1 0%, transparent 70%); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation-delay: 4s; +} + +@keyframes float { + 0%, 100% { + transform: translate(0, 0) scale(1); + } + 25% { + transform: translate(30px, -30px) scale(1.1); + } + 50% { + transform: translate(-20px, 20px) scale(0.9); + } + 75% { + transform: translate(20px, 30px) scale(1.05); + } +} + +.splash-content { + position: relative; + z-index: 1; + text-align: center; + animation: fadeInUp 0.8s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.splash-logo { + margin-bottom: 2rem; + animation: logoFloat 3s ease-in-out infinite; +} + +@keyframes logoFloat { + 0%, 100% { + transform: translateY(0) scale(1); + } + 50% { + transform: translateY(-10px) scale(1.05); + } +} + +.splash-icon { + font-size: 6rem; + filter: drop-shadow(0 10px 30px rgba(102, 126, 234, 0.4)); + animation: iconPulse 2s ease-in-out infinite; +} + +@keyframes iconPulse { + 0%, 100% { + filter: drop-shadow(0 10px 30px rgba(102, 126, 234, 0.4)); + } + 50% { + filter: drop-shadow(0 10px 40px rgba(102, 126, 234, 0.6)); + } +} + +.splash-title { + font-size: 3.5rem; + font-weight: 700; + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #6366f1 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 0.5rem 0; + letter-spacing: -0.02em; + animation: titleShine 3s ease-in-out infinite; +} + +@keyframes titleShine { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +.splash-subtitle { + font-size: 1.125rem; + color: #94a3b8; + margin: 0 0 3rem 0; + font-weight: 500; + letter-spacing: 0.05em; +} + +.splash-loader { + width: 200px; + height: 3px; + background: rgba(255, 255, 255, 0.1); + border-radius: 9999px; + overflow: hidden; + margin: 0 auto; +} + +.loader-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #6366f1 100%); + border-radius: 9999px; + animation: loaderProgress 1.5s ease-out forwards; + box-shadow: 0 0 10px rgba(102, 126, 234, 0.5); +} + +@keyframes loaderProgress { + from { + width: 0%; + } + to { + width: 100%; + } +} + +/* フェードアウトアニメーション */ +.splash-fade-out { + animation: fadeOut 0.5s ease-out forwards; +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* レスポンシブ */ +@media (max-width: 768px) { + .splash-icon { + font-size: 4rem; + } + + .splash-title { + font-size: 2.5rem; + } + + .splash-subtitle { + font-size: 1rem; + } + + .splash-loader { + width: 150px; + } +} diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx new file mode 100644 index 0000000..cc5222b --- /dev/null +++ b/src/components/SplashScreen.tsx @@ -0,0 +1,49 @@ +import { useState, useEffect } from "react"; +import "./SplashScreen.css"; + +interface SplashScreenProps { + onComplete: () => void; +} + +export const SplashScreen = ({ onComplete }: SplashScreenProps) => { + const [isVisible, setIsVisible] = useState(true); + const [isAnimating, setIsAnimating] = useState(false); + + useEffect(() => { + // スプラッシュスクリーンを1.5秒表示 + const timer = setTimeout(() => { + setIsAnimating(true); + // フェードアウトアニメーション後に非表示 + setTimeout(() => { + setIsVisible(false); + onComplete(); + }, 500); // フェードアウトの時間 + }, 1500); + + return () => clearTimeout(timer); + }, [onComplete]); + + if (!isVisible) { + return null; + } + + return ( +
+
+
+
🎬
+
+

OBS Sync

+

リアルタイム同期システム

+
+
+
+
+
+
+
+
+
+
+ ); +}; From 52476dbc9feebdac2ab1885e69ff57317d47c91d Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 08:18:25 +0900 Subject: [PATCH 14/20] Fix Rust formatting issues for CI check --- src-tauri/src/commands.rs | 8 +- src-tauri/src/network/client.rs | 1 - src-tauri/src/obs/events.rs | 4 +- src-tauri/src/sync/master.rs | 127 +++++++++++++++++++++----------- src-tauri/src/sync/slave.rs | 11 ++- 5 files changed, 99 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fbbfc04..4d66ae8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -206,10 +206,10 @@ impl PerformanceMonitor { pub async fn record_metric(&self, metric: SyncMetric) { let mut metrics = self.metrics.write().await; - + // Add new metric metrics.push_back(metric); - + // Keep only the last max_metrics entries let max_capacity = metrics.capacity(); while metrics.len() > max_capacity { @@ -491,7 +491,9 @@ pub async fn connect_to_master( latency_ms, message_size_bytes, }; - performance_monitor_for_processing.record_metric(metric).await; + performance_monitor_for_processing + .record_metric(metric) + .await; if let Err(e) = slave_sync_for_processing.apply_sync_message(message).await { eprintln!("Failed to apply sync message: {}", e); diff --git a/src-tauri/src/network/client.rs b/src-tauri/src/network/client.rs index 1a85fd0..062a8fd 100644 --- a/src-tauri/src/network/client.rs +++ b/src-tauri/src/network/client.rs @@ -288,7 +288,6 @@ impl SlaveClient { Ok((rx, send_tx)) } - pub async fn disconnect(&self) { // Stop reconnection attempts self.should_reconnect.store(false, Ordering::SeqCst); diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index 76b3997..414084d 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -80,9 +80,7 @@ impl OBSEventHandler { } Event::InputSettingsChanged { id, .. } => { let input_name = format!("{:?}", id); - let obs_event = OBSEvent::InputSettingsChanged { - input_name, - }; + let obs_event = OBSEvent::InputSettingsChanged { input_name }; if let Err(e) = tx.send(obs_event) { eprintln!("Failed to send InputSettingsChanged event: {}", e); break; diff --git a/src-tauri/src/sync/master.rs b/src-tauri/src/sync/master.rs index 3cac6bd..2ea1399 100644 --- a/src-tauri/src/sync/master.rs +++ b/src-tauri/src/sync/master.rs @@ -81,7 +81,8 @@ impl MasterSync { let client_lock = client_arc.read().await; if let Some(client) = client_lock.as_ref() { - let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(&scene_name_clone); + let scene_id: obws::requests::scenes::SceneId = + obws::requests::scenes::SceneId::Name(&scene_name_clone); match client .scene_items() .transform(scene_id, scene_item_id) @@ -141,22 +142,36 @@ impl MasterSync { let client_lock = client_arc.read().await; if let Some(client) = client_lock.as_ref() { - let (resolved_scene_name, resolved_scene_item_id, source_name) = + let (resolved_scene_name, resolved_scene_item_id, source_name) = if !scene_name_clone.is_empty() && scene_item_id > 0 { // scene_name and scene_item_id are already provided // Get scene items to find source name - match client.scene_items().list(obws::requests::scenes::SceneId::Name(&scene_name_clone)).await { + match client + .scene_items() + .list(obws::requests::scenes::SceneId::Name( + &scene_name_clone, + )) + .await + { Ok(items) => { - if let Some(item) = items.iter().find(|i| { - i.id as i64 == scene_item_id - }) { - (Some(scene_name_clone.clone()), Some(scene_item_id), Some(item.source_name.clone())) + if let Some(item) = items + .iter() + .find(|i| i.id as i64 == scene_item_id) + { + ( + Some(scene_name_clone.clone()), + Some(scene_item_id), + Some(item.source_name.clone()), + ) } else { (None, None, None) } } Err(e) => { - eprintln!("Failed to get scene items for {}: {}", scene_name_clone, e); + eprintln!( + "Failed to get scene items for {}: {}", + scene_name_clone, e + ); (None, None, None) } } @@ -167,11 +182,15 @@ impl MasterSync { let mut found = None; 'outer: for scene in scenes.scenes { let scene_id: obws::requests::scenes::SceneId = scene.id.clone().into(); - match client.scene_items().list(scene_id.clone()).await { + match client + .scene_items() + .list(scene_id.clone()) + .await + { Ok(items) => { for item in items { // Check if this source has the filter - match client.filters().list(obws::requests::sources::SourceId::Name(&item.source_name)).await { + match client.filters().list(obws::requests::sources::SourceId::Name(&item.source_name)).await { Ok(filters) => { if filters.iter().any(|f| f.name == filter_name_clone) { found = Some((format!("{:?}", scene.id), item.id as i64, item.source_name.clone())); @@ -198,11 +217,20 @@ impl MasterSync { } }; - if let (Some(scene), Some(item_id), Some(source)) = (resolved_scene_name, resolved_scene_item_id, source_name) { + if let (Some(scene), Some(item_id), Some(source)) = + (resolved_scene_name, resolved_scene_item_id, source_name) + { // Get filter settings - match client.filters().list(obws::requests::sources::SourceId::Name(&source)).await { + match client + .filters() + .list(obws::requests::sources::SourceId::Name(&source)) + .await + { Ok(filters) => { - if let Some(filter) = filters.iter().find(|f| f.name == filter_name_clone) { + if let Some(filter) = filters + .iter() + .find(|f| f.name == filter_name_clone) + { let payload = serde_json::json!({ "scene_name": scene, "scene_item_id": item_id, @@ -211,22 +239,28 @@ impl MasterSync { "filter_settings": filter.settings }); - let msg = SyncMessage::new( - SyncMessageType::FilterUpdate, - SyncTargetType::Source, - payload, - ); + let msg = SyncMessage::new( + SyncMessageType::FilterUpdate, + SyncTargetType::Source, + payload, + ); let _ = message_tx_clone.send(msg); println!( "Sent filter update for {} on source {} in scene {} (item: {})", filter_name_clone, source, scene, item_id ); } else { - eprintln!("Filter {} not found on source {}", filter_name_clone, source); + eprintln!( + "Filter {} not found on source {}", + filter_name_clone, source + ); } } Err(e) => { - eprintln!("Failed to get filter list for {}: {}", source, e); + eprintln!( + "Failed to get filter list for {}: {}", + source, e + ); } } } else { @@ -251,7 +285,11 @@ impl MasterSync { // Get input settings first to check if it's an image source match client .inputs() - .settings::(obws::requests::inputs::InputId::Name(&input_name_clone)) + .settings::( + obws::requests::inputs::InputId::Name( + &input_name_clone, + ), + ) .await { Ok(settings) => { @@ -425,25 +463,28 @@ impl MasterSync { println!(" - Item: {} (id: {})", item.source_name, item.id); // Get transform for this item - let transform = - match client.scene_items().transform(scene_id.clone(), item.id).await { - Ok(t) => Some(serde_json::json!({ - "position_x": t.position_x, - "position_y": t.position_y, - "rotation": t.rotation, - "scale_x": t.scale_x, - "scale_y": t.scale_y, - "width": t.width, - "height": t.height, - })), - Err(e) => { - eprintln!( - "Failed to get transform for {}: {}", - item.source_name, e - ); - None - } - }; + let transform = match client + .scene_items() + .transform(scene_id.clone(), item.id) + .await + { + Ok(t) => Some(serde_json::json!({ + "position_x": t.position_x, + "position_y": t.position_y, + "rotation": t.rotation, + "scale_x": t.scale_x, + "scale_y": t.scale_y, + "width": t.width, + "height": t.height, + })), + Err(e) => { + eprintln!( + "Failed to get transform for {}: {}", + item.source_name, e + ); + None + } + }; // Get source type from item let source_type = item @@ -467,7 +508,11 @@ impl MasterSync { // Get filters for this source let mut filters_data = Vec::new(); - match client.filters().list(obws::requests::sources::SourceId::Name(&item.source_name)).await { + match client + .filters() + .list(obws::requests::sources::SourceId::Name(&item.source_name)) + .await + { Ok(filters) => { for filter in filters { filters_data.push(serde_json::json!({ diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index 1245309..8ca0b16 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -388,8 +388,11 @@ impl SlaveSync { if let Some(filters) = item["filters"].as_array() { for filter in filters { let filter_name = filter["name"].as_str().unwrap_or(""); - let filter_enabled = filter["enabled"].as_bool().unwrap_or(true); - if let Some(filter_settings) = filter["settings"].as_object() { + let filter_enabled = + filter["enabled"].as_bool().unwrap_or(true); + if let Some(filter_settings) = + filter["settings"].as_object() + { // Apply filter settings if let Err(e) = self .apply_filter_settings( @@ -488,7 +491,7 @@ impl SlaveSync { ) -> Result<()> { // Convert scene_name to SceneId let scene_id: obws::requests::scenes::SceneId = scene_name.into(); - + // Get current transform to preserve values not in the update let current_transform = match client .scene_items() @@ -602,7 +605,7 @@ impl SlaveSync { .file_stem() .and_then(|name| name.to_str()) .unwrap_or(source_name); - + temp_dir.join(format!( "{}_{}.{}", original_file_name.replace("/", "_").replace("\\", "_"), From 1510daf3fba1201755eebe7716279a304cdead69 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 08:24:41 +0900 Subject: [PATCH 15/20] Fix Clippy warnings: remove unnecessary casts and clones --- src-tauri/src/obs/events.rs | 1 + src-tauri/src/sync/master.rs | 52 +++++++++++++++++------------------- src-tauri/src/sync/slave.rs | 10 +++---- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index 414084d..b380576 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -6,6 +6,7 @@ use tokio::sync::mpsc; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "payload")] +#[allow(clippy::enum_variant_names)] pub enum OBSEvent { SceneChanged { scene_name: String, diff --git a/src-tauri/src/sync/master.rs b/src-tauri/src/sync/master.rs index 2ea1399..f0c6b2a 100644 --- a/src-tauri/src/sync/master.rs +++ b/src-tauri/src/sync/master.rs @@ -154,9 +154,8 @@ impl MasterSync { .await { Ok(items) => { - if let Some(item) = items - .iter() - .find(|i| i.id as i64 == scene_item_id) + if let Some(item) = + items.iter().find(|i| i.id == scene_item_id) { ( Some(scene_name_clone.clone()), @@ -184,7 +183,7 @@ impl MasterSync { let scene_id: obws::requests::scenes::SceneId = scene.id.clone().into(); match client .scene_items() - .list(scene_id.clone()) + .list(scene_id) .await { Ok(items) => { @@ -193,7 +192,7 @@ impl MasterSync { match client.filters().list(obws::requests::sources::SourceId::Name(&item.source_name)).await { Ok(filters) => { if filters.iter().any(|f| f.name == filter_name_clone) { - found = Some((format!("{:?}", scene.id), item.id as i64, item.source_name.clone())); + found = Some((format!("{:?}", scene.id), item.id, item.source_name.clone())); break 'outer; } } @@ -455,7 +454,7 @@ impl MasterSync { let scene_id: obws::requests::scenes::SceneId = scene.id.clone().into(); println!("Processing scene: {:?}", scene.id); - match client.scene_items().list(scene_id.clone()).await { + match client.scene_items().list(scene_id).await { Ok(items) => { let mut scene_items_data = Vec::new(); @@ -463,28 +462,25 @@ impl MasterSync { println!(" - Item: {} (id: {})", item.source_name, item.id); // Get transform for this item - let transform = match client - .scene_items() - .transform(scene_id.clone(), item.id) - .await - { - Ok(t) => Some(serde_json::json!({ - "position_x": t.position_x, - "position_y": t.position_y, - "rotation": t.rotation, - "scale_x": t.scale_x, - "scale_y": t.scale_y, - "width": t.width, - "height": t.height, - })), - Err(e) => { - eprintln!( - "Failed to get transform for {}: {}", - item.source_name, e - ); - None - } - }; + let transform = + match client.scene_items().transform(scene_id, item.id).await { + Ok(t) => Some(serde_json::json!({ + "position_x": t.position_x, + "position_y": t.position_y, + "rotation": t.rotation, + "scale_x": t.scale_x, + "scale_y": t.scale_y, + "width": t.width, + "height": t.height, + })), + Err(e) => { + eprintln!( + "Failed to get transform for {}: {}", + item.source_name, e + ); + None + } + }; // Get source type from item let source_type = item diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index 8ca0b16..40d3933 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -163,17 +163,13 @@ impl SlaveSync { // Get sources in current scene let items = client .scene_items() - .list(scene_id.clone()) + .list(scene_id) .await .context("Failed to get scene items")?; let mut sources = Vec::new(); for item in items { - let transform = client - .scene_items() - .transform(scene_id.clone(), item.id) - .await - .ok(); + let transform = client.scene_items().transform(scene_id, item.id).await.ok(); sources.push(serde_json::json!({ "name": item.source_name, @@ -495,7 +491,7 @@ impl SlaveSync { // Get current transform to preserve values not in the update let current_transform = match client .scene_items() - .transform(scene_id.clone(), scene_item_id) + .transform(scene_id, scene_item_id) .await { Ok(t) => t, From 5c2e9a90ed6ec3a4cd1c0e247e4552ebcd90c381 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 08:31:55 +0900 Subject: [PATCH 16/20] allow dead code for unused structs --- src-tauri/src/sync/protocol.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-tauri/src/sync/protocol.rs b/src-tauri/src/sync/protocol.rs index 5a86fd7..11595ef 100644 --- a/src-tauri/src/sync/protocol.rs +++ b/src-tauri/src/sync/protocol.rs @@ -52,6 +52,7 @@ impl SyncMessage { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct TransformUpdatePayload { pub scene_name: String, pub scene_item_id: i64, @@ -59,6 +60,7 @@ pub struct TransformUpdatePayload { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct TransformData { pub position_x: f64, pub position_y: f64, @@ -70,11 +72,13 @@ pub struct TransformData { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct SceneChangePayload { pub scene_name: String, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct ImageUpdatePayload { pub scene_name: String, pub source_name: String, @@ -86,6 +90,7 @@ pub struct ImageUpdatePayload { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct StateSyncPayload { pub current_program_scene: String, pub current_preview_scene: Option, @@ -93,12 +98,14 @@ pub struct StateSyncPayload { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct SceneData { pub name: String, pub items: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct SceneItemData { pub source_name: String, pub source_type: String, From 6a20f684a762858289d84d594b3d6f097ee61671 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 08:41:49 +0900 Subject: [PATCH 17/20] fix: Remove unused PerformanceMetrics import to fix TypeScript errors --- src/components/MasterControl.tsx | 2 +- src/components/SlaveMonitor.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MasterControl.tsx b/src/components/MasterControl.tsx index 89b9bd9..8de7f37 100644 --- a/src/components/MasterControl.tsx +++ b/src/components/MasterControl.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { useNetworkStatus, PerformanceMetrics } from "../hooks/useNetworkStatus"; +import { useNetworkStatus } from "../hooks/useNetworkStatus"; import { ConnectionState } from "../types/network"; import { parseErrorMessage } from "../utils/errorMessages"; diff --git a/src/components/SlaveMonitor.tsx b/src/components/SlaveMonitor.tsx index 16ebfc9..c794bc4 100644 --- a/src/components/SlaveMonitor.tsx +++ b/src/components/SlaveMonitor.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { useNetworkStatus, PerformanceMetrics } from "../hooks/useNetworkStatus"; +import { useNetworkStatus } from "../hooks/useNetworkStatus"; import { ConnectionState } from "../types/network"; import { parseErrorMessage } from "../utils/errorMessages"; From d18d796291c62d3bd56a1d65fa94ef9149432e23 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 09:06:04 +0900 Subject: [PATCH 18/20] =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=91=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E9=80=9A=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/obs/commands.rs | 57 ++++++++ src-tauri/src/obs/events.rs | 48 +++++++ src-tauri/src/sync/master.rs | 238 +++++++++++++++++++++++++++++---- src-tauri/src/sync/protocol.rs | 20 +++ src-tauri/src/sync/slave.rs | 116 +++++++++++++++- 5 files changed, 455 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/obs/commands.rs b/src-tauri/src/obs/commands.rs index cebc6d1..ff38bab 100644 --- a/src-tauri/src/obs/commands.rs +++ b/src-tauri/src/obs/commands.rs @@ -12,4 +12,61 @@ impl OBSCommands { .context("Failed to set current program scene")?; Ok(()) } + + pub async fn create_scene_item( + client: &Client, + scene_name: &str, + source_name: &str, + scene_item_enabled: Option, + ) -> Result { + let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(scene_name); + let source_id: obws::requests::sources::SourceId = obws::requests::sources::SourceId::Name(source_name); + + use obws::requests::scene_items::CreateSceneItem; + let item_id = client + .scene_items() + .create(CreateSceneItem { + scene: scene_id, + source: source_id, + enabled: scene_item_enabled, + }) + .await + .context("Failed to create scene item")?; + + Ok(item_id as i64) + } + + pub async fn remove_scene_item(client: &Client, scene_name: &str, scene_item_id: i64) -> Result<()> { + let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(scene_name); + + client + .scene_items() + .remove(scene_id, scene_item_id) + .await + .context("Failed to remove scene item")?; + + Ok(()) + } + + pub async fn set_scene_item_enabled( + client: &Client, + scene_name: &str, + scene_item_id: i64, + enabled: bool, + ) -> Result<()> { + let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(scene_name); + + use obws::requests::scene_items::SetEnabled; + client + .scene_items() + .set_enabled(SetEnabled { + scene: scene_id, + item_id: scene_item_id, + enabled, + }) + .await + .context("Failed to set scene item enabled state")?; + + Ok(()) + } } diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index b380576..fec64c5 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -26,6 +26,21 @@ pub enum OBSEvent { scene_item_id: i64, filter_name: String, }, + SceneItemCreated { + scene_name: String, + scene_item_id: i64, + source_name: String, + }, + SceneItemRemoved { + scene_name: String, + scene_item_id: i64, + source_name: String, + }, + SceneItemEnableStateChanged { + scene_name: String, + scene_item_id: i64, + enabled: bool, + }, } pub struct OBSEventHandler { @@ -95,6 +110,39 @@ impl OBSEventHandler { // are not directly available as events in obws 0.11 // TODO: Implement filter change detection via polling or upgrade obws version } + Event::SceneItemCreated { scene, item_id, source, .. } => { + let obs_event = OBSEvent::SceneItemCreated { + scene_name: format!("{:?}", scene), + scene_item_id: item_id as i64, + source_name: format!("{:?}", source), + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send SceneItemCreated event: {}", e); + break; + } + } + Event::SceneItemRemoved { scene, item_id, source, .. } => { + let obs_event = OBSEvent::SceneItemRemoved { + scene_name: format!("{:?}", scene), + scene_item_id: item_id as i64, + source_name: format!("{:?}", source), + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send SceneItemRemoved event: {}", e); + break; + } + } + Event::SceneItemEnableStateChanged { scene, item_id, enabled, .. } => { + let obs_event = OBSEvent::SceneItemEnableStateChanged { + scene_name: format!("{:?}", scene), + scene_item_id: item_id as i64, + enabled, + }; + if let Err(e) = tx.send(obs_event) { + eprintln!("Failed to send SceneItemEnableStateChanged event: {}", e); + break; + } + } _ => { // Ignore other events } diff --git a/src-tauri/src/sync/master.rs b/src-tauri/src/sync/master.rs index f0c6b2a..a96fa1e 100644 --- a/src-tauri/src/sync/master.rs +++ b/src-tauri/src/sync/master.rs @@ -1,4 +1,7 @@ -use super::protocol::{SyncMessage, SyncMessageType, SyncTargetType}; +use super::protocol::{ + ImageUpdatePayload, SceneChangePayload, SourceUpdateAction, SourceUpdatePayload, SyncMessage, + SyncMessageType, SyncTargetType, TransformData, TransformUpdatePayload, +}; use crate::obs::{events::OBSEvent, OBSClient}; use anyhow::Result; use std::sync::Arc; @@ -42,26 +45,30 @@ impl MasterSync { match event { OBSEvent::SceneChanged { scene_name } => { if targets.contains(&SyncTargetType::Program) { - let payload = serde_json::json!({ - "scene_name": scene_name - }); + let payload = SceneChangePayload { + scene_name: scene_name.clone(), + }; + let payload_json = serde_json::to_value(&payload) + .unwrap_or_else(|_| serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::SceneChange, SyncTargetType::Program, - payload, + payload_json, ); let _ = message_tx.send(msg); } } OBSEvent::CurrentPreviewSceneChanged { scene_name } => { if targets.contains(&SyncTargetType::Preview) { - let payload = serde_json::json!({ - "scene_name": scene_name - }); + let payload = SceneChangePayload { + scene_name: scene_name.clone(), + }; + let payload_json = serde_json::to_value(&payload) + .unwrap_or_else(|_| serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::SceneChange, SyncTargetType::Preview, - payload, + payload_json, ); let _ = message_tx.send(msg); } @@ -89,24 +96,26 @@ impl MasterSync { .await { Ok(transform) => { - let payload = serde_json::json!({ - "scene_name": scene_name_clone, - "scene_item_id": scene_item_id, - "transform": { - "position_x": transform.position_x, - "position_y": transform.position_y, - "rotation": transform.rotation, - "scale_x": transform.scale_x, - "scale_y": transform.scale_y, - "width": transform.width, - "height": transform.height, - } - }); + let payload = TransformUpdatePayload { + scene_name: scene_name_clone.clone(), + scene_item_id, + transform: TransformData { + position_x: transform.position_x as f64, + position_y: transform.position_y as f64, + rotation: transform.rotation as f64, + scale_x: transform.scale_x as f64, + scale_y: transform.scale_y as f64, + width: transform.width as f64, + height: transform.height as f64, + }, + }; + let payload_json = serde_json::to_value(&payload) + .unwrap_or_else(|_| serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::TransformUpdate, SyncTargetType::Source, - payload, + payload_json, ); let _ = message_tx_clone.send(msg); println!( @@ -358,6 +367,189 @@ impl MasterSync { }); } } + OBSEvent::SceneItemCreated { + scene_name, + scene_item_id, + source_name, + } => { + if targets.contains(&SyncTargetType::Source) { + let obs_client_clone = obs_client.clone(); + let message_tx_clone = message_tx.clone(); + let scene_name_clone = scene_name.clone(); + let source_name_clone = source_name.clone(); + + tokio::spawn(async move { + let client_arc = obs_client_clone.get_client_arc(); + let client_lock = client_arc.read().await; + + if let Some(client) = client_lock.as_ref() { + let scene_id: obws::requests::scenes::SceneId = + obws::requests::scenes::SceneId::Name(&scene_name_clone); + + // Get scene item details + match client + .scene_items() + .list(scene_id) + .await + { + Ok(items) => { + if let Some(item) = items.iter().find(|i| i.id == scene_item_id) { + // Get transform if available + let transform = client + .scene_items() + .transform(scene_id, scene_item_id) + .await + .ok() + .map(|t| super::protocol::TransformData { + position_x: t.position_x as f64, + position_y: t.position_y as f64, + rotation: t.rotation as f64, + scale_x: t.scale_x as f64, + scale_y: t.scale_y as f64, + width: t.width as f64, + height: t.height as f64, + }); + + // Get enabled state separately since SceneItem doesn't have it + let enabled_state = client + .scene_items() + .enabled(scene_id, scene_item_id) + .await + .ok(); + + let source_type = item.input_kind.clone().unwrap_or_default(); + + let payload = SourceUpdatePayload { + scene_name: scene_name_clone.clone(), + scene_item_id, + source_name: source_name_clone.clone(), + action: SourceUpdateAction::Created, + source_type: Some(source_type), + scene_item_enabled: enabled_state, + transform, + }; + + let payload_json = serde_json::to_value(&payload) + .unwrap_or_else(|_| serde_json::Value::Null); + + let msg = SyncMessage::new( + SyncMessageType::SourceUpdate, + SyncTargetType::Source, + payload_json, + ); + let _ = message_tx_clone.send(msg); + println!( + "Sent source created update for item {} in {}", + scene_item_id, scene_name_clone + ); + } + } + Err(e) => { + eprintln!( + "Failed to get scene items for {}: {}", + scene_name_clone, e + ); + } + } + } + }); + } + } + OBSEvent::SceneItemRemoved { + scene_name, + scene_item_id, + source_name, + } => { + if targets.contains(&SyncTargetType::Source) { + let scene_name_clone = scene_name.clone(); + let payload = SourceUpdatePayload { + scene_name, + scene_item_id, + source_name, + action: SourceUpdateAction::Removed, + source_type: None, + scene_item_enabled: None, + transform: None, + }; + + let payload_json = serde_json::to_value(&payload) + .unwrap_or_else(|_| serde_json::Value::Null); + + let msg = SyncMessage::new( + SyncMessageType::SourceUpdate, + SyncTargetType::Source, + payload_json, + ); + let _ = message_tx.send(msg); + println!( + "Sent source removed update for item {} in {}", + scene_item_id, scene_name_clone + ); + } + } + OBSEvent::SceneItemEnableStateChanged { + scene_name, + scene_item_id, + enabled, + } => { + if targets.contains(&SyncTargetType::Source) { + let obs_client_clone = obs_client.clone(); + let message_tx_clone = message_tx.clone(); + let scene_name_clone = scene_name.clone(); + + tokio::spawn(async move { + let client_arc = obs_client_clone.get_client_arc(); + let client_lock = client_arc.read().await; + + if let Some(client) = client_lock.as_ref() { + let scene_id: obws::requests::scenes::SceneId = + obws::requests::scenes::SceneId::Name(&scene_name_clone); + + // Get scene item to find source name + match client + .scene_items() + .list(scene_id) + .await + { + Ok(items) => { + if let Some(item) = items.iter().find(|i| i.id == scene_item_id) { + let scene_name_for_payload = scene_name_clone.clone(); + let payload = SourceUpdatePayload { + scene_name: scene_name_clone, + scene_item_id, + source_name: item.source_name.clone(), + action: SourceUpdateAction::EnabledStateChanged, + source_type: None, + scene_item_enabled: Some(enabled), + transform: None, + }; + + let payload_json = serde_json::to_value(&payload) + .unwrap_or_else(|_| serde_json::Value::Null); + + let msg = SyncMessage::new( + SyncMessageType::SourceUpdate, + SyncTargetType::Source, + payload_json, + ); + let _ = message_tx_clone.send(msg); + println!( + "Sent source enable state changed update for item {} in {}", + scene_item_id, scene_name_for_payload + ); + } + } + Err(e) => { + eprintln!( + "Failed to get scene items for {}: {}", + scene_name_clone, e + ); + } + } + } + }); + } + } } } }); diff --git a/src-tauri/src/sync/protocol.rs b/src-tauri/src/sync/protocol.rs index 11595ef..8848abb 100644 --- a/src-tauri/src/sync/protocol.rs +++ b/src-tauri/src/sync/protocol.rs @@ -112,3 +112,23 @@ pub struct SceneItemData { /// Base64 encoded image data for image sources pub image_data: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceUpdateAction { + Created, + Removed, + EnabledStateChanged, + SettingsChanged, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceUpdatePayload { + pub scene_name: String, + pub scene_item_id: i64, + pub source_name: String, + pub action: SourceUpdateAction, + pub source_type: Option, + pub scene_item_enabled: Option, + pub transform: Option, +} diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index 40d3933..4b3c809 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -1,5 +1,5 @@ use super::diff::{DiffDetector, DiffSeverity}; -use super::protocol::{SyncMessage, SyncMessageType, SyncTargetType}; +use super::protocol::{SourceUpdateAction, SourceUpdatePayload, SyncMessage, SyncMessageType, SyncTargetType}; use crate::obs::{commands::OBSCommands, OBSClient}; use anyhow::{Context, Result}; use std::sync::Arc; @@ -316,6 +316,120 @@ impl SlaveSync { eprintln!("Filter settings missing in payload"); } } + SyncMessageType::SourceUpdate => { + // Parse SourceUpdatePayload from JSON + let payload: SourceUpdatePayload = serde_json::from_value(message.payload.clone()) + .context("Failed to parse SourceUpdatePayload")?; + + match payload.action { + SourceUpdateAction::Created => { + // Create scene item + match OBSCommands::create_scene_item( + client, + &payload.scene_name, + &payload.source_name, + payload.scene_item_enabled, + ) + .await + { + Ok(new_item_id) => { + println!( + "Created scene item {} (id: {}) in scene {}", + payload.source_name, new_item_id, payload.scene_name + ); + + // Apply transform if provided + if let Some(transform) = payload.transform { + let transform_map = serde_json::json!({ + "position_x": transform.position_x, + "position_y": transform.position_y, + "rotation": transform.rotation, + "scale_x": transform.scale_x, + "scale_y": transform.scale_y, + "width": transform.width, + "height": transform.height, + }); + + if let Some(transform_obj) = transform_map.as_object() { + if let Err(e) = self + .apply_transform(client, &payload.scene_name, new_item_id, transform_obj) + .await + { + eprintln!( + "Failed to apply transform for newly created item {}: {}", + new_item_id, e + ); + } + } + } + } + Err(e) => { + self.send_alert( + payload.scene_name.clone(), + payload.source_name.clone(), + format!("Failed to create scene item: {}", e), + AlertSeverity::Warning, + )?; + } + } + } + SourceUpdateAction::Removed => { + // Remove scene item + if let Err(e) = OBSCommands::remove_scene_item( + client, + &payload.scene_name, + payload.scene_item_id, + ) + .await + { + self.send_alert( + payload.scene_name.clone(), + payload.source_name.clone(), + format!("Failed to remove scene item: {}", e), + AlertSeverity::Warning, + )?; + } else { + println!( + "Removed scene item {} (id: {}) from scene {}", + payload.source_name, payload.scene_item_id, payload.scene_name + ); + } + } + SourceUpdateAction::EnabledStateChanged => { + // Update enabled state + if let Some(enabled) = payload.scene_item_enabled { + if let Err(e) = OBSCommands::set_scene_item_enabled( + client, + &payload.scene_name, + payload.scene_item_id, + enabled, + ) + .await + { + self.send_alert( + payload.scene_name.clone(), + payload.source_name.clone(), + format!("Failed to set scene item enabled state: {}", e), + AlertSeverity::Warning, + )?; + } else { + println!( + "Set scene item {} (id: {}) enabled state to {} in scene {}", + payload.source_name, payload.scene_item_id, enabled, payload.scene_name + ); + } + } + } + SourceUpdateAction::SettingsChanged => { + // Settings changed - similar to InputSettingsChanged, this might be handled elsewhere + // For now, just log it + println!( + "Received settings changed for scene item {} (id: {}) in scene {}", + payload.source_name, payload.scene_item_id, payload.scene_name + ); + } + } + } SyncMessageType::Heartbeat => { // Just acknowledge heartbeat } From 32207e1ed8880aad71a6f787087ee247b1ba4d21 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 09:21:41 +0900 Subject: [PATCH 19/20] =?UTF-8?q?=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 57 +-------- SYNC_DETAILS.md | 302 --------------------------------------------- index.html | 2 +- technologystack.md | 54 -------- 4 files changed, 4 insertions(+), 411 deletions(-) delete mode 100644 SYNC_DETAILS.md delete mode 100644 technologystack.md diff --git a/README.md b/README.md index bac0474..7fefeff 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # OBS Sync -LAN内の複数のOBS Studioを同期するシステム(イベント向け) +中規模~大規模イベント運営者向け +LAN内の複数のOBS Studioを同期するシステム ## 概要 -OBS Syncは、LAN内の複数のOBS Studio間で、画像ソース、シーン構成、ポジション情報などをリアルタイムで同期するためのデスクトップアプリケーションです。イベント制作現場での複数OBSのクライアントチェックや、複数配信環境の統一的な管理を想定しています。 +OBS Syncは、LAN内の複数のOBS Studio間で、画像ソース、シーン構成、ポジション情報などをリアルタイムで同期するためのデスクトップアプリケーションです。イベント制作現場での複数OBSのクライアントチェックや、複数配信環境の統一的な管理を想定しています。 ## 主要機能 @@ -31,58 +32,6 @@ Slaveモードでは、受信した変更とローカルのOBS状態に差異が - [最新リリース](https://github.com/FlowingSPDG/obs-sync/releases/latest) - [全リリース](https://github.com/FlowingSPDG/obs-sync/releases) -## 必要要件 - -- Node.js LTS版 -- Rust 1.70以降 -- OBS Studio 28.x以降(OBS WebSocket v5.x対応版) - -## セットアップ - -### 1. リポジトリのクローン -```bash -git clone https://github.com/FlowingSPDG/obs-sync.git -cd obs-sync -``` - -### 2. 依存関係のインストール -```bash -npm install -``` - -### 3. OBS Studio側の設定 -1. OBS Studioを起動 -2. 「ツール」→「WebSocketサーバー設定」を開く -3. WebSocketサーバーを有効化 -4. ポート番号とパスワード(オプション)を設定 - -## 開発 - -### 開発サーバーの起動 - -#### 通常起動(1インスタンス) -```bash -npm run tauri dev -``` - -#### マルチインスタンス起動(Master/Slaveテスト用) -2つのターミナルを開いて、それぞれで以下を実行: - -**ターミナル1(Master用):** -```bash -npm run tauri:master -``` - -**ターミナル2(Slave用):** -```bash -npm run tauri:slave -``` - -### ビルド -```bash -npm run tauri build -``` - ## 使い方 ### Masterモードでの起動 diff --git a/SYNC_DETAILS.md b/SYNC_DETAILS.md deleted file mode 100644 index 0aa7808..0000000 --- a/SYNC_DETAILS.md +++ /dev/null @@ -1,302 +0,0 @@ -# OBS Sync - 同期機能詳細仕様 - -## 同期アーキテクチャ概要 - -OBS SyncはMaster-Slaveアーキテクチャで、MasterのOBS Studioで発生した変更を、接続中のすべてのSlaveノードにリアルタイムで同期します。 - -## 同期される項目とタイミング - -### 1. リアルタイム同期(OBSイベントベース) - -以下の変更は、OBSイベント発生時に即座に同期されます。 - -#### 1.1 シーン変更(SceneChange) - -**トリガー**: OBS WebSocketイベント -- `CurrentProgramSceneChanged` - プログラムシーン変更時 -- `CurrentPreviewSceneChanged` - プレビューシーン変更時(Studio Mode) - -**同期タイミング**: リアルタイム(ユーザーがシーンを切り替えた瞬間) - -**同期対象タイプ**: -- `Program` - プログラムシーン(通常配信) -- `Preview` - プレビューシーン(Studio Mode) - -**同期内容**: -- `scene_name`: 変更されたシーン名 - -**Slave側の処理**: -- `set_current_program_scene()` または `set_current_preview_scene()` を呼び出し - -**制限事項**: -- Previewシーンの同期にはStudio Modeが有効である必要があります - ---- - -#### 1.2 シーンアイテムのTransform変更(TransformUpdate) - -**トリガー**: OBS WebSocketイベント -- `SceneItemTransformChanged` - シーンアイテムの位置・サイズ・回転が変更された時 - -**同期タイミング**: リアルタイム(ドラッグ&ドロップや数値入力でTransformを変更した瞬間) - -**同期対象タイプ**: `Source` - -**同期内容**: -```json -{ - "scene_name": "シーン名", - "scene_item_id": 1, - "transform": { - "position_x": 100.0, - "position_y": 200.0, - "rotation": 0.0, - "scale_x": 1.0, - "scale_y": 1.0, - "width": 1920.0, - "height": 1080.0 - } -} -``` - -**Slave側の処理**: -- 現在のTransformを取得 -- 受信した値で更新(部分更新対応) -- `set_transform()` を呼び出し - -**注意点**: -- Transform変更時、Master側でOBSから最新のTransform値を取得してから送信します(非同期処理) - ---- - -#### 1.3 フィルター設定変更(FilterUpdate) - -**トリガー**: OBS WebSocketイベント -- `SourceFilterSettingsChanged` - ソースのフィルター設定が変更された時 - -**同期タイミング**: リアルタイム(フィルタープロパティを変更した瞬間) - -**同期対象タイプ**: `Source` - -**同期内容**: -```json -{ - "scene_name": "シーン名", - "scene_item_id": 1, - "source_name": "画像ソース名", - "filter_name": "フィルター名", - "filter_settings": { - // フィルター固有の設定値(JSONオブジェクト) - } -} -``` - -**Slave側の処理**: -- `set_settings()` を呼び出してフィルター設定を更新 - -**制限事項**: -- `SourceFilterSettingsChanged`イベントは`source_name`のみを提供するため、`master.rs`で全シーンを検索して`scene_name`と`scene_item_id`を解決します -- シーン検索に失敗した場合、フィルター更新が送信されない可能性があります - ---- - -#### 1.4 画像ソース変更(ImageUpdate) - -**トリガー**: OBS WebSocketイベント -- `InputSettingsChanged` - 入力ソースの設定が変更された時 - -**同期タイミング**: リアルタイム(画像ファイルを変更した瞬間) - -**同期対象タイプ**: `Source` - -**同期条件**: -- ソースタイプが `image_*` で始まる場合のみ(例: `image_source`, `image_source_v3`) - -**同期内容**: -```json -{ - "scene_name": "", - "source_name": "画像ソース名", - "file": "/path/to/image.png", - "image_data": "base64エンコードされた画像データ" -} -``` - -**Slave側の処理**: -1. Base64デコードして画像データを復元 -2. 画像フォーマットを自動検出(PNG, JPEG, GIF, BMP, WebP) -3. 一時ファイルに保存(`%TEMP%/obs-sync/`) -4. OBSの入力設定でファイルパスを更新 - -**注意点**: -- 画像ファイル全体がBase64エンコードされて送信されるため、大きな画像の場合、ネットワーク負荷が高くなります -- 一時ファイルはOSの一時ディレクトリに保存されます - ---- - -### 2. 初期状態同期(StateSync) - -接続時や再同期時に、MasterのOBS全体の状態を同期します。 - -#### 2.1 トリガー条件 - -**自動トリガー**: -- SlaveがMasterに接続した時(接続確立後500ms遅延) - -**手動トリガー**: -- Master側で「全Slaveに再同期」ボタンをクリック -- Master側で特定のSlaveに対して再同期を実行 -- Slave側で「Masterに再同期をリクエスト」を実行 - -#### 2.2 同期内容 - -```json -{ - "current_program_scene": "現在のプログラムシーン名", - "current_preview_scene": "現在のプレビューシーン名(Studio Mode時のみ)", - "scenes": [ - { - "name": "シーン名", - "items": [ - { - "source_name": "ソース名", - "scene_item_id": 1, - "source_type": "image_source", - "transform": { - "position_x": 100.0, - "position_y": 200.0, - "rotation": 0.0, - "scale_x": 1.0, - "scale_y": 1.0, - "width": 1920.0, - "height": 1080.0 - }, - "image_data": { - "file": "/path/to/image.png", - "data": "base64エンコードされた画像データ(画像ソースの場合のみ)" - }, - "filters": [ - { - "name": "フィルター名", - "enabled": true, - "settings": { - // フィルター設定値 - } - } - ] - } - ] - } - ] -} -``` - -**Slave側の処理順序**: -1. 全シーンのアイテムを順次処理 -2. 各アイテムのTransformを適用 -3. 画像ソースの場合は画像データを適用 -4. 各フィルターの設定と有効/無効状態を適用 -5. 最後に現在のプログラムシーンとプレビューシーンを設定 - -**注意点**: -- 初期状態同期は大量のデータを送信するため、ネットワークが遅い場合、完了に時間がかかる可能性があります -- 画像ソースが多い場合、Base64エンコードされたデータのサイズが大きくなります - ---- - -## 同期対象の選択 - -フロントエンドの「同期設定」で、以下の同期対象を個別に選択できます: - -- **Source**: ソース関連の同期 - - Transform変更 - - フィルター設定変更 - - 画像ソース変更 - -- **Program**: プログラムシーン変更 - -- **Preview**: プレビューシーン変更(Studio Mode) - -**デフォルト設定**: `Program` と `Source` が有効 - ---- - -## 非同期検出機能 - -Slave側では、定期的に(デフォルト5秒間隔)ローカルのOBS状態をチェックし、Masterから受信した期待状態と比較します。 - -### 検出される不一致 - -1. **シーンミスマッチ(Critical)** - - 現在のシーンが期待されるシーンと異なる - -2. **ソース欠落(Warning)** - - 期待されるシーンにソースが存在しない - -3. **Transform不一致(Warning)** - - 位置、スケールが期待値と異なる(許容誤差: 0.5ピクセル) - -検出された不一致は、フロントエンドのアラートパネルに表示されます。 - ---- - -## 同期されない項目 - -以下の項目は現在、同期の対象外です: - -- **シーンの追加・削除・名前変更** -- **シーンアイテムの追加・削除** -- **ソースの作成・削除** -- **ソースの基本プロパティ(サイズ、名前など)** -- **シーンコレクションの変更** -- **オーディオ設定** -- **ビデオ設定** -- **出力設定(ストリーミング/録画)** -- **スクリプトやプラグインの設定** -- **Studio Modeの有効/無効状態** - ---- - -## パフォーマンス考慮事項 - -### メッセージ送信頻度 - -- **シーン変更**: 低頻度(ユーザー操作に依存) -- **Transform変更**: 高頻度(ドラッグ中は連続的に送信される可能性) -- **フィルター変更**: 低頻度(プロパティ変更時のみ) -- **画像変更**: 低頻度(ファイル変更時のみ) - -### ネットワーク負荷 - -- **Transform/Filter/SceneChange**: 軽量(JSON数KB) -- **ImageUpdate**: 重量(Base64エンコードされた画像データ) -- **StateSync**: 非常に重量(全シーン・全アイテム・全画像の一括送信) - -### 推奨事項 - -- 画像ソースは適切なサイズに最適化してください -- 多くのSlaveが接続している場合、Transform変更の連続送信に注意してください -- 初期同期は、ネットワークが安定している状態で実行してください - ---- - -## エラー処理 - -### メッセージ送信失敗 - -Master側でメッセージの送信に失敗した場合: -- エラーログに記録されますが、処理は継続します -- 再接続は自動的に試行されます(Slave側) - -### メッセージ受信・適用失敗 - -Slave側でメッセージの受信や適用に失敗した場合: -- エラーログに記録されます -- アラートがフロントエンドに表示されます -- 処理は継続され、次のメッセージを受信可能です - -### 再接続 - -Slave側で接続が切断された場合: -- 自動的に再接続を試行します(最大10回) -- 指数バックオフ(1秒、2秒、4秒...最大30秒)で再試行します diff --git a/index.html b/index.html index 2dc612a..812b787 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Tauri + React + Typescript + OBS Sync diff --git a/technologystack.md b/technologystack.md deleted file mode 100644 index c7894bc..0000000 --- a/technologystack.md +++ /dev/null @@ -1,54 +0,0 @@ -# 技術スタック - -## フロントエンド -- **フレームワーク**: React 19.1.0 (TypeScript) -- **ビルドツール**: Vite 7.0.4 -- **UI**: CSS Modules / Styled Components -- **通知**: react-toastify 10.x -- **OBS通信**: obs-websocket-js 5.x - -## バックエンド (Rust) -- **フレームワーク**: Tauri v2.x -- **非同期ランタイム**: tokio 1.x (full features) -- **WebSocket**: tokio-tungstenite 0.21.x -- **シリアライゼーション**: serde 1.x, serde_json 1.x -- **非同期処理**: futures 0.3.x - -## プロトコル -- **OBS WebSocket**: v5.x (OBS Studio 28.x以降) -- **Master-Slave通信**: カスタムJSON over WebSocket - -## 開発ツール -- **TypeScript**: ~5.8.3 -- **Node.js**: 推奨 LTS版 -- **Rust**: 1.70以降 -- **パッケージマネージャー**: npm - -## 対応プラットフォーム -- Windows -- macOS -- Linux - -## アーキテクチャ -### Master モード -- OBSの変更を監視 -- 変更をSlaveノードにブロードキャスト -- WebSocketサーバーとして動作 - -### Slave モード -- Masterからの変更を受信 -- 受信した変更をローカルOBSに適用 -- 非同期検出とアラート機能 -- WebSocketクライアントとして動作 - -## 同期対象 -- ソース(Source) -- プレビュー(Preview) -- プログラム(Program/Live Output) - -## 主要機能 -1. リアルタイム画像同期(内容、サイズ、位置) -2. Master-Slaveアーキテクチャ -3. 非同期アラート -4. 同期対象選択機能 -5. LAN内自動検出(オプション) From fd5fb9abc2ef5e92b09d99a451014690a446c224 Mon Sep 17 00:00:00 2001 From: Shugo Kawamura Date: Mon, 19 Jan 2026 09:25:49 +0900 Subject: [PATCH 20/20] Fix clippy warnings and formatting issues - Remove unused import ImageUpdatePayload - Replace unwrap_or_else with unwrap_or for constant fallback values - Apply cargo fmt formatting fixes --- src-tauri/src/obs/commands.rs | 18 +++++++++---- src-tauri/src/obs/events.rs | 21 ++++++++++++--- src-tauri/src/sync/master.rs | 48 +++++++++++++++++------------------ src-tauri/src/sync/slave.rs | 16 +++++++++--- 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/obs/commands.rs b/src-tauri/src/obs/commands.rs index ff38bab..8907903 100644 --- a/src-tauri/src/obs/commands.rs +++ b/src-tauri/src/obs/commands.rs @@ -19,8 +19,10 @@ impl OBSCommands { source_name: &str, scene_item_enabled: Option, ) -> Result { - let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(scene_name); - let source_id: obws::requests::sources::SourceId = obws::requests::sources::SourceId::Name(source_name); + let scene_id: obws::requests::scenes::SceneId = + obws::requests::scenes::SceneId::Name(scene_name); + let source_id: obws::requests::sources::SourceId = + obws::requests::sources::SourceId::Name(source_name); use obws::requests::scene_items::CreateSceneItem; let item_id = client @@ -36,8 +38,13 @@ impl OBSCommands { Ok(item_id as i64) } - pub async fn remove_scene_item(client: &Client, scene_name: &str, scene_item_id: i64) -> Result<()> { - let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(scene_name); + pub async fn remove_scene_item( + client: &Client, + scene_name: &str, + scene_item_id: i64, + ) -> Result<()> { + let scene_id: obws::requests::scenes::SceneId = + obws::requests::scenes::SceneId::Name(scene_name); client .scene_items() @@ -54,7 +61,8 @@ impl OBSCommands { scene_item_id: i64, enabled: bool, ) -> Result<()> { - let scene_id: obws::requests::scenes::SceneId = obws::requests::scenes::SceneId::Name(scene_name); + let scene_id: obws::requests::scenes::SceneId = + obws::requests::scenes::SceneId::Name(scene_name); use obws::requests::scene_items::SetEnabled; client diff --git a/src-tauri/src/obs/events.rs b/src-tauri/src/obs/events.rs index fec64c5..f4de8bb 100644 --- a/src-tauri/src/obs/events.rs +++ b/src-tauri/src/obs/events.rs @@ -110,7 +110,12 @@ impl OBSEventHandler { // are not directly available as events in obws 0.11 // TODO: Implement filter change detection via polling or upgrade obws version } - Event::SceneItemCreated { scene, item_id, source, .. } => { + Event::SceneItemCreated { + scene, + item_id, + source, + .. + } => { let obs_event = OBSEvent::SceneItemCreated { scene_name: format!("{:?}", scene), scene_item_id: item_id as i64, @@ -121,7 +126,12 @@ impl OBSEventHandler { break; } } - Event::SceneItemRemoved { scene, item_id, source, .. } => { + Event::SceneItemRemoved { + scene, + item_id, + source, + .. + } => { let obs_event = OBSEvent::SceneItemRemoved { scene_name: format!("{:?}", scene), scene_item_id: item_id as i64, @@ -132,7 +142,12 @@ impl OBSEventHandler { break; } } - Event::SceneItemEnableStateChanged { scene, item_id, enabled, .. } => { + Event::SceneItemEnableStateChanged { + scene, + item_id, + enabled, + .. + } => { let obs_event = OBSEvent::SceneItemEnableStateChanged { scene_name: format!("{:?}", scene), scene_item_id: item_id as i64, diff --git a/src-tauri/src/sync/master.rs b/src-tauri/src/sync/master.rs index a96fa1e..78ad5f5 100644 --- a/src-tauri/src/sync/master.rs +++ b/src-tauri/src/sync/master.rs @@ -1,6 +1,6 @@ use super::protocol::{ - ImageUpdatePayload, SceneChangePayload, SourceUpdateAction, SourceUpdatePayload, SyncMessage, - SyncMessageType, SyncTargetType, TransformData, TransformUpdatePayload, + SceneChangePayload, SourceUpdateAction, SourceUpdatePayload, SyncMessage, SyncMessageType, + SyncTargetType, TransformData, TransformUpdatePayload, }; use crate::obs::{events::OBSEvent, OBSClient}; use anyhow::Result; @@ -48,8 +48,8 @@ impl MasterSync { let payload = SceneChangePayload { scene_name: scene_name.clone(), }; - let payload_json = serde_json::to_value(&payload) - .unwrap_or_else(|_| serde_json::Value::Null); + let payload_json = + serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::SceneChange, SyncTargetType::Program, @@ -63,8 +63,8 @@ impl MasterSync { let payload = SceneChangePayload { scene_name: scene_name.clone(), }; - let payload_json = serde_json::to_value(&payload) - .unwrap_or_else(|_| serde_json::Value::Null); + let payload_json = + serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::SceneChange, SyncTargetType::Preview, @@ -110,7 +110,7 @@ impl MasterSync { }, }; let payload_json = serde_json::to_value(&payload) - .unwrap_or_else(|_| serde_json::Value::Null); + .unwrap_or(serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::TransformUpdate, @@ -387,13 +387,11 @@ impl MasterSync { obws::requests::scenes::SceneId::Name(&scene_name_clone); // Get scene item details - match client - .scene_items() - .list(scene_id) - .await - { + match client.scene_items().list(scene_id).await { Ok(items) => { - if let Some(item) = items.iter().find(|i| i.id == scene_item_id) { + if let Some(item) = + items.iter().find(|i| i.id == scene_item_id) + { // Get transform if available let transform = client .scene_items() @@ -417,7 +415,8 @@ impl MasterSync { .await .ok(); - let source_type = item.input_kind.clone().unwrap_or_default(); + let source_type = + item.input_kind.clone().unwrap_or_default(); let payload = SourceUpdatePayload { scene_name: scene_name_clone.clone(), @@ -430,7 +429,7 @@ impl MasterSync { }; let payload_json = serde_json::to_value(&payload) - .unwrap_or_else(|_| serde_json::Value::Null); + .unwrap_or(serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::SourceUpdate, @@ -472,8 +471,8 @@ impl MasterSync { transform: None, }; - let payload_json = serde_json::to_value(&payload) - .unwrap_or_else(|_| serde_json::Value::Null); + let payload_json = + serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::SourceUpdate, @@ -506,14 +505,13 @@ impl MasterSync { obws::requests::scenes::SceneId::Name(&scene_name_clone); // Get scene item to find source name - match client - .scene_items() - .list(scene_id) - .await - { + match client.scene_items().list(scene_id).await { Ok(items) => { - if let Some(item) = items.iter().find(|i| i.id == scene_item_id) { - let scene_name_for_payload = scene_name_clone.clone(); + if let Some(item) = + items.iter().find(|i| i.id == scene_item_id) + { + let scene_name_for_payload = + scene_name_clone.clone(); let payload = SourceUpdatePayload { scene_name: scene_name_clone, scene_item_id, @@ -525,7 +523,7 @@ impl MasterSync { }; let payload_json = serde_json::to_value(&payload) - .unwrap_or_else(|_| serde_json::Value::Null); + .unwrap_or(serde_json::Value::Null); let msg = SyncMessage::new( SyncMessageType::SourceUpdate, diff --git a/src-tauri/src/sync/slave.rs b/src-tauri/src/sync/slave.rs index 4b3c809..c9d9c39 100644 --- a/src-tauri/src/sync/slave.rs +++ b/src-tauri/src/sync/slave.rs @@ -1,5 +1,7 @@ use super::diff::{DiffDetector, DiffSeverity}; -use super::protocol::{SourceUpdateAction, SourceUpdatePayload, SyncMessage, SyncMessageType, SyncTargetType}; +use super::protocol::{ + SourceUpdateAction, SourceUpdatePayload, SyncMessage, SyncMessageType, SyncTargetType, +}; use crate::obs::{commands::OBSCommands, OBSClient}; use anyhow::{Context, Result}; use std::sync::Arc; @@ -352,7 +354,12 @@ impl SlaveSync { if let Some(transform_obj) = transform_map.as_object() { if let Err(e) = self - .apply_transform(client, &payload.scene_name, new_item_id, transform_obj) + .apply_transform( + client, + &payload.scene_name, + new_item_id, + transform_obj, + ) .await { eprintln!( @@ -415,7 +422,10 @@ impl SlaveSync { } else { println!( "Set scene item {} (id: {}) enabled state to {} in scene {}", - payload.source_name, payload.scene_item_id, enabled, payload.scene_name + payload.source_name, + payload.scene_item_id, + enabled, + payload.scene_name ); } }