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
+
+
+
+
+ )}
+
+ {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
+
+ {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)} />
+ )}
);
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
);
}
}