Compare commits
4 Commits
93ad9ae46c
...
a10fae75d2
Author | SHA1 | Date | |
---|---|---|---|
a10fae75d2 | |||
5f12b8312a | |||
1944c88b55 | |||
c8db28168c |
16
debug_displays.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use display_info;
|
||||
|
||||
fn main() {
|
||||
match display_info::DisplayInfo::all() {
|
||||
Ok(displays) => {
|
||||
println!("Found {} displays:", displays.len());
|
||||
for (index, display) in displays.iter().enumerate() {
|
||||
println!(" Display {}: ID={}, Scale={}, Width={}, Height={}",
|
||||
index, display.id, display.scale_factor, display.width, display.height);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error getting display info: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
@@ -21,10 +21,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.6.2",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^24.0.7",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"daisyui": "^5.0.43",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^4.9.5",
|
||||
|
23
pnpm-lock.yaml
generated
@@ -33,6 +33,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(vite@6.3.5(@types/node@24.0.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0))
|
||||
'@tauri-apps/cli':
|
||||
specifier: ^2.6.2
|
||||
version: 2.6.2
|
||||
@@ -45,6 +48,9 @@ importers:
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.6)
|
||||
daisyui:
|
||||
specifier: ^5.0.43
|
||||
version: 5.0.43
|
||||
postcss:
|
||||
specifier: ^8.5.6
|
||||
version: 8.5.6
|
||||
@@ -511,6 +517,11 @@ packages:
|
||||
'@tailwindcss/postcss@4.1.11':
|
||||
resolution: {integrity: sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==}
|
||||
|
||||
'@tailwindcss/vite@4.1.11':
|
||||
resolution: {integrity: sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==}
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tauri-apps/api@2.6.0':
|
||||
resolution: {integrity: sha512-hRNcdercfgpzgFrMXWwNDBN0B7vNzOzRepy6ZAmhxi5mDLVPNrTpo9MGg2tN/F7JRugj4d2aF7E1rtPXAHaetg==}
|
||||
|
||||
@@ -644,6 +655,9 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
daisyui@5.0.43:
|
||||
resolution: {integrity: sha512-2pshHJ73vetSpsbAyaOncGnNYL0mwvgseS1EWy1I9Qpw8D11OuBoDNIWrPIME4UFcq2xuff3A9x+eXbuFR9fUQ==}
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1344,6 +1358,13 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
tailwindcss: 4.1.11
|
||||
|
||||
'@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@24.0.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.11
|
||||
'@tailwindcss/oxide': 4.1.11
|
||||
tailwindcss: 4.1.11
|
||||
vite: 6.3.5(@types/node@24.0.7)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)
|
||||
|
||||
'@tauri-apps/api@2.6.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.6.2':
|
||||
@@ -1466,6 +1487,8 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
daisyui@5.0.43: {}
|
||||
|
||||
debug@4.4.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
84
src-tauri/Cargo.lock
generated
@@ -730,6 +730,12 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "ddc"
|
||||
version = "0.2.2"
|
||||
@@ -1645,6 +1651,17 @@ 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.3.1"
|
||||
@@ -1663,7 +1680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1674,7 +1691,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1700,7 +1717,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"itoa",
|
||||
@@ -1721,7 +1738,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
@@ -3452,7 +3469,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -3777,6 +3794,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4116,7 +4144,7 @@ dependencies = [
|
||||
"glob",
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4261,7 +4289,7 @@ dependencies = [
|
||||
"cookie",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"jni",
|
||||
"objc2 0.6.1",
|
||||
"objc2-ui-kit",
|
||||
@@ -4281,7 +4309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "902b5aa9035e16f342eb64f8bf06ccdc2808e411a2525ed1d07672fa4e780bad"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"jni",
|
||||
"log",
|
||||
"objc2 0.6.1",
|
||||
@@ -4314,7 +4342,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"html5ever",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"infer",
|
||||
"json-patch",
|
||||
"kuchikiki",
|
||||
@@ -4384,6 +4412,7 @@ dependencies = [
|
||||
"display-info",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"image",
|
||||
"itertools 0.10.5",
|
||||
@@ -4395,12 +4424,14 @@ dependencies = [
|
||||
"screen-capture-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-shell",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.7.8",
|
||||
"url-build-parse",
|
||||
]
|
||||
@@ -4537,6 +4568,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.15"
|
||||
@@ -4651,7 +4694,7 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
@@ -4719,6 +4762,25 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 0.2.12",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -5647,7 +5709,7 @@ dependencies = [
|
||||
"gdkx11",
|
||||
"gtk",
|
||||
"html5ever",
|
||||
"http",
|
||||
"http 1.3.1",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"kuchikiki",
|
||||
|
@@ -42,6 +42,9 @@ ddc-hi = "0.4.1"
|
||||
coreaudio-rs = "0.11.2"
|
||||
screen-capture-kit = "0.3.1"
|
||||
image = { version = "0.24", features = ["jpeg"] }
|
||||
tokio-tungstenite = "0.20"
|
||||
futures-util = "0.3"
|
||||
sha1 = "0.10"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
|
@@ -5,6 +5,8 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open"
|
||||
"shell:allow-open",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen"
|
||||
]
|
||||
}
|
||||
|
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main application window","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}
|
||||
{"default":{"identifier":"default","description":"Capability for the main application window","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"]}}
|
3
src-tauri/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
26
src-tauri/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "app"
|
||||
edition = "2021"
|
||||
rust-version = "1.60"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.5.6" }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.8.2" }
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
|
||||
# DO NOT REMOVE!!
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
3
src-tauri/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
BIN
src-tauri/src-tauri/icons/128x128.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/src-tauri/icons/128x128@2x.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/src-tauri/icons/32x32.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/src-tauri/icons/Square107x107Logo.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/src-tauri/icons/Square142x142Logo.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/src-tauri/icons/Square150x150Logo.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/src-tauri/icons/Square284x284Logo.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/src-tauri/icons/Square30x30Logo.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/src-tauri/icons/Square310x310Logo.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/src-tauri/icons/Square44x44Logo.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/src-tauri/icons/Square71x71Logo.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/src-tauri/icons/Square89x89Logo.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/src-tauri/icons/StoreLogo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/src-tauri/icons/icon.ico
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/src-tauri/icons/icon.png
Normal file
After Width: | Height: | Size: 49 KiB |
8
src-tauri/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
65
src-tauri/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devPath": "http://localhost:4000",
|
||||
"distDir": "../dist"
|
||||
},
|
||||
"package": {
|
||||
"productName": "Tauri App",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.tauri.dev",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
"title": "Tauri",
|
||||
"width": 800
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@@ -7,6 +7,7 @@ mod led_color;
|
||||
mod rpc;
|
||||
mod screenshot;
|
||||
mod screenshot_manager;
|
||||
mod screen_stream;
|
||||
mod volume;
|
||||
|
||||
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup};
|
||||
@@ -16,6 +17,7 @@ use paris::{error, info, warn};
|
||||
use rpc::{BoardInfo, UdpRpc};
|
||||
use screenshot::Screenshot;
|
||||
use screenshot_manager::ScreenshotManager;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::to_string;
|
||||
use tauri::{Manager, Emitter, Runtime};
|
||||
@@ -335,6 +337,21 @@ fn handle_ambient_light_protocol<R: Runtime>(
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
|
||||
// Debug: Print available displays
|
||||
match display_info::DisplayInfo::all() {
|
||||
Ok(displays) => {
|
||||
println!("=== AVAILABLE DISPLAYS ===");
|
||||
for (index, display) in displays.iter().enumerate() {
|
||||
println!(" Display {}: ID={}, Scale={}, Width={}, Height={}",
|
||||
index, display.id, display.scale_factor, display.width, display.height);
|
||||
}
|
||||
println!("=== END DISPLAYS ===");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error getting display info: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let screenshot_manager = ScreenshotManager::global().await;
|
||||
screenshot_manager.start().await.unwrap_or_else(|e| {
|
||||
@@ -347,6 +364,13 @@ async fn main() {
|
||||
led_color_publisher.start().await;
|
||||
});
|
||||
|
||||
// Start WebSocket server for screen streaming
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = start_websocket_server().await {
|
||||
error!("Failed to start WebSocket server: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let _volume = VolumeManager::global().await;
|
||||
|
||||
tauri::Builder::default()
|
||||
@@ -471,3 +495,30 @@ async fn main() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
// WebSocket server for screen streaming
|
||||
async fn start_websocket_server() -> anyhow::Result<()> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:8765").await?;
|
||||
info!("WebSocket server listening on ws://127.0.0.1:8765");
|
||||
|
||||
while let Ok((stream, addr)) = listener.accept().await {
|
||||
info!("New WebSocket connection from: {}", addr);
|
||||
|
||||
tokio::spawn(async move {
|
||||
info!("Starting WebSocket handler for connection from: {}", addr);
|
||||
match screen_stream::handle_websocket_connection(stream).await {
|
||||
Ok(_) => {
|
||||
info!("WebSocket connection from {} completed successfully", addr);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("WebSocket connection error from {}: {}", addr, e);
|
||||
}
|
||||
}
|
||||
info!("WebSocket handler task completed for: {}", addr);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
503
src-tauri/src/screen_stream.rs
Normal file
@@ -0,0 +1,503 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::Result;
|
||||
use image::{ImageFormat, RgbaImage};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio::time::sleep;
|
||||
use tokio_tungstenite::{accept_async, tungstenite::Message};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
||||
use crate::screenshot::Screenshot;
|
||||
use crate::screenshot_manager::ScreenshotManager;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamConfig {
|
||||
pub display_id: u32,
|
||||
pub target_width: u32,
|
||||
pub target_height: u32,
|
||||
pub quality: u8, // JPEG quality 1-100
|
||||
pub max_fps: u8, // Maximum frames per second
|
||||
}
|
||||
|
||||
impl Default for StreamConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
display_id: 0,
|
||||
target_width: 320, // Reduced from 400 for better performance
|
||||
target_height: 180, // Reduced from 225 for better performance
|
||||
quality: 50, // Reduced from 75 for faster compression
|
||||
max_fps: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamFrame {
|
||||
pub display_id: u32,
|
||||
pub timestamp: Instant,
|
||||
pub jpeg_data: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
pub struct ScreenStreamManager {
|
||||
streams: Arc<RwLock<HashMap<u32, Arc<RwLock<StreamState>>>>>,
|
||||
}
|
||||
|
||||
struct StreamState {
|
||||
config: StreamConfig,
|
||||
subscribers: Vec<broadcast::Sender<StreamFrame>>,
|
||||
last_frame: Option<StreamFrame>,
|
||||
last_screenshot_hash: Option<u64>,
|
||||
last_force_send: Instant,
|
||||
is_running: bool,
|
||||
}
|
||||
|
||||
impl ScreenStreamManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
streams: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_stream(&self, config: StreamConfig) -> Result<broadcast::Receiver<StreamFrame>> {
|
||||
let display_id = config.display_id;
|
||||
let mut streams = self.streams.write().await;
|
||||
|
||||
if let Some(stream_state) = streams.get(&display_id) {
|
||||
// Stream already exists, just add a new subscriber
|
||||
let mut state = stream_state.write().await;
|
||||
let (tx, rx) = broadcast::channel(10);
|
||||
state.subscribers.push(tx);
|
||||
return Ok(rx);
|
||||
}
|
||||
|
||||
// Create new stream
|
||||
let (tx, rx) = broadcast::channel(10);
|
||||
let stream_state = Arc::new(RwLock::new(StreamState {
|
||||
config: config.clone(),
|
||||
subscribers: vec![tx],
|
||||
last_frame: None,
|
||||
last_screenshot_hash: None,
|
||||
last_force_send: Instant::now(),
|
||||
is_running: false,
|
||||
}));
|
||||
|
||||
streams.insert(display_id, stream_state.clone());
|
||||
drop(streams);
|
||||
|
||||
// Start the stream processing task
|
||||
let streams_ref = self.streams.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::run_stream(display_id, streams_ref).await {
|
||||
log::error!("Stream {} error: {}", display_id, e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
async fn run_stream(display_id: u32, streams: Arc<RwLock<HashMap<u32, Arc<RwLock<StreamState>>>>>) -> Result<()> {
|
||||
log::info!("Starting stream for display_id: {}", display_id);
|
||||
|
||||
let screenshot_manager = ScreenshotManager::global().await;
|
||||
|
||||
// If display_id is 0, try to get the first available display
|
||||
let actual_display_id = if display_id == 0 {
|
||||
// Get available displays and use the first one
|
||||
let displays = display_info::DisplayInfo::all().map_err(|e| anyhow::anyhow!("Failed to get displays: {}", e))?;
|
||||
if displays.is_empty() {
|
||||
return Err(anyhow::anyhow!("No displays available"));
|
||||
}
|
||||
log::info!("Using first available display: {}", displays[0].id);
|
||||
displays[0].id
|
||||
} else {
|
||||
display_id
|
||||
};
|
||||
|
||||
log::info!("Attempting to subscribe to display_id: {}", actual_display_id);
|
||||
let screenshot_rx = match screenshot_manager.subscribe_by_display_id(actual_display_id).await {
|
||||
Ok(rx) => {
|
||||
log::info!("Successfully subscribed to display_id: {}", actual_display_id);
|
||||
rx
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to subscribe to display_id {}: {}", actual_display_id, e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut screenshot_rx = screenshot_rx;
|
||||
|
||||
// Mark stream as running
|
||||
{
|
||||
let streams_lock = streams.read().await;
|
||||
if let Some(stream_state) = streams_lock.get(&display_id) {
|
||||
let mut state = stream_state.write().await;
|
||||
state.is_running = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mut last_process_time = Instant::now();
|
||||
|
||||
loop {
|
||||
// Check if stream still has subscribers
|
||||
let should_continue = {
|
||||
let streams_lock = streams.read().await;
|
||||
if let Some(stream_state) = streams_lock.get(&display_id) {
|
||||
let state = stream_state.read().await;
|
||||
!state.subscribers.is_empty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait for new screenshot
|
||||
if let Ok(_) = screenshot_rx.changed().await {
|
||||
let screenshot = screenshot_rx.borrow().clone();
|
||||
|
||||
// Rate limiting based on max_fps
|
||||
let config = {
|
||||
let streams_lock = streams.read().await;
|
||||
if let Some(stream_state) = streams_lock.get(&display_id) {
|
||||
let state = stream_state.read().await;
|
||||
state.config.clone()
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let min_interval = Duration::from_millis(1000 / config.max_fps as u64);
|
||||
let elapsed = last_process_time.elapsed();
|
||||
if elapsed < min_interval {
|
||||
sleep(min_interval - elapsed).await;
|
||||
}
|
||||
|
||||
// Process screenshot into JPEG frame
|
||||
if let Ok(frame) = Self::process_screenshot(&screenshot, &config).await {
|
||||
last_process_time = Instant::now();
|
||||
|
||||
// Check if frame content changed (simple hash comparison) or force send
|
||||
let frame_hash = Self::calculate_frame_hash(&frame.jpeg_data);
|
||||
let should_send = {
|
||||
let streams_lock = streams.read().await;
|
||||
if let Some(stream_state) = streams_lock.get(&display_id) {
|
||||
let mut state = stream_state.write().await;
|
||||
let changed = state.last_screenshot_hash.map_or(true, |hash| hash != frame_hash);
|
||||
let elapsed_ms = state.last_force_send.elapsed().as_millis();
|
||||
let force_send = elapsed_ms > 200; // Force send every 200ms for higher FPS
|
||||
|
||||
if changed || force_send {
|
||||
state.last_screenshot_hash = Some(frame_hash);
|
||||
state.last_frame = Some(frame.clone());
|
||||
if force_send {
|
||||
state.last_force_send = Instant::now();
|
||||
}
|
||||
}
|
||||
changed || force_send
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if should_send {
|
||||
// Send to all subscribers
|
||||
let streams_lock = streams.read().await;
|
||||
if let Some(stream_state) = streams_lock.get(&display_id) {
|
||||
let state = stream_state.read().await;
|
||||
for tx in state.subscribers.iter() {
|
||||
if let Err(_) = tx.send(frame.clone()) {
|
||||
log::warn!("Failed to send frame to subscriber for display_id: {}", display_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark stream as stopped
|
||||
{
|
||||
let streams_lock = streams.read().await;
|
||||
if let Some(stream_state) = streams_lock.get(&display_id) {
|
||||
let mut state = stream_state.write().await;
|
||||
state.is_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_screenshot(screenshot: &Screenshot, config: &StreamConfig) -> Result<StreamFrame> {
|
||||
let total_start = Instant::now();
|
||||
let bytes = screenshot.bytes.read().await;
|
||||
|
||||
// Convert BGRA to RGBA using unsafe with optimized batch processing for maximum performance
|
||||
let mut rgba_bytes = bytes.as_ref().clone();
|
||||
unsafe {
|
||||
let ptr = rgba_bytes.as_mut_ptr() as *mut u32;
|
||||
let len = rgba_bytes.len() / 4;
|
||||
|
||||
// Process in larger chunks of 64 for better cache efficiency and loop unrolling
|
||||
let chunk_size = 64;
|
||||
let full_chunks = len / chunk_size;
|
||||
let remainder = len % chunk_size;
|
||||
|
||||
// Process full chunks with manual loop unrolling
|
||||
for chunk_idx in 0..full_chunks {
|
||||
let base_ptr = ptr.add(chunk_idx * chunk_size);
|
||||
|
||||
// Unroll the inner loop for better performance
|
||||
for i in (0..chunk_size).step_by(4) {
|
||||
// Process 4 pixels at once
|
||||
let p0 = base_ptr.add(i).read();
|
||||
let p1 = base_ptr.add(i + 1).read();
|
||||
let p2 = base_ptr.add(i + 2).read();
|
||||
let p3 = base_ptr.add(i + 3).read();
|
||||
|
||||
// BGRA (0xAABBGGRR) -> RGBA (0xAAGGBBRR)
|
||||
let s0 = (p0 & 0xFF00FF00) | ((p0 & 0x00FF0000) >> 16) | ((p0 & 0x000000FF) << 16);
|
||||
let s1 = (p1 & 0xFF00FF00) | ((p1 & 0x00FF0000) >> 16) | ((p1 & 0x000000FF) << 16);
|
||||
let s2 = (p2 & 0xFF00FF00) | ((p2 & 0x00FF0000) >> 16) | ((p2 & 0x000000FF) << 16);
|
||||
let s3 = (p3 & 0xFF00FF00) | ((p3 & 0x00FF0000) >> 16) | ((p3 & 0x000000FF) << 16);
|
||||
|
||||
base_ptr.add(i).write(s0);
|
||||
base_ptr.add(i + 1).write(s1);
|
||||
base_ptr.add(i + 2).write(s2);
|
||||
base_ptr.add(i + 3).write(s3);
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining pixels
|
||||
let remainder_start = full_chunks * chunk_size;
|
||||
for i in 0..remainder {
|
||||
let idx = remainder_start + i;
|
||||
let pixel = ptr.add(idx).read();
|
||||
let swapped = (pixel & 0xFF00FF00) | ((pixel & 0x00FF0000) >> 16) | ((pixel & 0x000000FF) << 16);
|
||||
ptr.add(idx).write(swapped);
|
||||
}
|
||||
}
|
||||
|
||||
// Create image from raw bytes
|
||||
let img = RgbaImage::from_raw(
|
||||
screenshot.width,
|
||||
screenshot.height,
|
||||
rgba_bytes,
|
||||
).ok_or_else(|| anyhow::anyhow!("Failed to create image from raw bytes"))?;
|
||||
|
||||
// Resize if needed
|
||||
let final_img = if screenshot.width != config.target_width || screenshot.height != config.target_height {
|
||||
image::imageops::resize(
|
||||
&img,
|
||||
config.target_width,
|
||||
config.target_height,
|
||||
image::imageops::FilterType::Nearest, // Fastest filter for real-time streaming
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
// Convert to JPEG
|
||||
let mut jpeg_buffer = Vec::new();
|
||||
let mut cursor = Cursor::new(&mut jpeg_buffer);
|
||||
|
||||
let rgb_img = image::DynamicImage::ImageRgba8(final_img).to_rgb8();
|
||||
rgb_img.write_to(&mut cursor, ImageFormat::Jpeg)?;
|
||||
|
||||
let total_duration = total_start.elapsed();
|
||||
log::debug!("Screenshot processed for display {} in {}ms, JPEG size: {} bytes",
|
||||
config.display_id, total_duration.as_millis(), jpeg_buffer.len());
|
||||
|
||||
Ok(StreamFrame {
|
||||
display_id: config.display_id,
|
||||
timestamp: Instant::now(),
|
||||
jpeg_data: jpeg_buffer,
|
||||
width: config.target_width,
|
||||
height: config.target_height,
|
||||
})
|
||||
}
|
||||
|
||||
fn calculate_frame_hash(data: &[u8]) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
// Sample every 100th byte for better sensitivity (was 1000)
|
||||
for (i, &byte) in data.iter().enumerate() {
|
||||
if i % 100 == 0 {
|
||||
byte.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub async fn stop_stream(&self, display_id: u32) {
|
||||
let mut streams = self.streams.write().await;
|
||||
streams.remove(&display_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
static SCREEN_STREAM_MANAGER: tokio::sync::OnceCell<ScreenStreamManager> = tokio::sync::OnceCell::const_new();
|
||||
|
||||
impl ScreenStreamManager {
|
||||
pub async fn global() -> &'static Self {
|
||||
SCREEN_STREAM_MANAGER.get_or_init(|| async {
|
||||
ScreenStreamManager::new()
|
||||
}).await
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket handler for screen streaming
|
||||
pub async fn handle_websocket_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
) -> Result<()> {
|
||||
log::info!("Accepting WebSocket connection...");
|
||||
|
||||
let ws_stream = match accept_async(stream).await {
|
||||
Ok(ws) => {
|
||||
log::info!("WebSocket handshake completed successfully");
|
||||
ws
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("WebSocket handshake failed: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let (ws_sender, mut ws_receiver) = ws_stream.split();
|
||||
|
||||
log::info!("WebSocket connection established, waiting for configuration...");
|
||||
|
||||
// Wait for the first configuration message
|
||||
let config = loop {
|
||||
// Add timeout to prevent hanging
|
||||
let timeout_duration = tokio::time::Duration::from_secs(10);
|
||||
match tokio::time::timeout(timeout_duration, ws_receiver.next()).await {
|
||||
Ok(Some(msg)) => {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
log::info!("Received configuration message: {}", text);
|
||||
|
||||
if let Ok(config_json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
// Parse configuration from JSON
|
||||
let display_id = config_json.get("display_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let width = config_json.get("width")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(320) as u32; // Reduced from 400 for better performance
|
||||
let height = config_json.get("height")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(180) as u32; // Reduced from 225 for better performance
|
||||
let quality = config_json.get("quality")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(50) as u8; // Reduced from 75 for faster compression
|
||||
|
||||
let config = StreamConfig {
|
||||
display_id,
|
||||
target_width: width,
|
||||
target_height: height,
|
||||
quality,
|
||||
max_fps: 15,
|
||||
};
|
||||
|
||||
log::info!("Parsed stream config: display_id={}, width={}, height={}, quality={}",
|
||||
display_id, width, height, quality);
|
||||
break config;
|
||||
} else {
|
||||
log::warn!("Failed to parse configuration JSON: {}", text);
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
log::info!("WebSocket connection closed before configuration");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("WebSocket error while waiting for config: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log::warn!("WebSocket connection closed while waiting for configuration");
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("Timeout waiting for WebSocket configuration message");
|
||||
return Err(anyhow::anyhow!("Timeout waiting for configuration"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start the stream with the received configuration
|
||||
log::info!("Starting stream with config: display_id={}, width={}, height={}",
|
||||
config.display_id, config.target_width, config.target_height);
|
||||
let stream_manager = ScreenStreamManager::global().await;
|
||||
let mut frame_rx = match stream_manager.start_stream(config).await {
|
||||
Ok(rx) => {
|
||||
log::info!("Screen stream started successfully");
|
||||
rx
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to start screen stream: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle incoming WebSocket messages (for control)
|
||||
let ws_sender = Arc::new(tokio::sync::Mutex::new(ws_sender));
|
||||
let ws_sender_clone = ws_sender.clone();
|
||||
|
||||
// Task to handle outgoing frames
|
||||
let frame_task = tokio::spawn(async move {
|
||||
while let Ok(frame) = frame_rx.recv().await {
|
||||
let mut sender = ws_sender_clone.lock().await;
|
||||
match sender.send(Message::Binary(frame.jpeg_data)).await {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to send frame: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("Frame sending task completed");
|
||||
});
|
||||
|
||||
// Task to handle incoming messages
|
||||
let control_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
log::info!("Received control message: {}", text);
|
||||
// Additional configuration updates could be handled here
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
log::info!("WebSocket connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
log::info!("Control message task completed");
|
||||
});
|
||||
|
||||
// Wait for either task to complete
|
||||
tokio::select! {
|
||||
_ = frame_task => {},
|
||||
_ = control_task => {},
|
||||
}
|
||||
|
||||
log::info!("WebSocket connection handler completed");
|
||||
Ok(())
|
||||
}
|
@@ -108,6 +108,11 @@ impl ScreenshotManager {
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
let displays = display_info::DisplayInfo::all()?;
|
||||
|
||||
log::info!("ScreenshotManager starting with {} displays:", displays.len());
|
||||
for display in &displays {
|
||||
log::info!(" Display ID: {}, Scale: {}", display.id, display.scale_factor);
|
||||
}
|
||||
|
||||
let futures = displays.iter().map(|display| async {
|
||||
self.start_one(display.id, display.scale_factor)
|
||||
.await
|
||||
@@ -118,11 +123,12 @@ impl ScreenshotManager {
|
||||
});
|
||||
|
||||
futures::future::join_all(futures).await;
|
||||
log::info!("ScreenshotManager started successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_one(&self, display_id: u32, scale_factor: f32) -> anyhow::Result<()> {
|
||||
|
||||
log::info!("Starting screenshot capture for display_id: {}", display_id);
|
||||
|
||||
let merged_screenshot_tx = self.merged_screenshot_tx.clone();
|
||||
|
||||
@@ -183,8 +189,8 @@ impl ScreenshotManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for a frame duration (30 FPS)
|
||||
sleep(Duration::from_millis(33)).await;
|
||||
// Sleep for a frame duration (15 FPS for better performance)
|
||||
sleep(Duration::from_millis(67)).await;
|
||||
yield_now().await;
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0",
|
||||
"productName": "test-demo",
|
||||
"version": "0.0.1",
|
||||
"identifier": "cc.ivanli.ambient-light.desktop",
|
||||
"mainBinaryName": "test-demo",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
@@ -10,7 +10,7 @@
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
|
44
src/App.tsx
@@ -11,29 +11,59 @@ import { DisplayStateIndex } from './components/displays/display-state-index';
|
||||
function App() {
|
||||
createEffect(() => {
|
||||
invoke<LedStripConfigContainer>('read_config').then((config) => {
|
||||
console.log('read config', config);
|
||||
console.log('App: read config', config);
|
||||
setLedStripStore({
|
||||
strips: config.strips,
|
||||
mappers: config.mappers,
|
||||
colorCalibration: config.color_calibration,
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('App: Failed to read config:', error);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<a href="/info">基本信息</a>
|
||||
<a href="/displays">显示器信息</a>
|
||||
<a href="/led-strips-configuration">灯条配置</a>
|
||||
<a href="/white-balance">白平衡</a>
|
||||
<div class="min-h-screen bg-base-100" data-theme="dark">
|
||||
{/* Navigation */}
|
||||
<div class="navbar bg-base-200 shadow-lg">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><a href="/info" class="text-base-content">基本信息</a></li>
|
||||
<li><a href="/displays" class="text-base-content">显示器信息</a></li>
|
||||
<li><a href="/led-strips-configuration" class="text-base-content">灯条配置</a></li>
|
||||
<li><a href="/white-balance" class="text-base-content">白平衡</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a class="btn btn-ghost text-xl text-primary font-bold">环境光控制</a>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="/info" class="btn btn-ghost text-base-content hover:text-primary">基本信息</a></li>
|
||||
<li><a href="/displays" class="btn btn-ghost text-base-content hover:text-primary">显示器信息</a></li>
|
||||
<li><a href="/led-strips-configuration" class="btn btn-ghost text-base-content hover:text-primary">灯条配置</a></li>
|
||||
<li><a href="/white-balance" class="btn btn-ghost text-base-content hover:text-primary">白平衡</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="badge badge-primary badge-outline">v1.0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main class="container mx-auto p-4">
|
||||
<Routes>
|
||||
<Route path="/info" component={InfoIndex} />
|
||||
<Route path="/displays" component={DisplayStateIndex} />
|
||||
<Route path="/led-strips-configuration" component={LedStripConfiguration} />
|
||||
<Route path="/white-balance" component={WhiteBalance} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -11,26 +11,59 @@ type ItemProps = {
|
||||
|
||||
const Item: ParentComponent<ItemProps> = (props) => {
|
||||
return (
|
||||
<dl class="flex">
|
||||
<dt class="w-20">{props.label}</dt>
|
||||
<dd class="flex-auto">{props.children}</dd>
|
||||
</dl>
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<dt class="text-sm font-medium text-base-content/70">{props.label}</dt>
|
||||
<dd class="text-sm font-mono text-base-content">{props.children}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DisplayStateCard: Component<DisplayStateCardProps> = (props) => {
|
||||
return (
|
||||
<section class="p-2 rounded shadow">
|
||||
<Item label="Brightness">{props.state.brightness}</Item>
|
||||
<Item label="Max Brightness">{props.state.max_brightness}</Item>
|
||||
<Item label="Min Brightness">{props.state.min_brightness}</Item>
|
||||
<Item label="Contrast">{props.state.contrast}</Item>
|
||||
<Item label="Max Contrast">{props.state.max_contrast}</Item>
|
||||
<Item label="Min Contrast">{props.state.min_contrast}</Item>
|
||||
<Item label="Max Mode">{props.state.max_mode}</Item>
|
||||
<Item label="Min Mode">{props.state.min_mode}</Item>
|
||||
<Item label="Mode">{props.state.mode}</Item>
|
||||
<Item label="Last Modified At">{props.state.last_modified_at.toISOString()}</Item>
|
||||
</section>
|
||||
<div class="card bg-base-200 shadow-lg hover:shadow-xl transition-shadow duration-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex items-center justify-between">
|
||||
<span>显示器状态</span>
|
||||
<div class="badge badge-primary badge-outline">实时</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
{/* 亮度信息 */}
|
||||
<div class="bg-base-100 rounded-lg p-3">
|
||||
<h4 class="text-sm font-semibold text-base-content mb-2">亮度设置</h4>
|
||||
<div class="space-y-1">
|
||||
<Item label="当前亮度">{props.state.brightness}</Item>
|
||||
<Item label="最大亮度">{props.state.max_brightness}</Item>
|
||||
<Item label="最小亮度">{props.state.min_brightness}</Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对比度信息 */}
|
||||
<div class="bg-base-100 rounded-lg p-3">
|
||||
<h4 class="text-sm font-semibold text-base-content mb-2">对比度设置</h4>
|
||||
<div class="space-y-1">
|
||||
<Item label="当前对比度">{props.state.contrast}</Item>
|
||||
<Item label="最大对比度">{props.state.max_contrast}</Item>
|
||||
<Item label="最小对比度">{props.state.min_contrast}</Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式信息 */}
|
||||
<div class="bg-base-100 rounded-lg p-3">
|
||||
<h4 class="text-sm font-semibold text-base-content mb-2">模式设置</h4>
|
||||
<div class="space-y-1">
|
||||
<Item label="当前模式">{props.state.mode}</Item>
|
||||
<Item label="最大模式">{props.state.max_mode}</Item>
|
||||
<Item label="最小模式">{props.state.min_mode}</Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新时间 */}
|
||||
<div class="text-xs text-base-content/50 text-center pt-2 border-t border-base-300">
|
||||
最后更新: {props.state.last_modified_at.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -36,17 +36,37 @@ export const DisplayStateIndex: Component = () => {
|
||||
};
|
||||
});
|
||||
return (
|
||||
<ol class="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 p-2 gap-2">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">显示器状态</h1>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">显示器数量</div>
|
||||
<div class="stat-value text-primary">{states().length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<For each={states()}>
|
||||
{(state, index) => (
|
||||
<li class="bg-slate-50 text-gray-800 relative border-2 border-slate-50 hover:border-sky-300 focus:border-sky-300 transition">
|
||||
<div class="relative">
|
||||
<DisplayStateCard state={state} />
|
||||
<span class="absolute left-2 -top-3 bg-sky-300 text-white px-1 py-0.5 text-xs rounded-sm font-mono">
|
||||
#{index() + 1}
|
||||
</span>
|
||||
</li>
|
||||
<div class="absolute -top-2 -left-2 w-6 h-6 bg-primary text-primary-content rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{index() + 1}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{states().length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🖥️</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-2">未检测到显示器</h3>
|
||||
<p class="text-base-content/70">请检查显示器连接状态</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -26,17 +26,37 @@ export const BoardIndex: Component = () => {
|
||||
};
|
||||
});
|
||||
return (
|
||||
<ol class="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 p-2 gap-2">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">设备信息</h1>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">设备总数</div>
|
||||
<div class="stat-value text-primary">{boards().length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={boards()}>
|
||||
{(board, index) => (
|
||||
<li class="bg-slate-50 text-gray-800 relative border-2 border-slate-50 hover:border-sky-300 focus:border-sky-300 transition">
|
||||
<div class="relative">
|
||||
<BoardInfoPanel board={board} />
|
||||
<span class="absolute left-2 -top-3 bg-sky-300 text-white px-1 py-0.5 text-xs rounded-sm font-mono">
|
||||
#{index() + 1}
|
||||
</span>
|
||||
</li>
|
||||
<div class="absolute -top-2 -left-2 w-6 h-6 bg-primary text-primary-content rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{index() + 1}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{boards().length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🔍</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-2">未发现设备</h3>
|
||||
<p class="text-base-content/70">请检查设备连接状态</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -7,10 +7,10 @@ type ItemProps = {
|
||||
|
||||
const Item: ParentComponent<ItemProps> = (props) => {
|
||||
return (
|
||||
<dl class="flex">
|
||||
<dt class="w-20">{props.label}</dt>
|
||||
<dd class="flex-auto">{props.children}</dd>
|
||||
</dl>
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<dt class="text-sm font-medium text-base-content/70">{props.label}</dt>
|
||||
<dd class="text-sm font-mono text-base-content">{props.children}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,20 +41,31 @@ export const BoardInfoPanel: Component<{ board: BoardInfo }> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
const statusBadgeClass = createMemo(() => {
|
||||
const status = connectStatus();
|
||||
if (status === 'Connected') {
|
||||
return 'badge badge-success badge-sm';
|
||||
} else if (status?.startsWith('Connecting')) {
|
||||
return 'badge badge-warning badge-sm';
|
||||
} else {
|
||||
return 'badge badge-error badge-sm';
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class="p-2 rounded shadow">
|
||||
<Item label="Host">{props.board.fullname}</Item>
|
||||
<Item label="Host">{props.board.host}</Item>
|
||||
<Item label="Ip Addr">
|
||||
<span class="font-mono">{props.board.address}</span>
|
||||
</Item>
|
||||
<Item label="Port">
|
||||
<span class="font-mono">{props.board.port}</span>
|
||||
</Item>
|
||||
<Item label="Status">
|
||||
<span class="font-mono">{connectStatus()}</span>
|
||||
</Item>
|
||||
<Item label="TTL">{ttl()}</Item>
|
||||
</section>
|
||||
<div class="card bg-base-200 shadow-lg hover:shadow-xl transition-shadow duration-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex items-center justify-between">
|
||||
<span class="truncate">{props.board.fullname}</span>
|
||||
<div class={statusBadgeClass()}>{connectStatus()}</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Item label="主机名">{props.board.host}</Item>
|
||||
<Item label="IP地址">{props.board.address}</Item>
|
||||
<Item label="端口">{props.board.port}</Item>
|
||||
<Item label="延迟">{ttl()}</Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -7,10 +7,10 @@ type DisplayInfoItemProps = {
|
||||
|
||||
export const DisplayInfoItem: ParentComponent<DisplayInfoItemProps> = (props) => {
|
||||
return (
|
||||
<dl class="px-3 py-1 flex hover:bg-slate-900/50 gap-2 text-white drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)] rounded">
|
||||
<dt class="uppercase w-1/2 select-all whitespace-nowrap">{props.label}</dt>
|
||||
<dd class="select-all w-1/2 whitespace-nowrap">{props.children}</dd>
|
||||
</dl>
|
||||
<div class="flex justify-between items-center py-1 px-2 hover:bg-base-300/50 rounded transition-colors">
|
||||
<dt class="text-sm font-medium text-base-content/80 uppercase">{props.label}</dt>
|
||||
<dd class="text-sm font-mono text-base-content select-all">{props.children}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,22 +21,29 @@ type DisplayInfoPanelProps = {
|
||||
export const DisplayInfoPanel: Component<DisplayInfoPanelProps> = (props) => {
|
||||
const [localProps, rootProps] = splitProps(props, ['display']);
|
||||
return (
|
||||
<section {...rootProps} class={'m-2 flex flex-col gap-1 py-2 ' + rootProps.class}>
|
||||
<div {...rootProps} class={'card bg-base-100/95 backdrop-blur shadow-lg border border-base-300 ' + rootProps.class}>
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-sm mb-3 flex items-center justify-between">
|
||||
<span class="text-base-content">显示器信息</span>
|
||||
{localProps.display.is_primary && (
|
||||
<div class="badge badge-primary badge-sm">主显示器</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<DisplayInfoItem label="ID">
|
||||
<code>{localProps.display.id}</code>
|
||||
<code class="bg-base-200 px-1 rounded text-xs">{localProps.display.id}</code>
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="Position">
|
||||
<DisplayInfoItem label="位置">
|
||||
({localProps.display.x}, {localProps.display.y})
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="Size">
|
||||
{localProps.display.width} x {localProps.display.height}
|
||||
<DisplayInfoItem label="尺寸">
|
||||
{localProps.display.width} × {localProps.display.height}
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="Scale Factor">
|
||||
{localProps.display.scale_factor}
|
||||
<DisplayInfoItem label="缩放">
|
||||
{localProps.display.scale_factor}×
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="is Primary">
|
||||
{localProps.display.is_primary ? 'True' : 'False'}
|
||||
</DisplayInfoItem>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -6,6 +6,7 @@ import { DisplayInfoPanel } from './display-info-panel';
|
||||
import { LedStripPart } from './led-strip-part';
|
||||
import { ScreenView } from './screen-view';
|
||||
|
||||
|
||||
type DisplayViewProps = {
|
||||
display: DisplayInfo;
|
||||
};
|
||||
|
@@ -0,0 +1,155 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Component, createMemo, For, JSX, splitProps } from 'solid-js';
|
||||
import { DisplayInfo } from '../../models/display-info.model';
|
||||
import { ledStripStore } from '../../stores/led-strip.store';
|
||||
import { Borders } from '../../constants/border';
|
||||
|
||||
type LedCountControlItemProps = {
|
||||
displayId: number;
|
||||
border: Borders;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
||||
const config = createMemo(() => {
|
||||
return ledStripStore.strips.find(
|
||||
(s) => s.display_id === props.displayId && s.border === props.border
|
||||
);
|
||||
});
|
||||
|
||||
const handleDecrease = () => {
|
||||
if (config()) {
|
||||
invoke('patch_led_strip_len', {
|
||||
displayId: props.displayId,
|
||||
border: props.border,
|
||||
deltaLen: -1,
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncrease = () => {
|
||||
if (config()) {
|
||||
invoke('patch_led_strip_len', {
|
||||
displayId: props.displayId,
|
||||
border: props.border,
|
||||
deltaLen: 1,
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const newValue = parseInt(target.value);
|
||||
const currentLen = config()?.len || 0;
|
||||
|
||||
if (!isNaN(newValue) && newValue >= 0 && newValue <= 1000) {
|
||||
const deltaLen = newValue - currentLen;
|
||||
if (deltaLen !== 0) {
|
||||
invoke('patch_led_strip_len', {
|
||||
displayId: props.displayId,
|
||||
border: props.border,
|
||||
deltaLen: deltaLen,
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
// Reset input value on error
|
||||
target.value = currentLen.toString();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Reset invalid input
|
||||
target.value = (config()?.len || 0).toString();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="card bg-base-100 border border-base-300/50 p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-center">
|
||||
<span class="text-xs font-medium text-base-content">
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="btn btn-xs btn-circle btn-outline flex-shrink-0"
|
||||
onClick={handleDecrease}
|
||||
disabled={!config() || (config()?.len || 0) <= 0}
|
||||
title="减少LED数量"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
class="input input-xs flex-1 text-center min-w-0 text-sm font-medium [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={config()?.len || 0}
|
||||
min="0"
|
||||
max="1000"
|
||||
onBlur={handleInputChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleInputChange(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
class="btn btn-xs btn-circle btn-outline flex-shrink-0"
|
||||
onClick={handleIncrease}
|
||||
disabled={!config() || (config()?.len || 0) >= 1000}
|
||||
title="增加LED数量"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LedCountControlPanelProps = {
|
||||
display: DisplayInfo;
|
||||
} & JSX.HTMLAttributes<HTMLElement>;
|
||||
|
||||
export const LedCountControlPanel: Component<LedCountControlPanelProps> = (props) => {
|
||||
const [localProps, rootProps] = splitProps(props, ['display']);
|
||||
|
||||
const borders: { border: Borders; label: string }[] = [
|
||||
{ border: 'Top', label: '上' },
|
||||
{ border: 'Bottom', label: '下' },
|
||||
{ border: 'Left', label: '左' },
|
||||
{ border: 'Right', label: '右' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div {...rootProps} class={'card bg-base-200 shadow-lg border border-base-300 ' + (rootProps.class || '')}>
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex items-center justify-between">
|
||||
<span>LED数量控制</span>
|
||||
<div class="badge badge-info badge-outline">显示器 {localProps.display.id}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<For each={borders}>
|
||||
{(item) => (
|
||||
<LedCountControlItem
|
||||
displayId={localProps.display.id}
|
||||
border={item.border}
|
||||
label={item.label}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-base-content/50 mt-3 p-2 bg-base-300/50 rounded">
|
||||
💡 提示:点击 +/- 按钮或直接输入数值来调整LED数量(范围:0-1000)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -7,6 +7,7 @@ import { LedStripConfigContainer } from '../../models/led-strip-config';
|
||||
import { setLedStripStore } from '../../stores/led-strip.store';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { LedStripPartsSorter } from './led-strip-parts-sorter';
|
||||
import { LedCountControlPanel } from './led-count-control-panel';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import {
|
||||
LedStripConfigurationContext,
|
||||
@@ -17,12 +18,20 @@ import {
|
||||
export const LedStripConfiguration = () => {
|
||||
createEffect(() => {
|
||||
invoke<string>('list_display_info').then((displays) => {
|
||||
const parsedDisplays = JSON.parse(displays);
|
||||
console.log('LedStripConfiguration: Loaded displays:', parsedDisplays);
|
||||
setDisplayStore({
|
||||
displays: JSON.parse(displays),
|
||||
displays: parsedDisplays,
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('LedStripConfiguration: Failed to load displays:', error);
|
||||
});
|
||||
|
||||
invoke<LedStripConfigContainer>('read_led_strip_configs').then((configs) => {
|
||||
console.log('LedStripConfiguration: Loaded LED strip configs:', configs);
|
||||
setLedStripStore(configs);
|
||||
}).catch((error) => {
|
||||
console.error('LedStripConfiguration: Failed to load LED strip configs:', error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,14 +100,65 @@ export const LedStripConfiguration = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">灯条配置</h1>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">显示器数量</div>
|
||||
<div class="stat-value text-primary">{displayStore.displays.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LedStripConfigurationContext.Provider value={ledStripConfigurationContextValue}>
|
||||
{/* LED Strip Sorter Panel */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>灯条排序</span>
|
||||
<div class="badge badge-info badge-outline">实时预览</div>
|
||||
</div>
|
||||
<LedStripPartsSorter />
|
||||
<div class="text-xs text-base-content/50 mt-2">
|
||||
💡 提示:拖拽灯条段落来调整顺序,双击可反转方向
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Configuration Panel */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>显示器配置</span>
|
||||
<div class="badge badge-secondary badge-outline">可视化编辑</div>
|
||||
</div>
|
||||
<div class="h-96 mb-4">
|
||||
<DisplayListContainer>
|
||||
{displayStore.displays.map((display) => {
|
||||
console.log('LedStripConfiguration: Rendering DisplayView for display:', display);
|
||||
return <DisplayView display={display} />;
|
||||
})}
|
||||
</DisplayListContainer>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50">
|
||||
💡 提示:悬停显示器查看详细信息,使用下方控制面板调整LED数量
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LED Count Control Panels */}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h2 class="text-lg font-semibold text-base-content">LED数量控制</h2>
|
||||
<div class="badge badge-info badge-outline">实时调整</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{displayStore.displays.map((display) => (
|
||||
<LedCountControlPanel display={display} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</LedStripConfigurationContext.Provider>
|
||||
</div>
|
||||
);
|
||||
|
@@ -34,7 +34,7 @@ export const Pixel: Component<PixelProps> = (props) => {
|
||||
title={props.color}
|
||||
>
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 h-2 w-2 rounded-full ring-1 ring-stone-300/30"
|
||||
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-300/50"
|
||||
style={style()}
|
||||
/>
|
||||
</div>
|
||||
@@ -124,26 +124,14 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
||||
},
|
||||
});
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (localProps.config) {
|
||||
invoke('patch_led_strip_len', {
|
||||
displayId: localProps.config.display_id,
|
||||
border: localProps.config.border,
|
||||
deltaLen: e.deltaY > 0 ? 1 : -1,
|
||||
})
|
||||
.then(() => {})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<section
|
||||
{...rootProps}
|
||||
ref={setAnchor}
|
||||
class={
|
||||
'flex rounded-full flex-nowrap justify-around items-center overflow-hidden bg-gray-800/20 border border-gray-600/30 min-h-[16px] min-w-[16px] ' +
|
||||
'flex rounded-full flex-nowrap justify-around items-center overflow-hidden bg-gray-800/20 border border-gray-600/30 min-h-[32px] min-w-[32px] ' +
|
||||
rootProps.class
|
||||
}
|
||||
classList={{
|
||||
@@ -152,7 +140,7 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
||||
stripConfiguration.selectedStripPart?.displayId ===
|
||||
localProps.config?.display_id,
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
|
||||
>
|
||||
<For each={colors()}>{(item) => <Pixel color={item} />}</For>
|
||||
</section>
|
||||
|
290
src/components/led-strip-configuration/screen-view-websocket.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
JSX,
|
||||
onCleanup,
|
||||
onMount,
|
||||
splitProps,
|
||||
} from 'solid-js';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
type ScreenViewWebSocketProps = {
|
||||
displayId: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ScreenViewWebSocket: Component<ScreenViewWebSocketProps> = (props) => {
|
||||
const [localProps, rootProps] = splitProps(props, ['displayId', 'width', 'height', 'quality']);
|
||||
let canvas: HTMLCanvasElement;
|
||||
let root: HTMLDivElement;
|
||||
const [ctx, setCtx] = createSignal<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
const [drawInfo, setDrawInfo] = createSignal({
|
||||
drawX: 0,
|
||||
drawY: 0,
|
||||
drawWidth: 0,
|
||||
drawHeight: 0,
|
||||
});
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected');
|
||||
const [frameCount, setFrameCount] = createSignal(0);
|
||||
const [lastFrameTime, setLastFrameTime] = createSignal(0);
|
||||
const [fps, setFps] = createSignal(0);
|
||||
|
||||
let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: number | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
// Performance monitoring
|
||||
let frameTimestamps: number[] = [];
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (!isMounted) {
|
||||
console.log('Component not mounted, skipping WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = `ws://127.0.0.1:8765`;
|
||||
console.log('Connecting to WebSocket:', wsUrl, 'with displayId:', localProps.displayId);
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
websocket = new WebSocket(wsUrl);
|
||||
websocket.binaryType = 'arraybuffer';
|
||||
console.log('WebSocket object created:', websocket);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket connected successfully!');
|
||||
setConnectionStatus('connected');
|
||||
|
||||
// Send initial configuration
|
||||
const config = {
|
||||
display_id: localProps.displayId,
|
||||
width: localProps.width || 320, // Reduced from 400 for better performance
|
||||
height: localProps.height || 180, // Reduced from 225 for better performance
|
||||
quality: localProps.quality || 50 // Reduced from 75 for faster compression
|
||||
};
|
||||
console.log('Sending WebSocket configuration:', config);
|
||||
websocket?.send(JSON.stringify(config));
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
console.log('🔍 WebSocket message received:', {
|
||||
type: typeof event.data,
|
||||
isArrayBuffer: event.data instanceof ArrayBuffer,
|
||||
size: event.data instanceof ArrayBuffer ? event.data.byteLength : 'N/A'
|
||||
});
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
console.log('📦 Processing ArrayBuffer frame, size:', event.data.byteLength);
|
||||
handleJpegFrame(new Uint8Array(event.data));
|
||||
} else {
|
||||
console.log('⚠️ Received non-ArrayBuffer data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason);
|
||||
setConnectionStatus('disconnected');
|
||||
websocket = null;
|
||||
|
||||
// Auto-reconnect after 2 seconds if component is still mounted
|
||||
if (isMounted && !reconnectTimeout) {
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
reconnectTimeout = null;
|
||||
connectWebSocket();
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setConnectionStatus('error');
|
||||
};
|
||||
};
|
||||
|
||||
const handleJpegFrame = async (jpegData: Uint8Array) => {
|
||||
const _ctx = ctx();
|
||||
if (!_ctx) return;
|
||||
|
||||
try {
|
||||
// Update performance metrics
|
||||
const now = performance.now();
|
||||
frameTimestamps.push(now);
|
||||
|
||||
// Keep only last 30 frames for FPS calculation
|
||||
if (frameTimestamps.length > 30) {
|
||||
frameTimestamps = frameTimestamps.slice(-30);
|
||||
}
|
||||
|
||||
// Calculate FPS
|
||||
if (frameTimestamps.length >= 2) {
|
||||
const timeSpan = frameTimestamps[frameTimestamps.length - 1] - frameTimestamps[0];
|
||||
if (timeSpan > 0) {
|
||||
const currentFps = Math.round((frameTimestamps.length - 1) * 1000 / timeSpan);
|
||||
setFps(Math.max(0, currentFps)); // Ensure FPS is never negative
|
||||
}
|
||||
}
|
||||
|
||||
setFrameCount(prev => prev + 1);
|
||||
setLastFrameTime(now);
|
||||
|
||||
// Create blob from JPEG data
|
||||
const blob = new Blob([jpegData], { type: 'image/jpeg' });
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Create image element
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const { drawX, drawY, drawWidth, drawHeight } = drawInfo();
|
||||
|
||||
// Clear canvas
|
||||
_ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image
|
||||
_ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('Failed to load JPEG image');
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling JPEG frame:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetSize = () => {
|
||||
// Set canvas size first
|
||||
canvas.width = root.clientWidth;
|
||||
canvas.height = root.clientHeight;
|
||||
|
||||
// Use a default aspect ratio if canvas dimensions are invalid
|
||||
const aspectRatio = (canvas.width > 0 && canvas.height > 0)
|
||||
? canvas.width / canvas.height
|
||||
: 16 / 9; // Default 16:9 aspect ratio
|
||||
|
||||
const drawWidth = Math.round(
|
||||
Math.min(root.clientWidth, root.clientHeight * aspectRatio),
|
||||
);
|
||||
const drawHeight = Math.round(
|
||||
Math.min(root.clientHeight, root.clientWidth / aspectRatio),
|
||||
);
|
||||
|
||||
const drawX = Math.round((root.clientWidth - drawWidth) / 2);
|
||||
const drawY = Math.round((root.clientHeight - drawHeight) / 2);
|
||||
|
||||
setDrawInfo({
|
||||
drawX,
|
||||
drawY,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
websocket = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize canvas and resize observer
|
||||
onMount(() => {
|
||||
console.log('ScreenViewWebSocket mounted with displayId:', localProps.displayId);
|
||||
const context = canvas.getContext('2d');
|
||||
setCtx(context);
|
||||
|
||||
// Initial size setup
|
||||
resetSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
resetSize();
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
|
||||
// Connect WebSocket
|
||||
console.log('About to connect WebSocket...');
|
||||
connectWebSocket();
|
||||
|
||||
onCleanup(() => {
|
||||
isMounted = false;
|
||||
disconnect();
|
||||
resizeObserver?.unobserve(root);
|
||||
});
|
||||
});
|
||||
|
||||
// Debug function to list displays
|
||||
const debugDisplays = async () => {
|
||||
try {
|
||||
const result = await invoke('list_display_info');
|
||||
console.log('Available displays:', result);
|
||||
alert(`Available displays: ${result}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to get display info:', error);
|
||||
alert(`Error: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Status indicator
|
||||
const getStatusColor = () => {
|
||||
switch (connectionStatus()) {
|
||||
case 'connected': return '#10b981'; // green
|
||||
case 'connecting': return '#f59e0b'; // yellow
|
||||
case 'error': return '#ef4444'; // red
|
||||
default: return '#6b7280'; // gray
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root!}
|
||||
{...rootProps}
|
||||
class={'overflow-hidden h-full w-full relative ' + (rootProps.class || '')}
|
||||
>
|
||||
<canvas
|
||||
ref={canvas!}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'background-color': '#f0f0f0'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div class="absolute top-2 right-2 flex items-center gap-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
style={{ 'background-color': getStatusColor() }}
|
||||
/>
|
||||
<span>{connectionStatus()}</span>
|
||||
{connectionStatus() === 'connected' && (
|
||||
<span>| {fps()} FPS | {frameCount()} frames</span>
|
||||
)}
|
||||
<button
|
||||
onClick={debugDisplays}
|
||||
class="ml-2 px-1 py-0.5 bg-blue-600 hover:bg-blue-700 rounded text-xs"
|
||||
title="Debug: Show available displays"
|
||||
>
|
||||
Debug
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rootProps.children}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -7,13 +7,22 @@ import {
|
||||
onMount,
|
||||
splitProps,
|
||||
} from 'solid-js';
|
||||
import { ScreenViewWebSocket } from './screen-view-websocket';
|
||||
|
||||
type ScreenViewProps = {
|
||||
displayId: number;
|
||||
useWebSocket?: boolean;
|
||||
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
const [localProps, rootProps] = splitProps(props, ['displayId']);
|
||||
const [localProps, rootProps] = splitProps(props, ['displayId', 'useWebSocket']);
|
||||
|
||||
// Use WebSocket by default for better performance
|
||||
if (localProps.useWebSocket !== false) {
|
||||
return <ScreenViewWebSocket displayId={localProps.displayId} {...rootProps} />;
|
||||
}
|
||||
|
||||
// Fallback to HTTP polling (legacy mode)
|
||||
let canvas: HTMLCanvasElement;
|
||||
let root: HTMLDivElement;
|
||||
const [ctx, setCtx] = createSignal<CanvasRenderingContext2D | null>(null);
|
||||
@@ -36,18 +45,10 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
let isMounted = true;
|
||||
|
||||
// Fetch screenshot data from backend
|
||||
// Fetch screenshot data from backend with frame-based rendering
|
||||
const fetchScreenshot = async () => {
|
||||
console.log('📸 FETCH: Starting screenshot fetch', {
|
||||
isLoading: isLoading(),
|
||||
isMounted,
|
||||
hidden: hidden(),
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
});
|
||||
|
||||
if (isLoading()) {
|
||||
console.log('⏳ FETCH: Already loading, skipping');
|
||||
return; // Skip if already loading
|
||||
return; // Skip if already loading - frame-based approach
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -57,9 +58,7 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
const response = await fetch(`ambient-light://displays/${localProps.displayId}?width=400&height=225&t=${timestamp}`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ FETCH: Screenshot fetch failed', response.status, response.statusText);
|
||||
const errorText = await response.text();
|
||||
console.error('❌ FETCH: Error response body:', errorText);
|
||||
console.error('Screenshot fetch failed:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,71 +68,43 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
const buffer = new Uint8ClampedArray(arrayBuffer);
|
||||
const expectedSize = width * height * 4;
|
||||
|
||||
|
||||
|
||||
// Validate buffer size
|
||||
if (buffer.length !== expectedSize) {
|
||||
console.error('❌ FETCH: Invalid buffer size!', {
|
||||
received: buffer.length,
|
||||
expected: expectedSize,
|
||||
ratio: buffer.length / expectedSize
|
||||
});
|
||||
console.error('Invalid buffer size:', buffer.length, 'expected:', expectedSize);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📊 FETCH: Setting image data', { width, height, bufferSize: buffer.length });
|
||||
|
||||
setImageData({
|
||||
buffer,
|
||||
width,
|
||||
height
|
||||
});
|
||||
|
||||
// Use setTimeout to ensure the signal update has been processed
|
||||
// Draw immediately after data is set
|
||||
setTimeout(() => {
|
||||
console.log('🖼️ FETCH: Triggering draw after data set');
|
||||
draw(false);
|
||||
}, 0);
|
||||
|
||||
// Schedule next frame after rendering is complete
|
||||
// Frame-based rendering: wait for current frame to complete before scheduling next
|
||||
const shouldContinue = !hidden() && isMounted;
|
||||
console.log('🔄 FETCH: Scheduling next frame', {
|
||||
hidden: hidden(),
|
||||
isMounted,
|
||||
shouldContinue,
|
||||
nextFrameDelay: '1000ms'
|
||||
});
|
||||
|
||||
if (shouldContinue) {
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
console.log('🔄 FETCH: Starting next frame');
|
||||
fetchScreenshot();
|
||||
} else {
|
||||
console.log('❌ FETCH: Component unmounted, stopping loop');
|
||||
fetchScreenshot(); // Start next frame only after current one completes
|
||||
}
|
||||
}, 1000); // Wait 1 second before next frame
|
||||
} else {
|
||||
console.log('❌ FETCH: Loop stopped - component hidden or unmounted');
|
||||
}, 500); // Reduced frequency to 500ms for better performance
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ FETCH: Error fetching screenshot:', error);
|
||||
// Even on error, schedule next frame
|
||||
console.error('Error fetching screenshot:', error);
|
||||
// On error, wait longer before retry
|
||||
const shouldContinueOnError = !hidden() && isMounted;
|
||||
console.log('🔄 FETCH: Error recovery - scheduling next frame', {
|
||||
error: error.message,
|
||||
shouldContinue: shouldContinueOnError,
|
||||
nextFrameDelay: '2000ms'
|
||||
});
|
||||
|
||||
if (shouldContinueOnError) {
|
||||
setTimeout(() => {
|
||||
if (isMounted) {
|
||||
console.log('🔄 FETCH: Retrying after error');
|
||||
fetchScreenshot();
|
||||
}
|
||||
}, 2000); // Wait longer on error
|
||||
}, 2000);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -141,22 +112,10 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
};
|
||||
|
||||
const resetSize = () => {
|
||||
console.log('📏 CANVAS: Resizing', {
|
||||
rootClientWidth: root.clientWidth,
|
||||
rootClientHeight: root.clientHeight,
|
||||
oldCanvasWidth: canvas.width,
|
||||
oldCanvasHeight: canvas.height
|
||||
});
|
||||
|
||||
// Set canvas size first
|
||||
canvas.width = root.clientWidth;
|
||||
canvas.height = root.clientHeight;
|
||||
|
||||
console.log('📏 CANVAS: Size set', {
|
||||
newCanvasWidth: canvas.width,
|
||||
newCanvasHeight: canvas.height
|
||||
});
|
||||
|
||||
// Use a default aspect ratio if canvas dimensions are invalid
|
||||
const aspectRatio = (canvas.width > 0 && canvas.height > 0)
|
||||
? canvas.width / canvas.height
|
||||
@@ -179,30 +138,15 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
drawHeight,
|
||||
});
|
||||
|
||||
|
||||
|
||||
draw(true);
|
||||
};
|
||||
|
||||
const draw = (cached: boolean = false) => {
|
||||
const { drawX, drawY, drawWidth, drawHeight } = drawInfo();
|
||||
|
||||
let _ctx = ctx();
|
||||
let raw = imageData();
|
||||
|
||||
console.log('🖼️ DRAW: Called with', {
|
||||
cached,
|
||||
hasContext: !!_ctx,
|
||||
hasImageData: !!raw,
|
||||
imageDataSize: raw ? `${raw.width}x${raw.height}` : 'none',
|
||||
drawInfo: { drawX, drawY, drawWidth, drawHeight },
|
||||
canvasSize: `${canvas.width}x${canvas.height}`,
|
||||
contextType: _ctx ? 'valid' : 'null',
|
||||
rawBufferSize: raw ? raw.buffer.length : 0
|
||||
});
|
||||
|
||||
if (_ctx && raw) {
|
||||
console.log('🖼️ DRAW: Starting to draw image');
|
||||
_ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apply transparency effect for cached images if needed
|
||||
@@ -220,34 +164,24 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
|
||||
// If the image size matches the draw size, use putImageData directly
|
||||
if (raw.width === drawWidth && raw.height === drawHeight) {
|
||||
console.log('🖼️ DRAW: Using putImageData directly');
|
||||
_ctx.putImageData(img, drawX, drawY);
|
||||
console.log('✅ DRAW: putImageData completed');
|
||||
} else {
|
||||
console.log('🖼️ DRAW: Using scaling with temp canvas');
|
||||
// Otherwise, use cached temporary canvas for scaling
|
||||
if (!tempCanvas || tempCanvas.width !== raw.width || tempCanvas.height !== raw.height) {
|
||||
tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = raw.width;
|
||||
tempCanvas.height = raw.height;
|
||||
tempCtx = tempCanvas.getContext('2d');
|
||||
console.log('🖼️ DRAW: Created new temp canvas');
|
||||
}
|
||||
|
||||
if (tempCtx) {
|
||||
tempCtx.putImageData(img, 0, 0);
|
||||
_ctx.drawImage(tempCanvas, drawX, drawY, drawWidth, drawHeight);
|
||||
console.log('✅ DRAW: Scaled drawing completed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ DRAW: Error in draw():', error);
|
||||
console.error('Error in draw():', error);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ DRAW: Cannot draw - missing context or image data', {
|
||||
hasContext: !!_ctx,
|
||||
hasImageData: !!raw
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -255,11 +189,8 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
|
||||
// Initialize canvas and resize observer
|
||||
onMount(() => {
|
||||
console.log('🚀 CANVAS: Component mounted');
|
||||
const context = canvas.getContext('2d');
|
||||
console.log('🚀 CANVAS: Context obtained', !!context);
|
||||
setCtx(context);
|
||||
console.log('🚀 CANVAS: Context signal set');
|
||||
|
||||
// Initial size setup
|
||||
resetSize();
|
||||
@@ -270,16 +201,13 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
resizeObserver.observe(root);
|
||||
|
||||
// Start screenshot fetching after context is ready
|
||||
console.log('🚀 SCREENSHOT: Starting screenshot fetching');
|
||||
setTimeout(() => {
|
||||
console.log('🚀 SCREENSHOT: Context ready, starting fetch');
|
||||
fetchScreenshot(); // Initial fetch - will self-schedule subsequent frames
|
||||
}, 100); // Small delay to ensure context is ready
|
||||
|
||||
onCleanup(() => {
|
||||
isMounted = false; // Stop scheduling new frames
|
||||
resizeObserver?.unobserve(root);
|
||||
console.log('🧹 CLEANUP: Component unmounted');
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -14,7 +14,7 @@ export const ColorSlider: Component<Props> = (props) => {
|
||||
step={0.01}
|
||||
value={props.value}
|
||||
class={
|
||||
'w-full h-2 bg-gradient-to-r rounded-lg appearance-none cursor-pointer dark:bg-gray-700 drop-shadow ' +
|
||||
'range range-primary w-full bg-gradient-to-r ' +
|
||||
props.class
|
||||
}
|
||||
/>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { Component, createEffect, onCleanup } from 'solid-js';
|
||||
import { Component, createEffect, onCleanup, createSignal } from 'solid-js';
|
||||
import { ColorCalibration, LedStripConfigContainer } from '../../models/led-strip-config';
|
||||
import { ledStripStore, setLedStripStore } from '../../stores/led-strip.store';
|
||||
import { ColorSlider } from './color-slider';
|
||||
@@ -7,18 +7,95 @@ import { TestColorsBg } from './test-colors-bg';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { VsClose } from 'solid-icons/vs';
|
||||
import { BiRegularReset } from 'solid-icons/bi';
|
||||
import { BsFullscreen, BsFullscreenExit } from 'solid-icons/bs';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import transparentBg from '../../assets/transparent-grid-background.svg?url';
|
||||
|
||||
const Value: Component<{ value: number }> = (props) => {
|
||||
return (
|
||||
<span class="w-10 text-sm block font-mono text-right ">
|
||||
{(props.value * 100).toFixed(0)}
|
||||
<span class="text-xs text-stone-600">%</span>
|
||||
</span>
|
||||
<div class="badge badge-outline badge-sm font-mono">
|
||||
{(props.value * 100).toFixed(0)}%
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WhiteBalance = () => {
|
||||
const [isFullscreen, setIsFullscreen] = createSignal(false);
|
||||
const [panelPosition, setPanelPosition] = createSignal({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const [dragOffset, setDragOffset] = createSignal({ x: 0, y: 0 });
|
||||
|
||||
// 自动进入全屏模式
|
||||
createEffect(() => {
|
||||
const autoEnterFullscreen = async () => {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
const currentFullscreen = await window.isFullscreen();
|
||||
if (!currentFullscreen) {
|
||||
await window.setFullscreen(true);
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
setIsFullscreen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to auto enter fullscreen:', error);
|
||||
}
|
||||
};
|
||||
|
||||
autoEnterFullscreen();
|
||||
});
|
||||
|
||||
// 初始化面板位置到屏幕中央
|
||||
createEffect(() => {
|
||||
if (isFullscreen()) {
|
||||
const centerX = window.innerWidth / 2 - 160; // 160是面板宽度的一半
|
||||
const centerY = window.innerHeight / 2 - 200; // 200是面板高度的一半
|
||||
setPanelPosition({ x: centerX, y: centerY });
|
||||
}
|
||||
});
|
||||
|
||||
// 拖拽处理函数
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
setIsDragging(true);
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging()) {
|
||||
const newX = e.clientX - dragOffset().x;
|
||||
const newY = e.clientY - dragOffset().y;
|
||||
|
||||
// 限制面板在屏幕范围内
|
||||
const maxX = window.innerWidth - 320; // 320是面板宽度
|
||||
const maxY = window.innerHeight - 400; // 400是面板高度
|
||||
|
||||
setPanelPosition({
|
||||
x: Math.max(0, Math.min(newX, maxX)),
|
||||
y: Math.max(0, Math.min(newY, maxY))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
createEffect(() => {
|
||||
if (isDragging()) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
} else {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
});
|
||||
|
||||
// listen to config_changed event
|
||||
createEffect(() => {
|
||||
const unlisten = listen('config_changed', (event) => {
|
||||
@@ -32,20 +109,48 @@ export const WhiteBalance = () => {
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unlisten.then((unlisten) => unlisten());
|
||||
onCleanup(async () => {
|
||||
(await unlisten)();
|
||||
});
|
||||
});
|
||||
|
||||
const updateColorCalibration = (field: keyof ColorCalibration, value: number) => {
|
||||
const calibration = { ...ledStripStore.colorCalibration, [field]: value };
|
||||
invoke('set_color_calibration', {
|
||||
calibration,
|
||||
}).catch((error) => console.log(error));
|
||||
const updateColorCalibration = (
|
||||
key: keyof ColorCalibration,
|
||||
value: number,
|
||||
) => {
|
||||
const calibration = { ...ledStripStore.colorCalibration };
|
||||
calibration[key] = value;
|
||||
setLedStripStore('colorCalibration', calibration);
|
||||
invoke('set_color_calibration', { calibration }).catch((error) =>
|
||||
console.log(error),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
const currentFullscreen = await window.isFullscreen();
|
||||
await window.setFullscreen(!currentFullscreen);
|
||||
setIsFullscreen(!currentFullscreen);
|
||||
|
||||
// 退出全屏时重置面板位置
|
||||
if (currentFullscreen) {
|
||||
setPanelPosition({ x: 0, y: 0 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle fullscreen:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const exit = () => {
|
||||
// 退出时确保退出全屏模式
|
||||
if (isFullscreen()) {
|
||||
toggleFullscreen().then(() => {
|
||||
window.history.back();
|
||||
});
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
@@ -55,18 +160,64 @@ export const WhiteBalance = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section class="select-none text-stone-800">
|
||||
<>
|
||||
{/* 普通模式 */}
|
||||
{!isFullscreen() && (
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">白平衡调节</h1>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline btn-sm" onClick={toggleFullscreen} title="进入全屏">
|
||||
<BsFullscreen size={16} />
|
||||
全屏
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" onClick={reset} title="重置到100%">
|
||||
<BiRegularReset size={16} />
|
||||
重置
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onClick={exit} title="返回">
|
||||
<VsClose size={16} />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 颜色测试区域 */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>颜色测试</span>
|
||||
<div class="badge badge-info badge-outline">点击测试</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 bottom-0"
|
||||
class="aspect-square rounded-lg overflow-hidden border border-base-300"
|
||||
style={{
|
||||
'background-image': `url(${transparentBg})`,
|
||||
}}
|
||||
>
|
||||
<TestColorsBg />
|
||||
</div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10/12 max-w-lg bg-stone-100/20 backdrop-blur p-5 rounded-xl shadow-lg">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">R:</span>
|
||||
<div class="text-xs text-base-content/50 mt-2">
|
||||
💡 提示:点击颜色块进行单色测试,再次点击返回多色模式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 白平衡控制面板 */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>RGB调节</span>
|
||||
<div class="badge badge-secondary badge-outline">实时调节</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-red-500">红色 (R)</span>
|
||||
<Value value={ledStripStore.colorCalibration.r} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-cyan-500 to-red-500"
|
||||
value={ledStripStore.colorCalibration.r}
|
||||
@@ -77,10 +228,13 @@ export const WhiteBalance = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Value value={ledStripStore.colorCalibration.r} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-green-500">绿色 (G)</span>
|
||||
<Value value={ledStripStore.colorCalibration.g} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">G:</span>
|
||||
<ColorSlider
|
||||
class="from-pink-500 to-green-500"
|
||||
value={ledStripStore.colorCalibration.g}
|
||||
@@ -91,10 +245,13 @@ export const WhiteBalance = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Value value={ledStripStore.colorCalibration.g} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-blue-500">蓝色 (B)</span>
|
||||
<Value value={ledStripStore.colorCalibration.b} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">B:</span>
|
||||
<ColorSlider
|
||||
class="from-yellow-500 to-blue-500"
|
||||
value={ledStripStore.colorCalibration.b}
|
||||
@@ -105,27 +262,171 @@ export const WhiteBalance = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Value value={ledStripStore.colorCalibration.b} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-base-content/70">白色 (W)</span>
|
||||
<div class="badge badge-outline badge-sm">暂未启用</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">W:</span>
|
||||
<ColorSlider class="from-yellow-50 to-cyan-50" />
|
||||
</label>
|
||||
<button
|
||||
class="absolute -right-4 -top-4 rounded-full aspect-square bg-stone-100/20 backdrop-blur p-1 shadow hover:bg-stone-200/20 active:bg-stone-300"
|
||||
onClick={exit}
|
||||
title="Go Back"
|
||||
<ColorSlider class="from-yellow-50 to-cyan-50" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 - 可展开 */}
|
||||
<div class="collapse collapse-arrow bg-base-100 mt-4">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title text-sm font-medium text-base-content/80">
|
||||
💡 白平衡调节使用说明
|
||||
</div>
|
||||
<div class="collapse-content text-xs text-base-content/70 space-y-3">
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold text-primary">🎯 推荐使用方法:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>点击上方"全屏"按钮进入全屏模式</li>
|
||||
<li>全屏模式下屏幕边缘会显示彩色条带</li>
|
||||
<li>将RGB控制面板拖拽到合适位置</li>
|
||||
<li>对比LED灯条颜色与屏幕边缘颜色</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold text-secondary">🔧 调节技巧:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li><span class="text-red-500 font-medium">红色偏强</span>:降低R值,LED会减少红色成分</li>
|
||||
<li><span class="text-green-500 font-medium">绿色偏强</span>:降低G值,LED会减少绿色成分</li>
|
||||
<li><span class="text-blue-500 font-medium">蓝色偏强</span>:降低B值,LED会减少蓝色成分</li>
|
||||
<li><span class="text-base-content font-medium">白色发黄</span>:适当提高B值,降低R/G值</li>
|
||||
<li><span class="text-base-content font-medium">白色发蓝</span>:适当降低B值,提高R/G值</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold text-accent">📋 对比方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>重点观察白色区域,确保LED白光与屏幕白色一致</li>
|
||||
<li>检查彩色区域,确保LED颜色饱和度合适</li>
|
||||
<li>在不同环境光下测试,确保效果稳定</li>
|
||||
<li>调节完成后可点击"重置"按钮恢复默认值</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全屏模式 */}
|
||||
{isFullscreen() && (
|
||||
<div class="fixed inset-0 w-screen h-screen bg-black z-50">
|
||||
{/* 全屏颜色测试区域 - 紧贴边缘 */}
|
||||
<div class="absolute inset-0 w-full h-full">
|
||||
<TestColorsBg />
|
||||
</div>
|
||||
|
||||
{/* 可拖拽的RGB控制面板 */}
|
||||
<div
|
||||
class="fixed w-80 bg-base-200/95 backdrop-blur-sm rounded-lg shadow-xl z-60 cursor-move select-none"
|
||||
style={{
|
||||
left: `${panelPosition().x}px`,
|
||||
top: `${panelPosition().y}px`,
|
||||
transform: 'none'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<VsClose size={24} />
|
||||
</button>
|
||||
<button
|
||||
class="absolute -right-4 -bottom-4 rounded-full aspect-square bg-stone-100/20 backdrop-blur p-1 shadow hover:bg-stone-200/20 active:bg-stone-300"
|
||||
onClick={reset}
|
||||
title="Reset to 100%"
|
||||
>
|
||||
<BiRegularReset size={24} />
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs opacity-60">⋮⋮</span>
|
||||
<span>RGB调节</span>
|
||||
<div class="badge badge-secondary badge-outline">可拖拽</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" onClick={toggleFullscreen} title="退出全屏">
|
||||
<BsFullscreenExit size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-red-500">红色 (R)</span>
|
||||
<Value value={ledStripStore.colorCalibration.r} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-cyan-500 to-red-500"
|
||||
value={ledStripStore.colorCalibration.r}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'r',
|
||||
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-green-500">绿色 (G)</span>
|
||||
<Value value={ledStripStore.colorCalibration.g} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-pink-500 to-green-500"
|
||||
value={ledStripStore.colorCalibration.g}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'g',
|
||||
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-blue-500">蓝色 (B)</span>
|
||||
<Value value={ledStripStore.colorCalibration.b} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-yellow-500 to-blue-500"
|
||||
value={ledStripStore.colorCalibration.b}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'b',
|
||||
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-base-content/70">白色 (W)</span>
|
||||
<div class="badge badge-outline badge-sm">暂未启用</div>
|
||||
</label>
|
||||
<ColorSlider class="from-yellow-50 to-cyan-50" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-base-content/60 mt-3 p-2 bg-base-300/50 rounded">
|
||||
💡 对比屏幕边缘颜色与LED灯条,调节RGB滑块使颜色一致
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-outline btn-sm flex-1" onClick={reset} title="重置到100%">
|
||||
<BiRegularReset size={14} />
|
||||
重置
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm flex-1" onClick={exit} title="返回">
|
||||
<VsClose size={14} />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@@ -1,3 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@config "../tailwind.config.js";
|
@@ -1,9 +1,20 @@
|
||||
import daisyui from 'daisyui';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
mode: 'jit',
|
||||
export default {
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [daisyui],
|
||||
daisyui: {
|
||||
themes: ["dark", "light"],
|
||||
darkTheme: "dark",
|
||||
base: true,
|
||||
styled: true,
|
||||
utils: true,
|
||||
prefix: "",
|
||||
logs: true,
|
||||
themeRoot: ":root",
|
||||
},
|
||||
};
|
||||
|
@@ -6,8 +6,14 @@ const mobile =
|
||||
process.env.TAURI_PLATFORM === "ios";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [solidPlugin()],
|
||||
export default defineConfig(async () => {
|
||||
const tailwindcss = (await import("@tailwindcss/vite")).default;
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
solidPlugin(),
|
||||
tailwindcss(),
|
||||
],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
// prevent vite from obscuring rust errors
|
||||
@@ -28,4 +34,5 @@ export default defineConfig(async () => ({
|
||||
// produce sourcemaps for debug builds
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|