50 Commits

Author SHA1 Message Date
3a44b96621 Update README.md with comprehensive project documentation
- Replace default Tauri template content with detailed project description
- Add comprehensive feature list and tech stack information
- Include complete installation and development setup guide
- Document application interface and configuration details
- Add development workflow and debugging tips
- Provide contributing guidelines and support information
- Write documentation in English for international accessibility
2025-07-04 21:57:36 +08:00
5da81e5f93 Fix resource leak and CPU performance issues
- Fix integer underflow panic in LED color publisher by adding bounds checking
- Reduce screenshot capture frequency from 15 FPS to 5 FPS for better CPU performance
- Reduce WebSocket force-send frequency from 200ms to 500ms
- Fix WebSocket resource leak by properly cleaning up streams when connections end
- Add proper stream lifecycle management with is_running flag checks
- Ensure background tasks exit when streams are stopped

This resolves the issue where CPU usage remained above 100% after visiting
the LED strip configuration page, even when navigating to other pages.
2025-07-04 21:49:05 +08:00
a10fae75d2 Refactor LED strip configuration interface layout
- Separate LED control panels from display preview areas
- Add dedicated LED count control section at bottom of page
- Create new LedCountControlPanel component with 4-column grid layout
- Fix display container height to prevent layout overflow
- Remove embedded LED controls from DisplayView component
- Improve text color for display info panel title
- Hide spinner buttons on number inputs for cleaner UI
- Enhance input field styling with centered text and larger font
2025-07-04 19:13:35 +08:00
5f12b8312a feat: enhance white balance interface with expandable help content
- Add comprehensive expandable help section in normal mode with detailed instructions
- Include usage recommendations, adjustment tips, and comparison methods
- Add simplified tooltip in fullscreen mode for quick reference
- Improve user guidance for LED strip color calibration process
- Maintain dual-mode functionality (normal/fullscreen) with appropriate help content
2025-07-04 18:31:44 +08:00
1944c88b55 Optimize screen streaming performance and clean up debug logs
- Reduced image processing time from 7-8 seconds to 340-420ms (15-20x improvement)
- Optimized BGRA->RGBA conversion with unsafe pointer operations and batch processing
- Changed image resize filter from Lanczos3 to Nearest for maximum speed
- Reduced target resolution from 400x225 to 320x180 for better performance
- Reduced JPEG quality from 75 to 50 for faster compression
- Fixed force-send mechanism timing from 500ms to 200ms intervals
- Improved frame rate from 0 FPS to ~2.5 FPS
- Cleaned up extensive debug logging and performance instrumentation
- Removed unused imports and variables to reduce compiler warnings
2025-07-04 14:45:50 +08:00
c8db28168c feat: Add Daisy-UI and optimize LED strip configuration UI
- Install and configure Tailwind CSS 4.1 with Daisy-UI plugin
- Redesign main navigation with responsive navbar and dark theme
- Optimize LED strip configuration layout with modern card components
- Improve screen preview performance with frame-based rendering
- Reduce LED pixel size for better visual appearance
- Remove excessive debug logging for better performance
- Fix Tailwind CSS ESM compatibility issues with dynamic imports
2025-07-03 13:28:19 +08:00
93ad9ae46c feat: implement real-time LED strip preview
- Add LED strip visualization around display previews
- Show real-time color status for each LED pixel
- Support multi-display LED strip configurations
- Use elegant 16px thin LED strip design
- Real-time LED color sync via WebSocket
- Responsive layout with display scaling support
2025-07-03 02:09:19 +08:00
6c30a824b0 feat: upgrade Tailwind CSS to v4.1.11
- Upgrade Tailwind CSS from 3.x to 4.x for latest features and performance
- Install @tailwindcss/postcss plugin for Tailwind CSS 4.0 compatibility
- Update CSS configuration to use new @import and @config syntax
- Update PostCSS configuration to use new plugin format
- Build working correctly with new Tailwind CSS engine
2025-06-30 18:01:26 +08:00
515b3a4ccb feat: upgrade Vite to v6.3.5
- Upgrade Vite from 4.x to 6.x for better performance and features
- Update @types/node to v24.0.7 for compatibility
- Maintain compatibility with vite-plugin-solid 2.11.7
- Build and development server working correctly
2025-06-30 17:51:53 +08:00
ddf61c861d feat: update dependencies to latest compatible versions
- Update frontend dependencies (SolidJS, Vite, Tailwind, etc.)
- Update backend dependencies (Tauri 1.8.3, Tokio, Serde, etc.)
- Fix thread safety issues with SafeDisplay wrapper for ddc-hi::Display
- Resolve display-info API compatibility issues
- All dependencies updated within major version constraints
2025-06-30 17:35:03 +08:00
b1fd751090 Fix LED color events and improve screenshot capture
- Fix LED color publisher: uncomment display_colors_tx.send() to enable LED color events
- Replace rust_swift_screencapture with screen-capture-kit for better macOS compatibility
- Add bounds checking in LED color processing to prevent array index errors
- Update screenshot manager to use CGDisplay as fallback implementation
- Fix frontend screenshot URL protocol to use ambient-light://
- Add debug logging for LED color events in frontend
- Remove debug logs that were added for troubleshooting
- Update dependencies and remove CMake-dependent paho-mqtt temporarily

This resolves the issue where LED color events were not being sent to the frontend,
enabling real-time LED color visualization in the UI.
2025-06-30 14:35:03 +08:00
91983e6728 feat: 电脑睡眠后唤醒,支持重新开始捕捉屏幕内容。 2023-06-17 17:36:32 +08:00
bab3b8941e fix: 临时避免 CPU 占用率高的问题。 2023-06-10 21:09:36 +08:00
268ec1df81 feat: 使用 ScreenCaptureKit 获取屏幕帧数据。 2023-06-05 22:34:32 +08:00
ed72bdfdb1 feat: 改用 udp 向板子发送颜色校准信息。 2023-05-12 20:38:24 +08:00
98d2f7891a feat: 支持定期向板子发送显示器亮度信息。 2023-05-11 21:52:50 +08:00
3a23e1760b feat: 支持记住显示器配置。 2023-05-11 14:13:14 +08:00
8b124f8182 feat: 当新板子上线或音量变化时,推送当前音量给板子。 2023-05-10 21:50:51 +08:00
878180ed5b fix: 亮度调节指令频繁时通道被关闭。 2023-05-10 21:18:55 +08:00
5ddd704c9d chore: 完善。 2023-05-09 21:57:52 +08:00
2c5ac11579 feat: 支持设置音量。 2023-05-07 18:18:34 +08:00
9109518822 chore: 清理代码。 2023-05-07 15:21:27 +08:00
d9d73f01d7 feat: 支持控制显示器参数。 2023-05-07 14:48:06 +08:00
239144a446 feat: 支持调整程序内存中暂存的显示器配置。 2023-05-07 14:32:31 +08:00
3a430716d6 feat: 支持读取显示器配置。 2023-05-07 09:56:00 +08:00
800c0d3fc4 feat: 支持列出显示器。 2023-05-07 01:18:48 +08:00
091bcf33da feat: 支持收取来自板子要求的显示器亮度和电脑音量。 2023-05-06 20:14:31 +08:00
9b863508e4 build: update deps. 2023-05-04 23:31:39 +08:00
174840403f pref: 调整发送数据的逻辑,改善丢包问题。 2023-05-04 21:56:56 +08:00
ca9a2ba34d feat: skip send colors for disconnected board. 2023-04-30 22:48:25 +08:00
82d4adfe0f feat: 增强连接状态。 2023-04-30 22:30:24 +08:00
6c90a5e655 feat: 支持获取和查看板子连接的情况。 2023-04-30 18:44:26 +08:00
11045f27d8 feat: 通过新的 udp 逻辑发送灯带颜色。 2023-04-29 18:07:21 +08:00
f6e3257670 feat: 前端显示 mdns 搜索到的板子连接信息。 2023-04-29 15:09:45 +08:00
e5527ce3c3 feat: mdns search. 2023-04-29 12:40:34 +08:00
3deb14823d chore: 改为单独 task 推送灯条颜色。 2023-04-28 21:24:46 +08:00
7a87748cf1 feat: 使用 UDP 发送颜色。 2023-04-28 00:26:49 +08:00
9d11abfa6e chore: ignore .DS_Store. 2023-04-20 14:57:48 +08:00
d97eb0115f feat: 完善颜色校准 GUI。 2023-04-16 23:45:07 +08:00
effcb1e192 chore: 期望以 30 fps 捕获屏幕。 2023-04-16 23:15:26 +08:00
1c08c17fd4 feat: 支持将校准的色彩发送到 MQTT 中。 2023-04-16 21:55:24 +08:00
81d666557b chore: clean code. 2023-04-16 18:23:56 +08:00
6e6160fc0a feat: 支持将色彩校准的值写入本地配置文件。 2023-04-16 18:17:49 +08:00
fc8b3164d8 feat(GUI): 色彩调整界面。 2023-04-16 12:53:03 +08:00
932cc78bcf chore: GUI 增加路由。 2023-04-15 18:58:40 +08:00
782f3bf029 fix: wrong sample points on mac os 13. 2023-04-15 13:45:30 +08:00
09799cb2d5 fix: 修复灯带顺序控件不能很好地被控制。 2023-04-15 11:26:41 +08:00
a905c98823 fix: 更新配置时无法应用配置到灯带颜色获取逻辑。 2023-04-14 22:18:59 +08:00
9cbccedc72 fix: wrong sample points on mac os 13. 2023-04-14 21:27:14 +08:00
aa7430c54e build: update deps. 2023-04-12 23:43:41 +08:00
87 changed files with 15203 additions and 3981 deletions

3
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules
dist
dist
.DS_Store

View File

@ -2,6 +2,10 @@
"files.autoSave": "onWindowChange",
"cSpell.words": [
"Itertools",
"Leds"
]
"Leds",
"unlisten"
],
"idf.customExtraVars": {
"OPENOCD_SCRIPTS": "/Users/ivan/.espressif/tools/openocd-esp32/v0.11.0-esp32-20211220/openocd-esp32/share/openocd/scripts"
}
}

27
.vscode/tasks.json vendored
View File

@ -3,15 +3,29 @@
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "dev",
"type": "shell",
"isBackground": true,
"command": "pnpm",
"args": [
"tauri",
"dev"
],
"problemMatcher": [
"$eslint-stylish"
],
"options": {
"env": {
"RUST_LOG": "info"
}
}
},
{
"label": "ui:dev",
"type": "shell",
// `dev` keeps running in the background
// ideally you should also configure a `problemMatcher`
// see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson
"isBackground": true,
// change this to your `beforeDevCommand`:
"command": "yarn",
"command": "pnpm",
"args": [
"dev"
]
@ -19,8 +33,7 @@
{
"label": "ui:build",
"type": "shell",
// change this to your `beforeBuildCommand`:
"command": "yarn",
"command": "pnpm",
"args": [
"build"
]

189
README.md
View File

@ -1,7 +1,188 @@
# Tauri + Solid + Typescript
# Display Ambient Light Desktop App
This template should help get you started developing with Tauri, Solid and Typescript in Vite.
A desktop application built with Tauri 2.0 for ambient light control, supporting multi-monitor screen sampling and LED strip control to create immersive ambient lighting effects.
## Recommended IDE Setup
## ✨ Features
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
- 🖥️ **Multi-Monitor Support** - Automatic detection and configuration of multiple displays
- 🎨 **Real-time Screen Sampling** - High-performance screen content capture and color analysis
- 💡 **LED Strip Control** - Configurable LED strip layout and mapping support
- ⚖️ **White Balance Calibration** - Built-in white balance adjustment tool with fullscreen mode
- 🎛️ **Intuitive Configuration Interface** - Modern UI with drag-and-drop configuration support
- 🔧 **Hardware Integration** - Display brightness control and audio device management
- 📡 **Network Communication** - UDP and WebSocket communication support
## 🛠️ Tech Stack
### Frontend
- **Framework**: [Solid.js](https://solidjs.com/) - High-performance reactive UI framework
- **Build Tool**: [Vite](https://vitejs.dev/) - Fast frontend build tool
- **Styling**: [Tailwind CSS](https://tailwindcss.com/) + [DaisyUI](https://daisyui.com/) - Modern UI component library
- **Routing**: [@solidjs/router](https://github.com/solidjs/solid-router) - Client-side routing
- **Language**: TypeScript - Type-safe JavaScript
### Backend
- **Framework**: [Tauri 2.0](https://tauri.app/) - Cross-platform desktop app framework
- **Language**: Rust - High-performance systems programming language
- **Screen Capture**: [screen-capture-kit](https://crates.io/crates/screen-capture-kit) - macOS native screen capture
- **Display Control**: [ddc-hi](https://crates.io/crates/ddc-hi) - DDC/CI display control
- **Audio**: [coreaudio-rs](https://crates.io/crates/coreaudio-rs) - macOS audio system integration
- **Networking**: tokio + tokio-tungstenite - Async network communication
## 📋 System Requirements
- **Operating System**: macOS 13.0+ (primary supported platform)
- **Memory**: 4GB+ recommended
- **Graphics**: Hardware-accelerated graphics card
- **Network**: For device discovery and communication
## 🚀 Quick Start
### Prerequisites
1. **Install Rust**
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
```
2. **Install Node.js and pnpm**
```bash
# Install Node.js (recommended using nvm)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install node
# Install pnpm
npm install -g pnpm
```
3. **Install Tauri CLI**
```bash
cargo install @tauri-apps/cli@next
```
### Development Setup
1. **Clone the project**
```bash
git clone <repository-url>
cd display-ambient-light/desktop
```
2. **Install dependencies**
```bash
pnpm install
```
3. **Start development server**
```bash
pnpm tauri dev
```
### Production Build
```bash
# Build the application
pnpm tauri build
# Build artifacts are located in src-tauri/target/release/bundle/
```
## 📱 Application Interface
### Main Pages
1. **System Info** (`/info`) - Display system and hardware information
2. **Display Info** (`/displays`) - Monitor status and configuration
3. **LED Strip Configuration** (`/led-strips-configuration`) - LED strip layout and mapping configuration
4. **White Balance** (`/white-balance`) - Color calibration and white balance adjustment
### Core Features
- **Real-time Screen Preview** - WebSocket streaming of screen content
- **LED Mapping Configuration** - Visual configuration of LED strip positions and quantities
- **Color Calibration** - RGB adjustment panel with fullscreen comparison mode
- **Device Management** - Automatic discovery and management of LED control devices
## 🔧 Configuration Files
Application configuration is stored in the user directory:
```text
~/Library/Application Support/cc.ivanli.ambient-light.desktop/
├── config.toml # Main configuration file
├── led_strips.json # LED strip configuration
└── color_calibration.json # Color calibration data
```
## 🎯 Development Guide
### Project Structure
```text
desktop/
├── src/ # Frontend source code (Solid.js)
│ ├── components/ # UI components
│ ├── stores/ # State management
│ ├── models/ # Data models
│ └── contexts/ # React Context
├── src-tauri/ # Backend source code (Rust)
│ ├── src/
│ │ ├── ambient_light/ # Ambient light control
│ │ ├── display/ # Display management
│ │ ├── rpc/ # Network communication
│ │ └── screenshot/ # Screen capture
│ └── tauri.conf.json # Tauri configuration
└── package.json # Frontend dependencies
```
### Development Workflow
1. **Frontend Development**: Modify files under `src/`, supports hot reload
2. **Backend Development**: Modify files under `src-tauri/src/`, requires dev server restart
3. **Configuration Changes**: Restart required after modifying `tauri.conf.json`
### Debugging Tips
- Use browser developer tools to debug frontend
- Use `console.log` and Rust's `println!` for debugging
- Check Tauri console output for backend logs
## 🤝 Contributing
1. Fork the project
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🔗 Related Links
- [Tauri Official Documentation](https://tauri.app/)
- [Solid.js Official Documentation](https://solidjs.com/)
- [Rust Official Documentation](https://doc.rust-lang.org/)
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
## 📞 Support
If you encounter issues or have suggestions, please:
- Create an [Issue](../../issues)
- Check the [Wiki](../../wiki) for more information
- Contact the developer
---
**Note**: This application is primarily optimized for macOS platform, support for other platforms may be limited.

16
debug_displays.rs Normal file
View 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);
}
}
}

View File

@ -11,19 +11,26 @@
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^1.2.0",
"solid-js": "^1.4.7",
"@solidjs/router": "^0.8.4",
"@tauri-apps/api": "^2.6.0",
"debug": "^4.4.1",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.7",
"solid-tippy": "^0.2.1",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.7",
"typescript": "^4.7.4",
"vite": "^4.0.0",
"vite-plugin-solid": "^2.3.0"
"@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",
"vite": "^6.3.5",
"vite-plugin-solid": "^2.11.7"
}
}

2703
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

4692
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,14 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "1.2", features = ["shell-open"] }
tauri = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-shell = "2.0"
serde = { version = "1.0", features = ["derive"] }
dirs = "5.0"
regex = "1.0"
serde_json = "1.0"
core-graphics = "0.22.3"
display-info = "0.4.1"
@ -28,10 +31,20 @@ url-build-parse = "9.0.0"
color_space = "0.5.3"
hex = "0.4.3"
toml = "0.7.3"
paho-mqtt = "0.12.1"
time = {version="0.3.20", features= ["formatting"] }
# paho-mqtt = "0.12.1" # Temporarily disabled due to CMake issues
time = {version="0.3.35", features= ["formatting"] }
itertools = "0.10.5"
core-foundation = "0.9.3"
tokio-stream = "0.1.14"
mdns-sd = "0.7.2"
futures = "0.3.28"
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

View File

@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main application window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen"
]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3
src-tauri/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
/target/

View 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" ]

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View 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");
}

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

View File

@ -3,9 +3,8 @@ use std::env::current_dir;
use display_info::DisplayInfo;
use paris::{error, info};
use serde::{Deserialize, Serialize};
use tauri::api::path::config_dir;
use crate::screenshot::{self, LedSamplePoints};
use crate::screenshot::LedSamplePoints;
const CONFIG_FILE_NAME: &str = "cc.ivanli.ambient_light/led_strip_config.toml";
@ -26,10 +25,28 @@ pub struct LedStripConfig {
pub len: usize,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct ColorCalibration {
r: f32,
g: f32,
b: f32,
}
impl ColorCalibration {
pub fn to_bytes(&self) -> [u8; 3] {
[
(self.r * 255.0) as u8,
(self.g * 255.0) as u8,
(self.b * 255.0) as u8,
]
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct LedStripConfigGroup {
pub strips: Vec<LedStripConfig>,
pub mappers: Vec<SamplePointMapper>,
pub color_calibration: ColorCalibration,
}
impl LedStripConfigGroup {
@ -37,7 +54,7 @@ impl LedStripConfigGroup {
let displays = DisplayInfo::all()?;
// config path
let path = config_dir()
let path = dirs::config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
@ -65,7 +82,7 @@ impl LedStripConfigGroup {
}
pub async fn write_config(configs: &Self) -> anyhow::Result<()> {
let path = config_dir()
let path = dirs::config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
@ -115,7 +132,17 @@ impl LedStripConfigGroup {
})
}
}
Ok(Self { strips, mappers })
let color_calibration = ColorCalibration {
r: 1.0,
g: 1.0,
b: 1.0,
};
Ok(Self {
strips,
mappers,
color_calibration,
})
}
}

View File

@ -1,15 +1,14 @@
use std::{borrow::BorrowMut, sync::Arc};
use tauri::async_runtime::RwLock;
use tokio::sync::OnceCell;
use tokio::{sync::OnceCell, task::yield_now};
use crate::ambient_light::{config, LedStripConfigGroup};
use super::{Border, SamplePointMapper};
use super::{Border, SamplePointMapper, ColorCalibration};
pub struct ConfigManager {
config: Arc<RwLock<LedStripConfigGroup>>,
config_update_receiver: tokio::sync::watch::Receiver<LedStripConfigGroup>,
config_update_sender: tokio::sync::watch::Sender<LedStripConfigGroup>,
}
@ -22,10 +21,12 @@ impl ConfigManager {
let (config_update_sender, config_update_receiver) =
tokio::sync::watch::channel(configs.clone());
config_update_sender.send(configs.clone()).unwrap();
if let Err(err) = config_update_sender.send(configs.clone()) {
log::error!("Failed to send config update when read config first time: {}", err);
}
drop(config_update_receiver);
ConfigManager {
config: Arc::new(RwLock::new(configs)),
config_update_receiver,
config_update_sender,
}
})
@ -46,8 +47,9 @@ impl ConfigManager {
self.config_update_sender
.send(configs.clone())
.map_err(|e| anyhow::anyhow!("Failed to send config update: {}", e))?;
yield_now().await;
// log::info!("config updated: {:?}", configs);
log::debug!("config updated: {:?}", configs);
Ok(())
}
@ -221,6 +223,17 @@ impl ConfigManager {
pub fn clone_config_update_receiver(
&self,
) -> tokio::sync::watch::Receiver<LedStripConfigGroup> {
self.config_update_receiver.clone()
self.config_update_sender.subscribe()
}
pub async fn set_color_calibration(&self, color_calibration: ColorCalibration) -> anyhow::Result<()> {
let config = self.config.write().await;
let mut cloned_config = config.clone();
cloned_config.color_calibration = color_calibration;
drop(config);
self.update(&cloned_config).await
}
}

View File

@ -1,30 +1,30 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use std::{borrow::Borrow, collections::HashMap, sync::Arc, time::Duration};
use paris::warn;
use tauri::async_runtime::RwLock;
use tokio::{
net::UdpSocket,
sync::{broadcast, watch},
time::sleep,
};
use crate::{
ambient_light::{config, ConfigManager},
rpc::MqttRpc,
screenshot::LedSamplePoints,
led_color::LedColor,
rpc::UdpRpc,
screenshot::{self, LedSamplePoints},
screenshot_manager::{self, ScreenshotManager},
};
use itertools::Itertools;
use super::{LedStripConfigGroup, SamplePointConfig, SamplePointMapper};
use super::{LedStripConfigGroup, SamplePointMapper};
pub struct LedColorsPublisher {
sorted_colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
sorted_colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
display_colors_rx: Arc<RwLock<broadcast::Receiver<(u32, Vec<u8>)>>>,
display_colors_tx: Arc<RwLock<broadcast::Sender<(u32, Vec<u8>)>>>,
inner_tasks_version: Arc<RwLock<usize>>,
}
@ -35,7 +35,6 @@ impl LedColorsPublisher {
let (sorted_tx, sorted_rx) = watch::channel(Vec::new());
let (tx, rx) = watch::channel(Vec::new());
let (display_colors_tx, display_colors_rx) = broadcast::channel(8);
LED_COLORS_PUBLISHER_GLOBAL
.get_or_init(|| async {
@ -44,67 +43,54 @@ impl LedColorsPublisher {
sorted_colors_tx: Arc::new(RwLock::new(sorted_tx)),
colors_rx: Arc::new(RwLock::new(rx)),
colors_tx: Arc::new(RwLock::new(tx)),
display_colors_rx: Arc::new(RwLock::new(display_colors_rx)),
display_colors_tx: Arc::new(RwLock::new(display_colors_tx)),
inner_tasks_version: Arc::new(RwLock::new(0)),
}
})
.await
}
fn start_one_display_colors_fetcher(
async fn start_one_display_colors_fetcher(
&self,
display_id: u32,
sample_points: Vec<Vec<LedSamplePoints>>,
sample_points: Vec<LedSamplePoints>,
bound_scale_factor: f32,
mappers: Vec<SamplePointMapper>,
display_colors_tx: broadcast::Sender<(u32, Vec<u8>)>,
) {
let display_colors_tx = self.display_colors_tx.clone();
let internal_tasks_version = self.inner_tasks_version.clone();
let screenshot_manager = ScreenshotManager::global().await;
let screenshot_rx = screenshot_manager.subscribe_by_display_id(display_id).await;
if let Err(err) = screenshot_rx {
log::error!("{}", err);
return;
}
let mut screenshot_rx = screenshot_rx.unwrap();
tokio::spawn(async move {
let display_colors_tx = display_colors_tx.read().await.clone();
let colors = screenshot_manager::get_display_colors(display_id, &sample_points);
if let Err(err) = colors {
warn!("Failed to get colors: {}", err);
return;
}
let mut start: tokio::time::Instant = tokio::time::Instant::now();
let mut interval = tokio::time::interval(Duration::from_millis(66));
let init_version = internal_tasks_version.read().await.clone();
loop {
interval.tick().await;
tokio::time::sleep(Duration::from_millis(1)).await;
while screenshot_rx.changed().await.is_ok() {
let screenshot = screenshot_rx.borrow().clone();
let colors = screenshot.get_colors_by_sample_points(&sample_points).await;
if internal_tasks_version.read().await.clone() != init_version {
log::info!(
"inner task version changed, stop. {} != {}",
internal_tasks_version.read().await.clone(),
init_version
);
let colors_copy = colors.clone();
break;
let mappers = mappers.clone();
match Self::send_colors_by_display(colors, mappers).await {
Ok(_) => {
// log::info!("sent colors: #{: >15}", display_id);
}
Err(err) => {
warn!("Failed to send colors: #{: >15}\t{}", display_id, err);
}
}
// log::info!("tick: {}ms", start.elapsed().as_millis());
start = tokio::time::Instant::now();
let colors = screenshot_manager::get_display_colors(display_id, &sample_points);
if let Err(err) = colors {
warn!("Failed to get colors: {}", err);
sleep(Duration::from_millis(100)).await;
continue;
}
let colors = colors.unwrap();
let color_len = colors.len();
match display_colors_tx.send((
display_id,
colors
colors_copy
.into_iter()
.map(|color| color.get_rgb())
.flatten()
@ -117,34 +103,48 @@ impl LedColorsPublisher {
warn!("Failed to send display_colors: {}", err);
}
};
// Check if the inner task version changed
let version = internal_tasks_version.read().await.clone();
if version != init_version {
log::info!(
"inner task version changed, stop. {} != {}",
internal_tasks_version.read().await.clone(),
init_version
);
break;
}
}
});
}
fn start_all_colors_worker(&self, display_ids: Vec<u32>, mappers: Vec<SamplePointMapper>) {
fn start_all_colors_worker(
&self,
display_ids: Vec<u32>,
mappers: Vec<SamplePointMapper>,
mut display_colors_rx: broadcast::Receiver<(u32, Vec<u8>)>,
) {
let sorted_colors_tx = self.sorted_colors_tx.clone();
let colors_tx = self.colors_tx.clone();
let display_colors_rx = self.display_colors_rx.clone();
tokio::spawn(async move {
for _ in 0..10 {
let mut rx = display_colors_rx.read().await.resubscribe();
let sorted_colors_tx = sorted_colors_tx.write().await;
let colors_tx = colors_tx.write().await;
let mut all_colors: Vec<Option<Vec<u8>>> = vec![None; display_ids.len()];
let mut start: tokio::time::Instant = tokio::time::Instant::now();
log::info!("start all_colors_worker");
loop {
// log::info!("display_colors_rx changed");
let color_info = rx.recv().await;
let color_info = display_colors_rx.recv().await;
if let Err(err) = color_info {
match err {
broadcast::error::RecvError::Closed => {
break;
return;
}
broadcast::error::RecvError::Lagged(_) => {
warn!("display_colors_rx lagged");
@ -186,7 +186,7 @@ impl LedColorsPublisher {
warn!("Failed to send sorted colors: {}", err);
}
};
log::info!("tick: {}ms", start.elapsed().as_millis());
start = tokio::time::Instant::now();
}
}
@ -194,170 +194,165 @@ impl LedColorsPublisher {
});
}
pub fn start(&self) {
let inner_tasks_version = self.inner_tasks_version.clone();
pub async fn start(&self) {
tokio::spawn(async move {
let publisher = Self::global().await;
let mut inner_tasks_version = inner_tasks_version.write().await;
*inner_tasks_version = inner_tasks_version.overflowing_add(1).0;
let config_manager = ConfigManager::global().await;
let mut config_receiver = config_manager.clone_config_update_receiver();
let configs = config_receiver.borrow().clone();
let config_manager = ConfigManager::global().await;
let mut config_receiver = config_manager.clone_config_update_receiver();
self.handle_config_change(configs).await;
log::info!("waiting for config update...");
while config_receiver.changed().await.is_ok() {
log::info!("config updated, restart inner tasks...");
let configs = config_receiver.borrow().clone();
let configs = Self::get_colors_configs(&configs).await;
if let Err(err) = configs {
warn!("Failed to get configs: {}", err);
sleep(Duration::from_millis(100)).await;
continue;
}
let configs = configs.unwrap();
for sample_point_group in configs.sample_point_groups.clone() {
let display_id = sample_point_group.display_id;
let sample_points = sample_point_group.points;
publisher.start_one_display_colors_fetcher(display_id, sample_points);
}
let display_ids = configs.sample_point_groups;
publisher.start_all_colors_worker(
display_ids.iter().map(|c| c.display_id).collect(),
configs.mappers,
);
break;
}
});
// tokio::spawn(async move {
// loop {
// let sorted_colors_tx = sorted_colors_tx.write().await;
// let colors_tx = colors_tx.write().await;
// let screenshot_manager = ScreenshotManager::global().await;
// let config_manager = ConfigManager::global().await;
// let config_receiver = config_manager.clone_config_update_receiver();
// let configs = config_receiver.borrow().clone();
// let configs = Self::get_colors_configs(&configs).await;
// if let Err(err) = configs {
// warn!("Failed to get configs: {}", err);
// sleep(Duration::from_millis(100)).await;
// continue;
// }
// let configs = configs.unwrap();
// let mut merged_screenshot_receiver =
// screenshot_manager.clone_merged_screenshot_rx().await;
// let mut screenshots = HashMap::new();
// // let mut start = tokio::time::Instant::now();
// loop {
// let screenshot = merged_screenshot_receiver.recv().await;
// if let Err(err) = screenshot {
// match err {
// tokio::sync::broadcast::error::RecvError::Closed => {
// warn!("closed");
// continue;
// }
// tokio::sync::broadcast::error::RecvError::Lagged(_) => {
// warn!("lagged");
// continue;
// }
// }
// }
// let screenshot = screenshot.unwrap();
// // log::info!("got screenshot: {:?}", screenshot.display_id);
// screenshots.insert(screenshot.display_id, screenshot);
// if screenshots.len() == configs.sample_point_groups.len() {
// // log::info!("{}", start.elapsed().as_millis().to_string());
// {
// let screenshots = configs
// .sample_point_groups
// .iter()
// .map(|strip| screenshots.get(&strip.display_id).unwrap())
// .collect::<Vec<_>>();
// let colors = screenshot_manager
// .get_all_colors(&configs.sample_point_groups, &screenshots)
// .await;
// let sorted_colors =
// ScreenshotManager::get_sorted_colors(&colors, &configs.mappers)
// .await;
// match colors_tx.send(colors) {
// Ok(_) => {
// // log::info!("colors updated");
// }
// Err(_) => {
// warn!("colors update failed");
// }
// }
// match sorted_colors_tx.send(sorted_colors) {
// Ok(_) => {
// // log::info!("colors updated");
// }
// Err(_) => {
// warn!("colors update failed");
// }
// }
// }
// // screenshots.clear();
// // start = tokio::time::Instant::now();
// }
// }
// }
// });
let rx = self.sorted_colors_rx.clone();
tokio::spawn(async move {
let mut rx = rx.read().await.clone();
loop {
if let Err(err) = rx.changed().await {
warn!("rx changed error: {}", err);
sleep(Duration::from_millis(1000)).await;
continue;
}
let colors = rx.borrow().clone();
let len = colors.len();
match Self::send_colors(colors).await {
Ok(_) => {
// log::info!("colors sent. len: {}", len);
}
Err(err) => {
warn!("colors send failed: {}", err);
}
}
}
});
while config_receiver.changed().await.is_ok() {
let configs = config_receiver.borrow().clone();
self.handle_config_change(configs).await;
}
}
pub async fn send_colors(payload: Vec<u8>) -> anyhow::Result<()> {
let mqtt = MqttRpc::global().await;
async fn handle_config_change(&self, configs: LedStripConfigGroup) {
let inner_tasks_version = self.inner_tasks_version.clone();
let configs = Self::get_colors_configs(&configs).await;
mqtt.publish_led_sub_pixels(payload).await
if let Err(err) = configs {
warn!("Failed to get configs: {}", err);
sleep(Duration::from_millis(100)).await;
return;
}
let configs = configs.unwrap();
let mut inner_tasks_version = inner_tasks_version.write().await;
*inner_tasks_version = inner_tasks_version.overflowing_add(1).0;
drop(inner_tasks_version);
let (display_colors_tx, display_colors_rx) = broadcast::channel::<(u32, Vec<u8>)>(8);
for sample_point_group in configs.sample_point_groups.clone() {
let display_id = sample_point_group.display_id;
let sample_points = sample_point_group.points;
let bound_scale_factor = sample_point_group.bound_scale_factor;
self.start_one_display_colors_fetcher(
display_id,
sample_points,
bound_scale_factor,
sample_point_group.mappers,
display_colors_tx.clone(),
)
.await;
}
let display_ids = configs.sample_point_groups;
self.start_all_colors_worker(
display_ids.iter().map(|c| c.display_id).collect(),
configs.mappers,
display_colors_rx,
);
}
pub async fn send_colors(offset: u16, mut payload: Vec<u8>) -> anyhow::Result<()> {
// let mqtt = MqttRpc::global().await;
// mqtt.publish_led_sub_pixels(payload).await;
let socket = UdpSocket::bind("0.0.0.0:8000").await?;
let mut buffer = vec![2];
buffer.push((offset >> 8) as u8);
buffer.push((offset & 0xff) as u8);
buffer.append(&mut payload);
socket.send_to(&buffer, "192.168.31.206:23042").await?;
Ok(())
}
pub async fn send_colors_by_display(
colors: Vec<LedColor>,
mappers: Vec<SamplePointMapper>,
) -> anyhow::Result<()> {
// let color_len = colors.len();
let display_led_offset = mappers
.clone()
.iter()
.flat_map(|mapper| [mapper.start, mapper.end])
.min()
.unwrap();
let udp_rpc = UdpRpc::global().await;
if let Err(err) = udp_rpc {
warn!("udp_rpc can not be initialized: {}", err);
}
let udp_rpc = udp_rpc.as_ref().unwrap();
// let socket = UdpSocket::bind("0.0.0.0:0").await?;
for group in mappers.clone() {
if (group.start.abs_diff(group.end)) > colors.len() {
return Err(anyhow::anyhow!(
"get_sorted_colors: color_index out of range. color_index: {}, strip len: {}, colors.len(): {}",
group.pos,
group.start.abs_diff(group.end),
colors.len()
));
}
let group_size = group.start.abs_diff(group.end);
let mut buffer = Vec::<u8>::with_capacity(group_size * 3);
if group.end > group.start {
// Prevent integer underflow by using saturating subtraction
let start_index = if group.pos >= display_led_offset {
group.pos - display_led_offset
} else {
0
};
let end_index = if group.pos + group_size >= display_led_offset {
group_size + group.pos - display_led_offset
} else {
0
};
for i in start_index..end_index {
if i < colors.len() {
let bytes = colors[i].as_bytes();
buffer.append(&mut bytes.to_vec());
} else {
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
// Add black color as fallback
buffer.append(&mut vec![0, 0, 0]);
}
}
} else {
// Prevent integer underflow by using saturating subtraction
let start_index = if group.pos >= display_led_offset {
group.pos - display_led_offset
} else {
0
};
let end_index = if group.pos + group_size >= display_led_offset {
group_size + group.pos - display_led_offset
} else {
0
};
for i in (start_index..end_index).rev() {
if i < colors.len() {
let bytes = colors[i].as_bytes();
buffer.append(&mut bytes.to_vec());
} else {
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
// Add black color as fallback
buffer.append(&mut vec![0, 0, 0]);
}
}
}
let offset = group.start.min(group.end);
let mut tx_buffer = vec![2];
tx_buffer.push((offset >> 8) as u8);
tx_buffer.push((offset & 0xff) as u8);
tx_buffer.append(&mut buffer);
udp_rpc.send_to_all(&tx_buffer).await?;
}
Ok(())
}
pub async fn clone_sorted_colors_receiver(&self) -> watch::Receiver<Vec<u8>> {
@ -380,11 +375,12 @@ impl LedColorsPublisher {
let mut colors_configs = Vec::new();
let mut merged_screenshot_receiver = screenshot_manager.clone_merged_screenshot_rx().await;
merged_screenshot_receiver.resubscribe();
let mut screenshots = HashMap::new();
loop {
log::info!("waiting merged screenshot...");
let screenshot = merged_screenshot_receiver.recv().await;
if let Err(err) = screenshot {
@ -406,35 +402,51 @@ impl LedColorsPublisher {
screenshots.insert(screenshot.display_id, screenshot);
if screenshots.len() == display_ids.len() {
let mut led_start = 0;
for display_id in display_ids {
let led_strip_configs: Vec<_> = configs
let led_strip_configs = configs
.strips
.iter()
.filter(|c| c.display_id == display_id)
.enumerate()
.filter(|(_, c)| c.display_id == display_id);
let screenshot = screenshots.get(&display_id).unwrap();
let points: Vec<_> = led_strip_configs
.clone()
.map(|(_, config)| screenshot.get_sample_points(&config))
.flatten()
.collect();
if led_strip_configs.len() == 0 {
if points.len() == 0 {
warn!("no led strip config for display_id: {}", display_id);
continue;
}
let screenshot = screenshots.get(&display_id).unwrap();
log::debug!("screenshot updated: {:?}", display_id);
let bound_scale_factor = screenshot.bound_scale_factor;
let points: Vec<_> = led_strip_configs
.iter()
.map(|config| screenshot.get_sample_points(&config))
.collect();
let led_end = led_start + points.iter().map(|p| p.len()).sum::<usize>();
let colors_config = DisplaySamplePointGroup { display_id, points };
let mappers = led_strip_configs.map(|(i, _)| mappers[i].clone()).collect();
let colors_config = DisplaySamplePointGroup {
display_id,
points,
bound_scale_factor,
mappers,
};
colors_configs.push(colors_config);
led_start = led_end;
}
return Ok(AllColorConfig {
sample_point_groups: colors_configs,
mappers,
// screenshot_receivers: local_rx_list,
});
}
}
@ -455,5 +467,7 @@ pub struct AllColorConfig {
#[derive(Debug, Clone)]
pub struct DisplaySamplePointGroup {
pub display_id: u32,
pub points: Vec<Vec<LedSamplePoints>>,
pub points: Vec<LedSamplePoints>,
pub bound_scale_factor: f32,
pub mappers: Vec<config::SamplePointMapper>,
}

View File

@ -0,0 +1,117 @@
use std::{sync::Arc, time::SystemTime};
use ddc_hi::{Ddc, Display};
use tokio::sync::RwLock;
use super::DisplayState;
// Safe wrapper for Display that implements Send + Sync
pub struct SafeDisplay {
display: Display,
}
unsafe impl Send for SafeDisplay {}
unsafe impl Sync for SafeDisplay {}
impl SafeDisplay {
pub fn new(display: Display) -> Self {
Self { display }
}
pub fn get_mut(&mut self) -> &mut Display {
&mut self.display
}
}
pub struct DisplayHandler {
pub state: Arc<RwLock<DisplayState>>,
pub controller: Arc<RwLock<SafeDisplay>>,
}
impl DisplayHandler {
pub async fn fetch_state(&self) {
let mut controller = self.controller.write().await;
let mut temp_state = self.state.read().await.clone();
match controller.get_mut().handle.get_vcp_feature(0x10) {
Ok(value) => {
temp_state.max_brightness = value.maximum();
temp_state.min_brightness = 0;
temp_state.brightness = value.value();
}
Err(_) => {}
};
match controller.get_mut().handle.get_vcp_feature(0x12) {
Ok(value) => {
temp_state.max_contrast = value.maximum();
temp_state.min_contrast = 0;
temp_state.contrast = value.value();
}
Err(_) => {}
};
match controller.get_mut().handle.get_vcp_feature(0xdc) {
Ok(value) => {
temp_state.max_mode = value.maximum();
temp_state.min_mode = 0;
temp_state.mode = value.value();
}
Err(_) => {}
};
temp_state.last_fetched_at = SystemTime::now();
let mut state = self.state.write().await;
*state = temp_state;
}
pub async fn set_brightness(&self, brightness: u16) -> anyhow::Result<()> {
let mut controller = self.controller.write().await;
let mut state = self.state.write().await;
controller
.get_mut()
.handle
.set_vcp_feature(0x10, brightness)
.map_err(|err| anyhow::anyhow!("can not set brightness. {:?}", err))?;
state.brightness = brightness;
state.last_modified_at = SystemTime::now();
Ok(())
}
pub async fn set_contrast(&self, contrast: u16) -> anyhow::Result<()> {
let mut controller = self.controller.write().await;
let mut state = self.state.write().await;
controller
.get_mut()
.handle
.set_vcp_feature(0x12, contrast)
.map_err(|err| anyhow::anyhow!("can not set contrast. {:?}", err))?;
state.contrast = contrast;
state.last_modified_at = SystemTime::now();
Ok(())
}
pub async fn set_mode(&self, mode: u16) -> anyhow::Result<()> {
let mut controller = self.controller.write().await;
let mut state = self.state.write().await;
controller
.get_mut()
.handle
.set_vcp_feature(0xdc, mode)
.map_err(|err| anyhow::anyhow!("can not set mode. {:?}", err))?;
state.mode = mode;
state.last_modified_at = SystemTime::now();
Ok(())
}
}

View File

@ -3,8 +3,7 @@ use std::time::SystemTime;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct DisplayConfig {
pub id: usize,
pub struct DisplayState {
pub brightness: u16,
pub max_brightness: u16,
pub min_brightness: u16,
@ -15,22 +14,35 @@ pub struct DisplayConfig {
pub max_mode: u16,
pub min_mode: u16,
pub last_modified_at: SystemTime,
pub last_fetched_at: SystemTime,
}
impl DisplayConfig {
pub fn default(index: usize) -> Self {
impl DisplayState {
pub fn default() -> Self {
Self {
id: index,
brightness: 30,
contrast: 50,
mode: 0,
last_modified_at: SystemTime::now(),
last_modified_at: SystemTime::UNIX_EPOCH,
max_brightness: 100,
min_brightness: 0,
max_contrast: 100,
min_contrast: 0,
max_mode: 15,
min_mode: 0,
last_fetched_at: SystemTime::UNIX_EPOCH,
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct DisplayStateWrapper {
pub version: u8,
pub states: Vec<DisplayState>,
}
impl DisplayStateWrapper {
pub fn new(states: Vec<DisplayState>) -> Self {
Self { version: 1, states }
}
}

View File

@ -1,187 +1,283 @@
use std::{
borrow::Borrow,
collections::HashMap,
ops::Sub,
sync::Arc,
time::{Duration, SystemTime},
};
use std::{env::current_dir, sync::Arc, time::Duration};
use base64::Config;
use ddc_hi::Display;
use paris::{error, info, warn};
use tauri::async_runtime::Mutex;
use tokio::sync::{broadcast, OwnedMutexGuard};
use tracing::warn;
use dirs::config_dir;
use tokio::{
sync::{broadcast, watch, OnceCell, RwLock},
task::yield_now,
};
use crate::{display::Brightness, models, rpc};
use crate::{
display::DisplayStateWrapper,
rpc::{BoardMessageChannels, DisplaySetting},
};
use super::{display_config::DisplayConfig, DisplayBrightness};
use ddc_hi::Ddc;
use super::{display_handler::{DisplayHandler, SafeDisplay}, display_state::DisplayState};
pub struct Manager {
displays: Arc<Mutex<HashMap<usize, Arc<Mutex<DisplayConfig>>>>>,
const CONFIG_FILE_NAME: &str = "cc.ivanli.ambient_light/displays.toml";
pub struct DisplayManager {
displays: Arc<RwLock<Vec<Arc<RwLock<DisplayHandler>>>>>,
setting_request_handler: Option<tokio::task::JoinHandle<()>>,
displays_changed_sender: Arc<watch::Sender<Vec<DisplayState>>>,
auto_save_state_handler: Option<tokio::task::JoinHandle<()>>,
}
impl Manager {
pub fn global() -> &'static Self {
static DISPLAY_MANAGER: once_cell::sync::OnceCell<Manager> =
once_cell::sync::OnceCell::new();
impl DisplayManager {
pub async fn global() -> &'static Self {
static DISPLAY_MANAGER: OnceCell<DisplayManager> = OnceCell::const_new();
DISPLAY_MANAGER.get_or_init(|| Self::create())
DISPLAY_MANAGER.get_or_init(|| Self::create()).await
}
pub fn create() -> Self {
let instance = Self {
displays: Arc::new(Mutex::new(HashMap::new())),
pub async fn create() -> Self {
let (displays_changed_sender, _) = watch::channel(Vec::new());
let displays_changed_sender = Arc::new(displays_changed_sender);
let mut instance = Self {
displays: Arc::new(RwLock::new(Vec::new())),
setting_request_handler: None,
displays_changed_sender,
auto_save_state_handler: None,
};
instance.fetch_displays().await;
instance.restore_states().await;
instance.fetch_state_of_displays().await;
instance.subscribe_setting_request();
instance.auto_save_state_of_displays();
instance
}
pub async fn subscribe_display_brightness(&self) {
let rpc = rpc::Manager::global().await;
fn auto_save_state_of_displays(&mut self) {
let displays = self.displays.clone();
let mut rx = rpc.client().subscribe_change_display_brightness_rx();
loop {
if let Ok(display_brightness) = rx.recv().await {
if let Err(err) = self.set_display_brightness(display_brightness).await {
error!("set_display_brightness failed. {:?}", err);
}
let handler = tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(10)).await;
Self::save_states(displays.clone()).await;
Self::send_displays_changed(displays.clone()).await;
}
});
self.auto_save_state_handler = Some(handler);
}
async fn send_displays_changed(displays: Arc<RwLock<Vec<Arc<RwLock<DisplayHandler>>>>>) {
let mut states = Vec::new();
for display in displays.read().await.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
let channel = BoardMessageChannels::global().await;
let tx = channel.displays_changed_sender.clone();
if let Err(err) = tx.send(states) {
error!("Failed to send displays changed: {}", err);
}
}
fn read_display_config_by_ddc(index: usize) -> anyhow::Result<DisplayConfig> {
let mut displays = Display::enumerate();
match displays.get_mut(index) {
Some(display) => {
let mut config = DisplayConfig::default(index);
match display.handle.get_vcp_feature(0x10) {
Ok(value) => {
config.max_brightness = value.maximum();
config.min_brightness = 0;
config.brightness = value.value();
}
Err(_) => {}
};
match display.handle.get_vcp_feature(0x12) {
Ok(value) => {
config.max_contrast = value.maximum();
config.min_contrast = 0;
config.contrast = value.value();
}
Err(_) => {}
};
match display.handle.get_vcp_feature(0xdc) {
Ok(value) => {
config.max_mode = value.maximum();
config.min_mode = 0;
config.mode = value.value();
}
Err(_) => {}
};
async fn fetch_displays(&self) {
let mut displays = self.displays.write().await;
displays.clear();
Ok(config)
}
None => anyhow::bail!("display#{} is missed.", index),
let controllers = Display::enumerate();
for display in controllers {
let safe_display = SafeDisplay::new(display);
let controller = Arc::new(RwLock::new(safe_display));
let state = Arc::new(RwLock::new(DisplayState::default()));
let handler = DisplayHandler {
state: state.clone(),
controller: controller.clone(),
};
displays.push(Arc::new(RwLock::new(handler)));
}
}
async fn get_display(&self, index: usize) -> anyhow::Result<OwnedMutexGuard<DisplayConfig>> {
let mut displays = self.displays.lock().await;
match displays.get_mut(&index) {
Some(config) => {
let mut config = config.to_owned().lock_owned().await;
if config.last_modified_at > SystemTime::now().sub(Duration::from_secs(10)) {
info!("cached");
return Ok(config);
}
return match Self::read_display_config_by_ddc(index) {
Ok(config) => {
let id = config.id;
let value = Arc::new(Mutex::new(config));
let valueGuard = value.clone().lock_owned().await;
displays.insert(id, value);
info!("read form ddc");
Ok(valueGuard)
}
Err(err) => {
warn!(
"can not read config from display by ddc, use CACHED value. {:?}",
err
);
config.last_modified_at = SystemTime::now();
Ok(config)
}
};
}
None => {
let config = Self::read_display_config_by_ddc(index).map_err(|err| {
anyhow::anyhow!(
"can not read config from display by ddc,use DEFAULT value. {:?}",
err
)
})?;
let id = config.id;
let value = Arc::new(Mutex::new(config));
let valueGuard = value.clone().lock_owned().await;
displays.insert(id, value);
Ok(valueGuard)
}
async fn fetch_state_of_displays(&self) {
let displays = self.displays.read().await;
for display in displays.iter() {
let display = display.read().await;
display.fetch_state().await;
}
}
pub async fn set_display_brightness(
&self,
display_brightness: DisplayBrightness,
) -> anyhow::Result<()> {
match Display::enumerate().get_mut(display_brightness.display_index) {
Some(display) => {
match self.get_display(display_brightness.display_index).await {
Ok(mut config) => {
let curr = config.brightness;
info!("curr_brightness: {:?}", curr);
let mut target = match display_brightness.brightness {
Brightness::Relative(v) => curr.wrapping_add_signed(v),
Brightness::Absolute(v) => v,
};
if target.gt(&config.max_brightness) {
target = config.max_brightness;
} else if target.lt(&config.min_brightness) {
target = config.min_brightness;
pub async fn get_displays(&self) -> Vec<DisplayState> {
let displays = self.displays.read().await;
let mut states = Vec::new();
for display in displays.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
states
}
fn subscribe_setting_request(&mut self) {
let displays = self.displays.clone();
let displays_changed_sender = self.displays_changed_sender.clone();
let handler = tokio::spawn(async move {
let channels = BoardMessageChannels::global().await;
let mut request_rx = channels.display_setting_request_sender.subscribe();
loop {
if let Err(err) = request_rx.recv().await {
match err {
broadcast::error::RecvError::Closed => {
info!("display setting request channel closed");
break;
}
broadcast::error::RecvError::Lagged(_) => {
warn!("display setting request channel lagged");
continue;
}
config.brightness = target;
display
.handle
.set_vcp_feature(0x10, target as u16)
.map_err(|err| anyhow::anyhow!("can not set brightness. {:?}", err))?;
let rpc = rpc::Manager::global().await;
rpc.publish_desktop_cmd(
format!("display{}/brightness", display_brightness.display_index)
.as_str(),
target.to_be_bytes().to_vec(),
)
.await;
}
Err(err) => {
info!(
"can not get display#{} brightness. {:?}",
display_brightness.display_index, err
);
if let Brightness::Absolute(v) = display_brightness.brightness {
display.handle.set_vcp_feature(0x10, v).map_err(|err| {
anyhow::anyhow!("can not set brightness. {:?}", err)
})?;
};
}
}
let message = request_rx.recv().await.unwrap();
let displays = displays.write().await;
let display = displays.get(message.display_index);
if display.is_none() {
warn!("display#{} not found", message.display_index);
continue;
}
let display = display.unwrap().write().await;
let result = match message.setting {
DisplaySetting::Brightness(value) => display.set_brightness(value as u16).await,
DisplaySetting::Contrast(value) => display.set_contrast(value as u16).await,
DisplaySetting::Mode(value) => display.set_mode(value as u16).await,
};
if let Err(err) = result {
error!("failed to set display setting: {}", err);
continue;
}
drop(display);
let mut states = Vec::new();
for display in displays.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
if let Err(err) = displays_changed_sender.send(states) {
error!("failed to send displays changed event: {}", err);
}
yield_now().await;
}
None => {
warn!("display#{} is not found.", display_brightness.display_index);
});
self.setting_request_handler = Some(handler);
}
async fn restore_states(&self) {
let path = config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
if !path.exists() {
log::info!("config file not found: {}. skip read.", path.display());
return;
}
let text = std::fs::read_to_string(path);
if let Err(err) = text {
log::error!("failed to read config file: {}", err);
return;
}
let text = text.unwrap();
let wrapper = toml::from_str::<DisplayStateWrapper>(&text);
if let Err(err) = wrapper {
log::error!("failed to parse display states file: {}", err);
return;
}
let states = wrapper.unwrap().states;
let displays = self.displays.read().await;
for (index, display) in displays.iter().enumerate() {
let display = display.read().await;
let mut state = display.state.write().await;
let saved = states.get(index);
if let Some(saved) = saved {
state.brightness = saved.brightness;
state.contrast = saved.contrast;
state.mode = saved.mode;
log::info!("restore display config. display#{}: {:?}", index, state);
}
}
Ok(())
log::info!(
"restore display config. store displays: {}, online displays: {}",
states.len(),
displays.len()
);
}
async fn save_states(displays: Arc<RwLock<Vec<Arc<RwLock<DisplayHandler>>>>>) {
let path = config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
let displays = displays.read().await;
let mut states = Vec::new();
for display in displays.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
let wrapper = DisplayStateWrapper::new(states);
let text = toml::to_string(&wrapper);
if let Err(err) = text {
log::error!("failed to serialize display states: {}", err);
log::error!("display states: {:?}", &wrapper);
return;
}
let text = text.unwrap();
if path.exists() {
if let Err(err) = std::fs::remove_file(&path) {
log::error!("failed to remove old config file: {}", err);
return;
}
}
if let Err(err) = std::fs::write(&path, text) {
log::error!("failed to write config file: {}", err);
return;
}
log::debug!(
"save display config. store displays: {}, online displays: {}",
wrapper.states.len(),
displays.len()
);
}
pub fn subscribe_displays_changed(&self) -> watch::Receiver<Vec<DisplayState>> {
self.displays_changed_sender.subscribe()
}
}
impl Drop for DisplayManager {
fn drop(&mut self) {
log::info!("dropping display manager=============");
if let Some(handler) = self.setting_request_handler.take() {
handler.abort();
}
if let Some(handler) = self.auto_save_state_handler.take() {
handler.abort();
}
}
}

View File

@ -1,11 +1,13 @@
// mod brightness;
// mod manager;
mod display_config;
mod display_state;
mod manager;
mod display_handler;
pub use display_config::*;
pub use display_state::*;
// pub use brightness::*;
// pub use manager::*;
pub use manager::*;

View File

@ -2,45 +2,47 @@ use color_space::{Hsv, Rgb};
use serde::Serialize;
#[derive(Clone, Copy, Debug)]
pub struct LedColor {
bits: [u8; 3],
}
pub struct LedColor([u8; 3]);
impl LedColor {
pub fn default() -> Self {
Self { bits: [0, 0, 0] }
Self ([0, 0, 0] )
}
pub fn new(r: u8, g: u8, b: u8) -> Self {
Self { bits: [r, g, b] }
Self ([r, g, b])
}
pub fn from_hsv(h: f64, s: f64, v: f64) -> Self {
let rgb = Rgb::from(Hsv::new(h, s, v));
Self { bits: [rgb.r as u8, rgb.g as u8, rgb.b as u8] }
Self ([rgb.r as u8, rgb.g as u8, rgb.b as u8])
}
pub fn get_rgb(&self) -> [u8; 3] {
self.bits
self.0
}
pub fn is_empty(&self) -> bool {
self.bits.iter().any(|bit| *bit == 0)
self.0.iter().any(|bit| *bit == 0)
}
pub fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> &Self {
self.bits = [r, g, b];
self.0 = [r, g, b];
self
}
pub fn merge(&mut self, r: u8, g: u8, b: u8) -> &Self {
self.bits = [
(self.bits[0] / 2 + r / 2),
(self.bits[1] / 2 + g / 2),
(self.bits[2] / 2 + b / 2),
self.0 = [
(self.0[0] / 2 + r / 2),
(self.0[1] / 2 + g / 2),
(self.0[2] / 2 + b / 2),
];
self
}
pub fn as_bytes (&self) -> [u8; 3] {
self.0
}
}
impl Serialize for LedColor {
@ -48,7 +50,7 @@ impl Serialize for LedColor {
where
S: serde::Serializer,
{
let hex = format!("#{}", hex::encode(self.bits));
let hex = format!("#{}", hex::encode(self.0));
serializer.serialize_str(hex.as_str())
}
}

View File

@ -5,21 +5,25 @@ mod ambient_light;
mod display;
mod led_color;
mod rpc;
pub mod screenshot;
mod screenshot;
mod screenshot_manager;
mod screen_stream;
mod volume;
use ambient_light::{Border, LedColorsPublisher, LedStripConfig, LedStripConfigGroup};
use core_graphics::display::{
kCGNullWindowID, kCGWindowImageDefault, kCGWindowListOptionOnScreenOnly, CGDisplay,
};
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup};
use display::{DisplayManager, DisplayState};
use display_info::DisplayInfo;
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::{http::ResponseBuilder, regex, Manager};
use tauri::{Manager, Emitter, Runtime};
use regex;
use tauri::http::{Request, Response};
use volume::VolumeManager;
#[derive(Serialize, Deserialize)]
#[serde(remote = "DisplayInfo")]
struct DisplayInfoDef {
@ -87,7 +91,7 @@ async fn get_led_strips_sample_points(
let screenshot_manager = ScreenshotManager::global().await;
let channels = screenshot_manager.channels.read().await;
if let Some(rx) = channels.get(&config.display_id) {
let rx = rx.clone();
let rx = rx.read().await;
let screenshot = rx.borrow().clone();
let sample_points = screenshot.get_sample_points(&config);
Ok(sample_points)
@ -104,7 +108,7 @@ async fn get_one_edge_colors(
let screenshot_manager = ScreenshotManager::global().await;
let channels = screenshot_manager.channels.read().await;
if let Some(rx) = channels.get(&display_id) {
let rx = rx.clone();
let rx = rx.read().await;
let screenshot = rx.borrow().clone();
let bytes = screenshot.bytes.read().await.to_owned();
let colors =
@ -135,8 +139,8 @@ async fn patch_led_strip_len(display_id: u32, border: Border, delta_len: i8) ->
}
#[tauri::command]
async fn send_colors(buffer: Vec<u8>) -> Result<(), String> {
ambient_light::LedColorsPublisher::send_colors(buffer)
async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
ambient_light::LedColorsPublisher::send_colors(offset, buffer)
.await
.map_err(|e| {
error!("can not send colors: {}", e);
@ -145,14 +149,14 @@ async fn send_colors(buffer: Vec<u8>) -> Result<(), String> {
}
#[tauri::command]
async fn move_strip_part(display_id: u32, border: Border, target_start: usize) -> Result<(), String> {
async fn move_strip_part(
display_id: u32,
border: Border,
target_start: usize,
) -> Result<(), String> {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager
.move_strip_part(
display_id,
border,
target_start,
)
.move_strip_part(display_id, border, target_start)
.await
.map_err(|e| {
error!("can not move strip part: {}", e);
@ -172,17 +176,205 @@ async fn reverse_led_strip_part(display_id: u32, border: Border) -> Result<(), S
})
}
#[tauri::command]
async fn set_color_calibration(calibration: ColorCalibration) -> Result<(), String> {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager
.set_color_calibration(calibration)
.await
.map_err(|e| {
error!("can not set color calibration: {}", e);
e.to_string()
})
}
#[tauri::command]
async fn read_config() -> ambient_light::LedStripConfigGroup {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager.configs().await
}
#[tauri::command]
async fn get_boards() -> Result<Vec<BoardInfo>, String> {
let udp_rpc = UdpRpc::global().await;
if let Err(e) = udp_rpc {
return Err(format!("can not ping: {}", e));
}
let udp_rpc = udp_rpc.as_ref().unwrap();
let boards = udp_rpc.get_boards().await;
let boards = boards.into_iter().collect::<Vec<_>>();
Ok(boards)
}
#[tauri::command]
async fn get_displays() -> Vec<DisplayState> {
let display_manager = DisplayManager::global().await;
display_manager.get_displays().await
}
// Protocol handler for ambient-light://
fn handle_ambient_light_protocol<R: Runtime>(
_ctx: tauri::UriSchemeContext<R>,
request: Request<Vec<u8>>
) -> Response<Vec<u8>> {
let url = request.uri();
// info!("Handling ambient-light protocol request: {}", url);
// Parse the URL to extract parameters
let url_str = url.to_string();
let re = regex::Regex::new(r"ambient-light://displays/(\d+)\?width=(\d+)&height=(\d+)").unwrap();
if let Some(captures) = re.captures(&url_str) {
let display_id: u32 = captures[1].parse().unwrap_or(0);
let width: u32 = captures[2].parse().unwrap_or(400);
let height: u32 = captures[3].parse().unwrap_or(300);
// info!("Efficient screenshot request for display {}, {}x{}", display_id, width, height);
// Optimized screenshot processing with much smaller intermediate size
// info!("Screenshot request received: display_id={}, width={}, height={}", display_id, width, height);
let screenshot_data = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let screenshot_manager = ScreenshotManager::global().await;
let channels = screenshot_manager.channels.read().await;
if let Some(rx) = channels.get(&display_id) {
let rx = rx.read().await;
let screenshot = rx.borrow().clone();
let bytes = screenshot.bytes.read().await.to_owned();
// Use much smaller intermediate resolution for performance
let intermediate_width = 800; // Much smaller than original 5120
let intermediate_height = 450; // Much smaller than original 2880
// Convert BGRA to RGBA format
let mut rgba_bytes = bytes.as_ref().clone();
for chunk in rgba_bytes.chunks_exact_mut(4) {
chunk.swap(0, 2); // Swap B and R channels
}
let image_result = image::RgbaImage::from_raw(
screenshot.width as u32,
screenshot.height as u32,
rgba_bytes,
);
if let Some(img) = image_result {
// Step 1: Fast downscale to intermediate size
let intermediate_image = image::imageops::resize(
&img,
intermediate_width,
intermediate_height,
image::imageops::FilterType::Nearest, // Fastest possible
);
// Step 2: Scale to final target size
let final_image = if width == intermediate_width && height == intermediate_height {
intermediate_image
} else {
image::imageops::resize(
&intermediate_image,
width,
height,
image::imageops::FilterType::Triangle,
)
};
let raw_data = final_image.into_raw();
// info!("Efficient resize completed: {}x{}, {} bytes", width, height, raw_data.len());
Ok(raw_data)
} else {
error!("Failed to create image from raw bytes");
Err("Failed to create image from raw bytes".to_string())
}
} else {
error!("Display {} not found", display_id);
Err(format!("Display {} not found", display_id))
}
})
});
match screenshot_data {
Ok(data) => {
Response::builder()
.header("Content-Type", "application/octet-stream")
.header("Access-Control-Allow-Origin", "*")
.header("X-Image-Width", width.to_string())
.header("X-Image-Height", height.to_string())
.body(data)
.unwrap_or_else(|_| {
Response::builder()
.status(500)
.body("Failed to build response".as_bytes().to_vec())
.unwrap()
})
}
Err(e) => {
error!("Failed to get screenshot: {}", e);
Response::builder()
.status(500)
.body(format!("Error: {}", e).into_bytes())
.unwrap()
}
}
} else {
warn!("Invalid ambient-light URL format: {}", url_str);
Response::builder()
.status(400)
.body("Invalid URL format".as_bytes().to_vec())
.unwrap()
}
}
#[tokio::main]
async fn main() {
env_logger::init();
let screenshot_manager = ScreenshotManager::global().await;
screenshot_manager.start().unwrap();
// 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);
}
}
let led_color_publisher = ambient_light::LedColorsPublisher::global().await;
led_color_publisher.start();
tokio::spawn(async move {
let screenshot_manager = ScreenshotManager::global().await;
screenshot_manager.start().await.unwrap_or_else(|e| {
error!("can not start screenshot manager: {}", e);
})
});
tokio::spawn(async move {
let led_color_publisher = ambient_light::LedColorsPublisher::global().await;
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()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
greet,
list_display_info,
@ -194,140 +386,19 @@ async fn main() {
send_colors,
move_strip_part,
reverse_led_strip_part,
set_color_calibration,
read_config,
get_boards,
get_displays
])
.register_uri_scheme_protocol("ambient-light", move |_app, request| {
let response = ResponseBuilder::new().header("Access-Control-Allow-Origin", "*");
.register_uri_scheme_protocol("ambient-light", handle_ambient_light_protocol)
let uri = request.uri();
let uri = percent_encoding::percent_decode_str(uri)
.decode_utf8()
.unwrap()
.to_string();
let url = url_build_parse::parse_url(uri.as_str());
if let Err(err) = url {
error!("url parse error: {}", err);
return response
.status(500)
.mimetype("text/plain")
.body("Parse uri failed.".as_bytes().to_vec());
}
let url = url.unwrap();
let re = regex::Regex::new(r"^/displays/(\d+)$").unwrap();
let path = url.path;
let captures = re.captures(path.as_str());
if let None = captures {
error!("path not matched: {:?}", path);
return response
.status(404)
.mimetype("text/plain")
.body("Path Not Found.".as_bytes().to_vec());
}
let captures = captures.unwrap();
let display_id = captures[1].parse::<u32>().unwrap();
let bytes = tokio::task::block_in_place(move || {
tauri::async_runtime::block_on(async move {
let screenshot_manager = ScreenshotManager::global().await;
let channels = screenshot_manager.channels.read().await;
if let Some(rx) = channels.get(&display_id) {
let rx = rx.clone();
let screenshot = rx.borrow().clone();
let bytes = screenshot.bytes.read().await;
let (scale_factor_x, scale_factor_y, width, height) = if url.query.is_some()
&& url.query.as_ref().unwrap().contains_key("height")
&& url.query.as_ref().unwrap().contains_key("width")
{
let width = url.query.as_ref().unwrap()["width"]
.parse::<u32>()
.map_err(|err| {
warn!("width parse error: {}", err);
err
})?;
let height = url.query.as_ref().unwrap()["height"]
.parse::<u32>()
.map_err(|err| {
warn!("height parse error: {}", err);
err
})?;
(
screenshot.width as f32 / width as f32,
screenshot.height as f32 / height as f32,
width,
height,
)
} else {
log::debug!("scale by scale_factor");
let scale_factor = screenshot.scale_factor;
(
scale_factor,
scale_factor,
(screenshot.width as f32 / scale_factor) as u32,
(screenshot.height as f32 / scale_factor) as u32,
)
};
log::debug!(
"scale by query. width: {}, height: {}, scale_factor: {}, len: {}",
width,
height,
screenshot.width as f32 / width as f32,
width * height * 4,
);
let bytes_per_row = screenshot.bytes_per_row as f32;
let mut rgba_buffer = vec![0u8; (width * height * 4) as usize];
for y in 0..height {
for x in 0..width {
let offset = ((y as f32) * scale_factor_y).floor() as usize
* bytes_per_row as usize
+ ((x as f32) * scale_factor_x).floor() as usize * 4;
let b = bytes[offset];
let g = bytes[offset + 1];
let r = bytes[offset + 2];
let a = bytes[offset + 3];
let offset_2 = (y * width + x) as usize * 4;
rgba_buffer[offset_2] = r;
rgba_buffer[offset_2 + 1] = g;
rgba_buffer[offset_2 + 2] = b;
rgba_buffer[offset_2 + 3] = a;
}
}
Ok(rgba_buffer.clone())
} else {
anyhow::bail!("Display#{}: not found", display_id);
}
})
});
if let Ok(bytes) = bytes {
return response
.mimetype("octet/stream")
.status(200)
.body(bytes.to_vec());
}
let err = bytes.unwrap_err();
error!("request screenshot bin data failed: {}", err);
return response
.mimetype("text/plain")
.status(500)
.body(err.to_string().into_bytes());
})
.setup(move |app| {
let app_handle = app.handle().clone();
tokio::spawn(async move {
let config_manager = ambient_light::ConfigManager::global().await;
let config_update_receiver = config_manager.clone_config_update_receiver();
let mut config_update_receiver = config_update_receiver;
let mut config_update_receiver = config_manager.clone_config_update_receiver();
loop {
if let Err(err) = config_update_receiver.changed().await {
error!("config update receiver changed error: {}", err);
@ -338,7 +409,7 @@ async fn main() {
let config = config_update_receiver.borrow().clone();
app_handle.emit_all("config_changed", config).unwrap();
app_handle.emit("config_changed", config).unwrap();
}
});
@ -355,10 +426,11 @@ async fn main() {
let publisher = publisher_update_receiver.borrow().clone();
app_handle
.emit_all("led_sorted_colors_changed", publisher)
.emit("led_sorted_colors_changed", publisher)
.unwrap();
}
});
let app_handle = app.handle().clone();
tokio::spawn(async move {
let publisher = ambient_light::LedColorsPublisher::global().await;
@ -372,13 +444,81 @@ async fn main() {
let publisher = publisher_update_receiver.borrow().clone();
app_handle
.emit_all("led_colors_changed", publisher)
.emit("led_colors_changed", publisher)
.unwrap();
}
});
let app_handle = app.handle().clone();
tokio::spawn(async move {
loop {
match UdpRpc::global().await {
Ok(udp_rpc) => {
let mut receiver = udp_rpc.subscribe_boards_change();
loop {
if let Err(err) = receiver.changed().await {
error!("boards change receiver changed error: {}", err);
return;
}
let boards = receiver.borrow().clone();
let boards = boards.into_iter().collect::<Vec<_>>();
app_handle.emit("boards_changed", boards).unwrap();
}
}
Err(err) => {
error!("udp rpc error: {}", err);
return;
}
}
}
});
let app_handle = app.handle().clone();
tokio::spawn(async move {
let display_manager = DisplayManager::global().await;
let mut rx = display_manager.subscribe_displays_changed();
while rx.changed().await.is_ok() {
let displays = rx.borrow().clone();
log::info!("displays changed. emit displays_changed event.");
app_handle.emit("displays_changed", displays).unwrap();
}
});
Ok(())
})
.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(())
}

328
src-tauri/src/rpc/board.rs Normal file
View File

@ -0,0 +1,328 @@
use std::{sync::Arc, time::Duration};
use paris::{error, info, warn};
use tokio::{io, net::UdpSocket, sync::RwLock, task::yield_now, time::timeout};
use crate::{
ambient_light::{ConfigManager, LedStripConfig},
rpc::DisplaySettingRequest,
volume::{self, VolumeManager},
};
use super::{BoardConnectStatus, BoardInfo, BoardMessageChannels};
#[derive(Debug)]
pub struct Board {
pub info: Arc<RwLock<BoardInfo>>,
socket: Option<Arc<UdpSocket>>,
listen_handler: Option<tokio::task::JoinHandle<()>>,
volume_changed_subscriber_handler: Option<tokio::task::JoinHandle<()>>,
state_of_displays_changed_subscriber_handler: Option<tokio::task::JoinHandle<()>>,
led_strip_config_changed_subscriber_handler: Option<tokio::task::JoinHandle<()>>,
}
impl Board {
pub fn new(info: BoardInfo) -> Self {
Self {
info: Arc::new(RwLock::new(info)),
socket: None,
listen_handler: None,
volume_changed_subscriber_handler: None,
state_of_displays_changed_subscriber_handler: None,
led_strip_config_changed_subscriber_handler: None,
}
}
pub async fn init_socket(&mut self) -> anyhow::Result<()> {
let info = self.info.clone();
let info = info.read().await;
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.connect((info.address, info.port)).await?;
let socket = Arc::new(socket);
self.socket = Some(socket.clone());
let handler = tokio::spawn(async move {
let mut buf = [0u8; 128];
let board_message_channels = crate::rpc::channels::BoardMessageChannels::global().await;
let display_setting_request_sender = board_message_channels
.display_setting_request_sender
.clone();
let volume_setting_request_sender =
board_message_channels.volume_setting_request_sender.clone();
loop {
match socket.recv(&mut buf).await {
Ok(len) => {
log::info!("recv: {:?}", &buf[..len]);
if buf[0] == 3 {
let result =
display_setting_request_sender.send(DisplaySettingRequest {
display_index: buf[1] as usize,
setting: crate::rpc::DisplaySetting::Brightness(buf[2]),
});
if let Err(err) = result {
error!("send display setting request to channel failed: {:?}", err);
}
} else if buf[0] == 4 {
let result = volume_setting_request_sender.send(buf[1] as f32 / 100.0);
if let Err(err) = result {
error!("send volume setting request to channel failed: {:?}", err);
}
}
}
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
yield_now().await;
continue;
}
Err(e) => {
error!("socket recv error: {:?}", e);
break;
}
}
}
});
self.listen_handler = Some(handler);
self.subscribe_volume_changed().await;
self.subscribe_state_of_displays_changed().await;
self.subscribe_led_strip_config_changed().await;
Ok(())
}
async fn subscribe_volume_changed(&mut self) {
let channel = BoardMessageChannels::global().await;
let mut volume_changed_rx = channel.volume_changed_sender.subscribe();
let info = self.info.clone();
let socket = self.socket.clone();
let handler = tokio::spawn(async move {
loop {
let volume: Result<f32, tokio::sync::broadcast::error::RecvError> =
volume_changed_rx.recv().await;
if let Err(err) = volume {
match err {
tokio::sync::broadcast::error::RecvError::Closed => {
log::error!("volume changed channel closed");
break;
}
tokio::sync::broadcast::error::RecvError::Lagged(_) => {
log::info!("volume changed channel lagged");
continue;
}
}
}
let volume = volume.unwrap();
let info = info.read().await;
if socket.is_none() || info.connect_status != BoardConnectStatus::Connected {
log::info!("board is not connected, skip send volume changed");
continue;
}
let socket = socket.as_ref().unwrap();
let mut buf = [0u8; 2];
buf[0] = 4;
buf[1] = (volume * 100.0) as u8;
if let Err(err) = socket.send(&buf).await {
log::warn!("send volume changed failed: {:?}", err);
}
}
});
let volume_manager = VolumeManager::global().await;
let volume = volume_manager.get_volume().await;
if let Some(socket) = self.socket.as_ref() {
let buf = [4, (volume * 100.0) as u8];
if let Err(err) = socket.send(&buf).await {
log::warn!("send volume failed: {:?}", err);
}
} else {
log::warn!("socket is none, skip send volume");
}
self.volume_changed_subscriber_handler = Some(handler);
}
async fn subscribe_state_of_displays_changed(&mut self) {
let channel: &BoardMessageChannels = BoardMessageChannels::global().await;
let mut state_of_displays_changed_rx = channel.displays_changed_sender.subscribe();
let info = self.info.clone();
let socket = self.socket.clone();
let handler = tokio::spawn(async move {
loop {
let states: Result<
Vec<crate::display::DisplayState>,
tokio::sync::broadcast::error::RecvError,
> = state_of_displays_changed_rx.recv().await;
if let Err(err) = states {
match err {
tokio::sync::broadcast::error::RecvError::Closed => {
log::error!("state of displays changed channel closed");
break;
}
tokio::sync::broadcast::error::RecvError::Lagged(_) => {
log::info!("state of displays changed channel lagged");
continue;
}
}
}
let info = info.read().await;
if socket.is_none() || info.connect_status != BoardConnectStatus::Connected {
log::info!("board is not connected, skip send state of displays changed");
continue;
}
let socket = socket.as_ref().unwrap();
let mut buf = [0u8; 3];
let states = states.unwrap();
for (index, state) in states.iter().enumerate() {
buf[0] = 3;
buf[1] = index as u8;
buf[2] = state.brightness as u8;
log::info!("send state of displays changed: {:?}", &buf[..]);
if let Err(err) = socket.send(&buf).await {
log::warn!("send state of displays changed failed: {:?}", err);
}
}
}
});
self.state_of_displays_changed_subscriber_handler = Some(handler);
}
async fn subscribe_led_strip_config_changed(&mut self) {
let config_manager = ConfigManager::global().await;
let mut led_strip_config_changed_rx = config_manager.clone_config_update_receiver();
let info = self.info.clone();
let socket = self.socket.clone();
let handler = tokio::spawn(async move {
while led_strip_config_changed_rx.changed().await.is_ok() {
let config = led_strip_config_changed_rx.borrow().clone();
let info = info.read().await;
if socket.is_none() || info.connect_status != BoardConnectStatus::Connected {
log::info!("board is not connected, skip send led strip config changed");
continue;
}
let socket = socket.as_ref().unwrap();
let mut buf = [0u8; 4];
buf[0] = 5;
buf[1..].copy_from_slice(&config.color_calibration.to_bytes());
log::info!("send led strip config changed: {:?}", &buf[..]);
if let Err(err) = socket.send(&buf).await {
log::warn!("send led strip config changed failed: {:?}", err);
}
}
});
self.led_strip_config_changed_subscriber_handler = Some(handler);
}
pub async fn send_colors(&self, buf: &[u8]) {
let info = self.info.read().await;
if self.socket.is_none() || info.connect_status != BoardConnectStatus::Connected {
return;
}
let socket = self.socket.as_ref().unwrap();
socket.send(buf).await.unwrap();
}
pub async fn check(&self) -> anyhow::Result<()> {
let info = self.info.read().await;
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.connect((info.address, info.port)).await?;
drop(info);
let instant = std::time::Instant::now();
socket.send(&[1]).await?;
let mut buf = [0u8; 1];
let recv_future = socket.recv(&mut buf);
let check_result = timeout(Duration::from_secs(1), recv_future).await;
let mut info = self.info.write().await;
match check_result {
Ok(_) => {
let ttl = instant.elapsed();
if buf == [1] {
info.connect_status = BoardConnectStatus::Connected;
} else {
if let BoardConnectStatus::Connecting(retry) = info.connect_status {
if retry < 10 {
info.connect_status = BoardConnectStatus::Connecting(retry + 1);
info!("reconnect: {}", retry + 1);
} else {
info.connect_status = BoardConnectStatus::Disconnected;
warn!("board Disconnected: bad pong.");
}
} else if info.connect_status != BoardConnectStatus::Disconnected {
info.connect_status = BoardConnectStatus::Connecting(1);
}
}
info.ttl = Some(ttl.as_millis());
}
Err(_) => {
if let BoardConnectStatus::Connecting(retry) = info.connect_status {
if retry < 10 {
info.connect_status = BoardConnectStatus::Connecting(retry + 1);
info!("reconnect: {}", retry + 1);
} else {
info.connect_status = BoardConnectStatus::Disconnected;
warn!("board Disconnected: timeout");
}
} else if info.connect_status != BoardConnectStatus::Disconnected {
info.connect_status = BoardConnectStatus::Connecting(1);
}
info.ttl = None;
}
}
info.checked_at = Some(std::time::SystemTime::now());
Ok(())
}
}
impl Drop for Board {
fn drop(&mut self) {
info!("board drop");
if let Some(handler) = self.listen_handler.take() {
handler.abort();
}
if let Some(handler) = self.volume_changed_subscriber_handler.take() {
handler.abort();
}
if let Some(handler) = self.state_of_displays_changed_subscriber_handler.take() {
handler.abort();
}
if let Some(handler) = self.led_strip_config_changed_subscriber_handler.take() {
handler.abort();
}
}
}

View File

@ -0,0 +1,36 @@
use std::{net::Ipv4Addr, time::Duration};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
pub enum BoardConnectStatus {
Connected,
Connecting(u8),
Disconnected,
Unknown,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct BoardInfo {
pub fullname: String,
pub host: String,
pub address: Ipv4Addr,
pub port: u16,
pub connect_status: BoardConnectStatus,
pub checked_at: Option<std::time::SystemTime>,
pub ttl: Option<u128>,
}
impl BoardInfo {
pub fn new(fullname: String, host: String, address: Ipv4Addr, port: u16) -> Self {
Self {
fullname,
host,
address,
port,
connect_status: BoardConnectStatus::Unknown,
checked_at: None,
ttl: None,
}
}
}

View File

@ -0,0 +1,43 @@
use std::sync::Arc;
use tokio::sync::{broadcast, OnceCell};
use crate::display::DisplayState;
use super::DisplaySettingRequest;
pub struct BoardMessageChannels {
pub display_setting_request_sender: Arc<broadcast::Sender<DisplaySettingRequest>>,
pub volume_setting_request_sender: Arc<broadcast::Sender<f32>>,
pub volume_changed_sender: Arc<broadcast::Sender<f32>>,
pub displays_changed_sender: Arc<broadcast::Sender<Vec<DisplayState>>>,
}
impl BoardMessageChannels {
pub async fn global() -> &'static Self {
static BOARD_MESSAGE_CHANNELS: OnceCell<BoardMessageChannels> = OnceCell::const_new();
BOARD_MESSAGE_CHANNELS.get_or_init(|| async {Self::new()}).await
}
pub fn new() -> Self {
let (display_setting_request_sender, _) = broadcast::channel(16);
let display_setting_request_sender = Arc::new(display_setting_request_sender);
let (volume_setting_request_sender, _) = broadcast::channel(16);
let volume_setting_request_sender = Arc::new(volume_setting_request_sender);
let (volume_changed_sender, _) = broadcast::channel(2);
let volume_changed_sender = Arc::new(volume_changed_sender);
let (displays_changed_sender, _) = broadcast::channel(2);
let displays_changed_sender = Arc::new(displays_changed_sender);
Self {
display_setting_request_sender,
volume_setting_request_sender,
volume_changed_sender,
displays_changed_sender,
}
}
}

View File

@ -0,0 +1,13 @@
#[derive(Clone, Debug)]
pub enum DisplaySetting {
Brightness(u8),
Contrast(u8),
Mode(u8),
}
#[derive(Clone, Debug)]
pub struct DisplaySettingRequest {
pub display_index: usize,
pub setting: DisplaySetting,
}

View File

@ -1,3 +1,11 @@
mod mqtt;
mod board_info;
mod udp;
mod board;
mod display_setting_request;
mod channels;
pub use mqtt::*;
pub use board_info::*;
pub use udp::*;
pub use board::*;
pub use display_setting_request::*;
pub use channels::*;

View File

@ -1,241 +0,0 @@
use paho_mqtt as mqtt;
use paris::{error, info, warn};
use serde_json::json;
use std::time::Duration;
use time::{format_description, OffsetDateTime};
use tokio::{sync::OnceCell, task};
const DISPLAY_TOPIC: &'static str = "display-ambient-light/display";
const DESKTOP_TOPIC: &'static str = "display-ambient-light/desktop";
const DISPLAY_BRIGHTNESS_TOPIC: &'static str = "display-ambient-light/board/brightness";
const BOARD_SEND_CMD: &'static str = "display-ambient-light/board/cmd";
pub struct MqttRpc {
client: mqtt::AsyncClient,
// change_display_brightness_tx: broadcast::Sender<display::DisplayBrightness>,
// message_tx: broadcast::Sender<models::CmdMqMessage>,
}
impl MqttRpc {
pub async fn global() -> &'static Self {
static MQTT_RPC: OnceCell<MqttRpc> = OnceCell::const_new();
MQTT_RPC
.get_or_init(|| async {
let mqtt_rpc = MqttRpc::new().await.unwrap();
mqtt_rpc.initialize().await.unwrap();
mqtt_rpc
})
.await
}
pub async fn new() -> anyhow::Result<Self> {
let client = mqtt::AsyncClient::new("tcp://192.168.31.11:1883")
.map_err(|err| anyhow::anyhow!("can not create MQTT client. {:?}", err))?;
client.set_connected_callback(|client| {
info!("MQTT server connected.");
client.subscribe("display-ambient-light/board/#", mqtt::QOS_1);
client.subscribe(format!("{}/#", DISPLAY_TOPIC), mqtt::QOS_1);
});
client.set_connection_lost_callback(|client| {
info!("MQTT server connection lost.");
});
client.set_disconnected_callback(|_, a1, a2| {
info!("MQTT server disconnected. {:?} {:?}", a1, a2);
});
let mut last_will_payload = serde_json::Map::new();
last_will_payload.insert("message".to_string(), json!("offline"));
last_will_payload.insert(
"time".to_string(),
serde_json::Value::String(
OffsetDateTime::now_utc()
.format(&time::format_description::well_known::iso8601::Iso8601::DEFAULT)
.unwrap()
.to_string(),
),
);
let last_will = mqtt::Message::new(
format!("{}/status", DESKTOP_TOPIC),
serde_json::to_string(&last_will_payload)
.unwrap()
.as_bytes(),
mqtt::QOS_1,
);
let connect_options = mqtt::ConnectOptionsBuilder::new()
.keep_alive_interval(Duration::from_secs(5))
.will_message(last_will)
.automatic_reconnect(Duration::from_secs(1), Duration::from_secs(5))
.finalize();
let token = client.connect(connect_options);
token.await.map_err(|err| {
anyhow::anyhow!(
"can not connect MQTT server. wait for connect token failed. {:?}",
err
)
})?;
// let (change_display_brightness_tx, _) =
// broadcast::channel::<display::DisplayBrightness>(16);
// let (message_tx, _) = broadcast::channel::<models::CmdMqMessage>(32);
Ok(Self { client })
}
pub async fn listen(&self) {
// let change_display_brightness_tx2 = self.change_display_brightness_tx.clone();
// let message_tx_cloned = self.message_tx.clone();
// let mut stream = self.client.to_owned().get_stream(100);
// while let Some(notification) = stream.next().await {
// match notification {
// Some(notification) => match notification.topic() {
// DISPLAY_BRIGHTNESS_TOPIC => {
// let payload_text = String::from_utf8(notification.payload().to_vec());
// match payload_text {
// Ok(payload_text) => {
// let display_brightness: Result<display::DisplayBrightness, _> =
// serde_json::from_str(payload_text.as_str());
// match display_brightness {
// Ok(display_brightness) => {
// match change_display_brightness_tx2.send(display_brightness)
// {
// Ok(_) => {}
// Err(err) => {
// warn!(
// "can not send display brightness to channel. {:?}",
// err
// );
// }
// }
// }
// Err(err) => {
// warn!(
// "can not parse display brightness from payload. {:?}",
// err
// );
// }
// }
// }
// Err(err) => {
// warn!("can not parse display brightness from payload. {:?}", err);
// }
// }
// }
// BOARD_SEND_CMD => {
// let payload_text = String::from_utf8(notification.payload().to_vec());
// match payload_text {
// Ok(payload_text) => {
// let message: Result<models::CmdMqMessage, _> =
// serde_json::from_str(payload_text.as_str());
// match message {
// Ok(message) => match message_tx_cloned.send(message) {
// Ok(_) => {}
// Err(err) => {
// warn!("can not send message to channel. {:?}", err);
// }
// },
// Err(err) => {
// warn!("can not parse message from payload. {:?}", err);
// }
// }
// }
// Err(err) => {
// warn!("can not parse message from payload. {:?}", err);
// }
// }
// }
// _ => {}
// },
// _ => {
// warn!("can not get notification from MQTT server.");
// }
// }
// }
}
pub async fn initialize(&self) -> anyhow::Result<()> {
// self.subscribe_board()?;
// self.subscribe_display()?;
self.broadcast_desktop_online();
anyhow::Ok(())
}
fn subscribe_board(&self) -> anyhow::Result<()> {
self.client
.subscribe("display-ambient-light/board/#", mqtt::QOS_1)
.wait()
.map_err(|err| anyhow::anyhow!("subscribe board failed. {:?}", err))
.map(|_| ())
}
fn subscribe_display(&self) -> anyhow::Result<()> {
self.client
.subscribe(format!("{}/#", DISPLAY_TOPIC), mqtt::QOS_1)
.wait()
.map_err(|err| anyhow::anyhow!("subscribe board failed. {:?}", err))
.map(|_| ())
}
fn broadcast_desktop_online(&self) {
let client = self.client.to_owned();
task::spawn(async move {
loop {
match OffsetDateTime::now_utc()
.format(&format_description::well_known::Iso8601::DEFAULT)
{
Ok(now_str) => {
let msg = mqtt::Message::new(
"display-ambient-light/desktop/online",
now_str.as_bytes(),
mqtt::QOS_0,
);
match client.publish(msg).await {
Ok(_) => {}
Err(error) => {
warn!("can not publish last online time. {}", error)
}
}
}
Err(error) => {
warn!("can not get time for now. {}", error);
}
}
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}
pub async fn publish_led_sub_pixels(&self, payload: Vec<u8>) -> anyhow::Result<()> {
self.client
.publish(mqtt::Message::new(
"display-ambient-light/desktop/colors",
payload,
mqtt::QOS_1,
))
.await
.map_err(|error| anyhow::anyhow!("mqtt publish failed. {}", error))
}
// pub fn subscribe_change_display_brightness_rx(
// &self,
// ) -> broadcast::Receiver<display::DisplayBrightness> {
// self.change_display_brightness_tx.subscribe()
// }
pub async fn publish_desktop_cmd(&self, field: &str, payload: Vec<u8>) -> anyhow::Result<()> {
self.client
.publish(mqtt::Message::new(
format!("{}/{}", DESKTOP_TOPIC, field),
payload,
mqtt::QOS_1,
))
.await
.map_err(|error| anyhow::anyhow!("mqtt publish failed. {}", error))
}
}

218
src-tauri/src/rpc/udp.rs Normal file
View File

@ -0,0 +1,218 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use futures::future::join_all;
use mdns_sd::{ServiceDaemon, ServiceEvent};
use paris::{error, info, warn};
use tokio::sync::{watch, OnceCell, RwLock};
use super::{Board, BoardInfo};
#[derive(Debug, Clone)]
pub struct UdpRpc {
boards: Arc<RwLock<HashMap<String, Board>>>,
boards_change_sender: Arc<watch::Sender<Vec<BoardInfo>>>,
}
impl UdpRpc {
pub async fn global() -> &'static anyhow::Result<Self> {
static UDP_RPC: OnceCell<anyhow::Result<UdpRpc>> = OnceCell::const_new();
UDP_RPC
.get_or_init(|| async {
let udp_rpc = UdpRpc::new().await?;
udp_rpc.initialize().await;
Ok(udp_rpc)
})
.await
}
async fn new() -> anyhow::Result<Self> {
let boards = Arc::new(RwLock::new(HashMap::new()));
let (boards_change_sender, _) = watch::channel(Vec::new());
let boards_change_sender = Arc::new(boards_change_sender);
Ok(Self {
boards,
boards_change_sender,
})
}
async fn initialize(&self) {
let shared_self = Arc::new(self.clone());
let shared_self_for_search = shared_self.clone();
tokio::spawn(async move {
loop {
match shared_self_for_search.search_boards().await {
Ok(_) => {
info!("search_boards finished");
}
Err(err) => {
error!("search_boards failed: {:?}", err);
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
});
let shared_self_for_check = shared_self.clone();
tokio::spawn(async move {
shared_self_for_check.check_boards().await;
});
}
async fn search_boards(&self) -> anyhow::Result<()> {
let service_type = "_ambient_light._udp.local.";
let mdns = ServiceDaemon::new()?;
let receiver = mdns.browse(&service_type).map_err(|e| {
warn!("Failed to browse for {:?}: {:?}", service_type, e);
e
})?;
let sender = self.boards_change_sender.clone();
while let Ok(event) = receiver.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
info!(
"Resolved a new service: {} host: {} port: {} IP: {:?} TXT properties: {:?}",
info.get_fullname(),
info.get_hostname(),
info.get_port(),
info.get_addresses(),
info.get_properties(),
);
let mut boards = self.boards.write().await;
let board_info = BoardInfo::new(
info.get_fullname().to_string(),
info.get_hostname().to_string(),
info.get_addresses().iter().next().unwrap().clone(),
info.get_port(),
);
let mut board = Board::new(board_info.clone());
if let Err(err) = board.init_socket().await {
error!("failed to init socket: {:?}", err);
continue;
}
if boards.insert(board_info.fullname.clone(), board).is_some() {
info!("replace board {:?}", board_info);
} else {
info!("add board {:?}", board_info);
}
let tx_boards = boards
.values()
.map(|it| async move { it.info.read().await.clone() });
let tx_boards = join_all(tx_boards).await;
drop(boards);
sender.send(tx_boards)?;
}
ServiceEvent::ServiceRemoved(_, fullname) => {
info!("removed board {:?}", fullname);
let mut boards = self.boards.write().await;
if boards.remove(&fullname).is_some() {
info!("removed board {:?} successful", fullname);
}
let tx_boards = boards
.values()
.map(|it| async move { it.info.read().await.clone() });
let tx_boards = join_all(tx_boards).await;
drop(boards);
sender.send(tx_boards)?;
}
other_event => {
// log::info!("{:?}", &other_event);
}
}
tokio::task::yield_now().await;
}
Ok(())
}
pub fn subscribe_boards_change(&self) -> watch::Receiver<Vec<BoardInfo>> {
self.boards_change_sender.subscribe()
}
pub async fn get_boards(&self) -> Vec<BoardInfo> {
self.boards_change_sender.borrow().clone()
}
pub async fn send_to_all(&self, buff: &Vec<u8>) -> anyhow::Result<()> {
let boards = self.boards.read().await;
for board in boards.values() {
board.send_colors(buff).await;
}
// let socket = self.socket.clone();
// let handlers = boards.into_iter().map(|board| {
// if board.connect_status == BoardConnectStatus::Disconnected {
// return tokio::spawn(async move {
// log::debug!("board {} is disconnected, skip.", board.host);
// });
// }
// let socket = socket.clone();
// let buff = buff.clone();
// tokio::spawn(async move {
// match socket.send_to(&buff, (board.address, board.port)).await {
// Ok(_) => {}
// Err(err) => {
// error!("failed to send to {}: {:?}", board.host, err);
// }
// }
// })
// });
// join_all(handlers).await;
Ok(())
}
pub async fn check_boards(&self) {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::task::yield_now().await;
interval.tick().await;
let boards = self.boards.read().await;
if boards.is_empty() {
info!("no boards found");
continue;
}
for board in boards.values() {
if let Err(err) = board.check().await {
error!("failed to check board: {:?}", err);
}
}
let tx_boards = boards
.values()
.map(|it| async move { it.info.read().await.clone() });
let tx_boards = join_all(tx_boards).await;
drop(boards);
let board_change_sender = self.boards_change_sender.clone();
if let Err(err) = board_change_sender.send(tx_boards) {
error!("failed to send board change: {:?}", err);
}
drop(board_change_sender);
}
}
}

View File

@ -0,0 +1,520 @@
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 and is still running
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() && state.is_running
} 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 > 500; // Force send every 500ms for better CPU performance
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) {
log::info!("Stopping stream for display_id: {}", display_id);
let mut streams = self.streams.write().await;
if let Some(stream_state) = streams.get(&display_id) {
// Mark stream as not running to stop the processing task
let mut state = stream_state.write().await;
state.is_running = false;
log::info!("Marked stream as not running for display_id: {}", display_id);
}
// Remove the stream from the map
streams.remove(&display_id);
log::info!("Removed stream from manager for display_id: {}", 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 display_id_for_cleanup = config.display_id;
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 => {},
}
// Clean up resources when connection ends
log::info!("WebSocket connection ending, cleaning up resources for display_id: {}", display_id_for_cleanup);
let stream_manager = ScreenStreamManager::global().await;
stream_manager.stop_stream(display_id_for_cleanup).await;
log::info!("WebSocket connection handler completed");
Ok(())
}

View File

@ -1,20 +1,35 @@
use std::cell::RefCell;
use std::{iter, cell::Ref};
use std::fmt::Formatter;
use std::{iter, fmt::Debug};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tauri::async_runtime::{RwLock, Mutex};
use tauri::async_runtime::RwLock;
use crate::{ambient_light::LedStripConfig, led_color::LedColor};
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct Screenshot {
pub display_id: u32,
pub height: u32,
pub width: u32,
pub bytes_per_row: usize,
pub bytes: Arc<RwLock<Vec<u8>>>,
pub bytes: Arc<RwLock<Arc<Vec<u8>>>>,
pub scale_factor: f32,
pub bound_scale_factor: f32,
}
impl Debug for Screenshot {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Screenshot")
.field("display_id", &self.display_id)
.field("height", &self.height)
.field("width", &self.width)
.field("bytes_per_row", &self.bytes_per_row)
.field("scale_factor", &self.scale_factor)
.field("bound_scale_factor", &self.bound_scale_factor)
.finish()
}
}
static SINGLE_AXIS_POINTS: usize = 5;
@ -25,8 +40,9 @@ impl Screenshot {
height: u32,
width: u32,
bytes_per_row: usize,
bytes: Vec<u8>,
bytes: Arc<Vec<u8>>,
scale_factor: f32,
bound_scale_factor: f32,
) -> Self {
Self {
display_id,
@ -35,13 +51,16 @@ impl Screenshot {
bytes_per_row,
bytes: Arc::new(RwLock::new(bytes)),
scale_factor,
bound_scale_factor,
}
}
pub fn get_sample_points(&self, config: &LedStripConfig) -> Vec<LedSamplePoints> {
let height = self.height as usize;
let width = self.width as usize;
// let height = CGDisplay::new(self.display_id).bounds().size.height as usize;
// let width = CGDisplay::new(self.display_id).bounds().size.width as usize;
match config.border {
crate::ambient_light::Border::Top => {
Self::get_one_edge_sample_points(height / 18, width, config.len, SINGLE_AXIS_POINTS)
@ -126,9 +145,16 @@ impl Screenshot {
for (x, y) in led_points {
// log::info!("x: {}, y: {}, bytes_per_row: {}", x, y, bytes_per_row);
let position = x * 4 + y * bytes_per_row;
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
// Add bounds checking to prevent index out of bounds
if position + 2 < bitmap.len() {
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
} else {
// Skip invalid positions or use default values
log::warn!("Invalid pixel position: x={}, y={}, position={}, bitmap_len={}", x, y, position, bitmap.len());
}
}
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);
colors.push(color);
@ -150,9 +176,17 @@ impl Screenshot {
for (x, y) in led_points {
// log::info!("x: {}, y: {}, bytes_per_row: {}", x, y, bytes_per_row);
let position = x * 4 + y * bytes_per_row;
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
// Add bounds checking to prevent index out of bounds
if position + 2 < bitmap.len() as usize {
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
} else {
// Skip invalid positions or use default values
log::warn!("Invalid pixel position in CG image: x={}, y={}, position={}, bitmap_len={}", x, y, position, bitmap.len());
}
// log::info!("position: {}, total: {}", position, bitmap.len());
}
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);
colors.push(color);

View File

@ -1,61 +1,31 @@
use std::cell::{Ref, RefCell};
use std::time::Duration;
use std::{collections::HashMap, sync::Arc};
use core_graphics::display::{
kCGNullWindowID, kCGWindowImageDefault, kCGWindowListOptionOnScreenOnly, CGDisplay,
};
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use paris::warn;
use paris::{info, warn};
use screen_capture_kit::shareable_content::{SCDisplay, SCShareableContent};
use screen_capture_kit::stream::{SCStream, SCStreamConfiguration, SCContentFilter, SCStreamOutput};
use screen_capture_kit::stream::SCStreamDelegate;
use tauri::async_runtime::RwLock;
use tokio::sync::{broadcast, watch, OnceCell};
use tokio::time::{self, Duration};
use tokio::task::yield_now;
use tokio::time::sleep;
use crate::screenshot::LedSamplePoints;
use crate::{
ambient_light::{SamplePointConfig, SamplePointMapper},
led_color::LedColor,
screenshot::{ScreenSamplePoints, Screenshot},
};
pub fn take_screenshot(display_id: u32, scale_factor: f32) -> anyhow::Result<Screenshot> {
log::debug!("take_screenshot");
let cg_display = CGDisplay::new(display_id);
let cg_image = CGDisplay::screenshot(
cg_display.bounds(),
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowImageDefault,
)
.ok_or_else(|| anyhow::anyhow!("Display#{}: take screenshot failed", display_id))?;
let buffer = cg_image.data();
let bytes_per_row = cg_image.bytes_per_row();
let height = cg_image.height();
let width = cg_image.width();
let bytes = buffer.bytes().to_owned();
Ok(Screenshot::new(
display_id,
height as u32,
width as u32,
bytes_per_row,
bytes,
scale_factor,
))
}
use crate::{ambient_light::SamplePointMapper, led_color::LedColor, screenshot::Screenshot};
pub fn get_display_colors(
display_id: u32,
sample_points: &Vec<Vec<LedSamplePoints>>,
bound_scale_factor: f32,
) -> anyhow::Result<Vec<LedColor>> {
log::debug!("take_screenshot");
let cg_display = CGDisplay::new(display_id);
let mut colors = vec![];
let start_at = std::time::Instant::now();
for points in sample_points {
if points.len() == 0 {
continue;
@ -69,14 +39,23 @@ pub fn get_display_colors(
let (start_y, end_y) = (usize::min(start_y, end_y), usize::max(start_y, end_y));
let origin = CGPoint {
x: start_x as f64 + cg_display.bounds().origin.x,
y: start_y as f64 + cg_display.bounds().origin.y,
x: start_x as f64 * bound_scale_factor as f64 + cg_display.bounds().origin.x,
y: start_y as f64 * bound_scale_factor as f64 + cg_display.bounds().origin.y,
};
let size = CGSize {
width: (end_x - start_x + 1) as f64,
height: (end_y - start_y + 1) as f64,
};
// log::info!(
// "origin: {:?}, size: {:?}, start_x: {}, start_y: {}, bounds: {:?}",
// origin,
// size,
// start_x,
// start_y,
// cg_display.bounds().size
// );
let cg_image = CGDisplay::screenshot(
CGRect::new(&origin, &size),
kCGWindowListOptionOnScreenOnly,
@ -102,18 +81,11 @@ pub fn get_display_colors(
colors.append(&mut part_colors);
}
// if display_id == 4849664 {
// log::info!(
// "======= get_display_colors {} took {}ms",
// display_id,
// start_at.elapsed().as_millis()
// );
// }
Ok(colors)
}
pub struct ScreenshotManager {
pub channels: Arc<RwLock<HashMap<u32, watch::Receiver<Screenshot>>>>,
pub channels: Arc<RwLock<HashMap<u32, Arc<RwLock<watch::Sender<Screenshot>>>>>>,
merged_screenshot_tx: Arc<RwLock<broadcast::Sender<Screenshot>>>,
}
@ -133,90 +105,139 @@ impl ScreenshotManager {
.await
}
pub fn start(&self) -> anyhow::Result<()> {
pub async fn start(&self) -> anyhow::Result<()> {
let displays = display_info::DisplayInfo::all()?;
for display in displays {
self.start_one(display.id, display.scale_factor)?;
log::info!("ScreenshotManager starting with {} displays:", displays.len());
for display in &displays {
log::info!(" Display ID: {}, Scale: {}", display.id, display.scale_factor);
}
Ok(())
}
fn start_one(&self, display_id: u32, scale_factor: f32) -> anyhow::Result<()> {
let channels = self.channels.to_owned();
let merged_screenshot_tx = self.merged_screenshot_tx.clone();
tokio::spawn(async move {
let screenshot = take_screenshot(display_id, scale_factor);
let futures = displays.iter().map(|display| async {
self.start_one(display.id, display.scale_factor)
.await
.unwrap_or_else(|err| {
warn!("start_one failed: display_id: {}, err: {}", display.id, err);
});
if screenshot.is_err() {
warn!("take_screenshot_loop: {}", screenshot.err().unwrap());
return;
}
let mut interval = time::interval(Duration::from_millis(3300));
let mut start = tokio::time::Instant::now();
let screenshot = screenshot.unwrap();
let (screenshot_tx, screenshot_rx) = watch::channel(screenshot);
{
let channels = channels.clone();
let mut channels = channels.write().await;
channels.insert(display_id, screenshot_rx.clone());
}
let merged_screenshot_tx = merged_screenshot_tx.read().await.clone();
loop {
start = tokio::time::Instant::now();
Self::take_screenshot_loop(
display_id,
scale_factor,
&screenshot_tx,
&merged_screenshot_tx,
)
.await;
interval.tick().await;
tokio::time::sleep(Duration::from_millis(1)).await;
}
});
futures::future::join_all(futures).await;
log::info!("ScreenshotManager started successfully");
Ok(())
}
async fn take_screenshot_loop(
display_id: u32,
scale_factor: f32,
screenshot_tx: &watch::Sender<Screenshot>,
merged_screenshot_tx: &broadcast::Sender<Screenshot>,
) {
let screenshot = take_screenshot(display_id, scale_factor);
if let Ok(screenshot) = screenshot {
match merged_screenshot_tx.send(screenshot.clone()) {
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();
let (tx, _) = watch::channel(Screenshot::new(
display_id,
0,
0,
0,
Arc::new(vec![]),
scale_factor,
scale_factor,
));
let tx = Arc::new(RwLock::new(tx));
let mut channels = self.channels.write().await;
channels.insert(display_id, tx.clone());
drop(channels);
// Implement screen capture using screen-capture-kit
loop {
match Self::capture_display_screenshot(display_id, scale_factor).await {
Ok(screenshot) => {
let tx_for_send = tx.read().await;
let merged_screenshot_tx = merged_screenshot_tx.write().await;
if let Err(err) = merged_screenshot_tx.send(screenshot.clone()) {
// log::warn!("merged_screenshot_tx.send failed: {}", err);
}
if let Err(err) = tx_for_send.send(screenshot.clone()) {
log::warn!("display {} screenshot_tx.send failed: {}", display_id, err);
}
}
Err(err) => {
// warn!("take_screenshot_loop: merged_screenshot_tx.send failed. display#{}. err: {}", display_id, err);
warn!("Failed to capture screenshot for display {}: {}", display_id, err);
// Create a fallback empty screenshot to maintain the interface
let screenshot = Screenshot::new(
display_id,
1080,
1920,
1920 * 4, // Assuming RGBA format
Arc::new(vec![0u8; 1920 * 1080 * 4]),
scale_factor,
scale_factor,
);
let tx_for_send = tx.read().await;
let merged_screenshot_tx = merged_screenshot_tx.write().await;
if let Err(err) = merged_screenshot_tx.send(screenshot.clone()) {
// log::warn!("merged_screenshot_tx.send failed: {}", err);
}
if let Err(err) = tx_for_send.send(screenshot.clone()) {
log::warn!("display {} screenshot_tx.send failed: {}", display_id, err);
}
}
}
screenshot_tx.send(screenshot).unwrap();
// log::info!("take_screenshot_loop: send success. display#{}", display_id)
} else {
warn!("take_screenshot_loop: {}", screenshot.err().unwrap());
// Sleep for a frame duration (5 FPS for much better CPU performance)
sleep(Duration::from_millis(200)).await;
yield_now().await;
}
}
pub async fn get_all_colors(
&self,
configs: &Vec<SamplePointConfig>,
screenshots: &Vec<&Screenshot>,
) -> Vec<LedColor> {
let mut all_colors = vec![];
async fn capture_display_screenshot(display_id: u32, scale_factor: f32) -> anyhow::Result<Screenshot> {
// For now, use the existing CGDisplay approach as a fallback
// TODO: Implement proper screen-capture-kit integration
for (index, screenshot) in screenshots.iter().enumerate() {
let config = &configs[index];
let mut colors = screenshot.get_colors_by_sample_points(&config.points).await;
all_colors.append(&mut colors);
}
let cg_display = CGDisplay::new(display_id);
let bounds = cg_display.bounds();
all_colors
let cg_image = CGDisplay::screenshot(
bounds,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowImageDefault,
)
.ok_or_else(|| anyhow::anyhow!("Display#{}: take screenshot failed - possibly no screen recording permission", display_id))?;
let bitmap = cg_image.data();
let width = cg_image.width() as u32;
let height = cg_image.height() as u32;
let bytes_per_row = cg_image.bytes_per_row();
// Convert CFData to Vec<u8>
let data_ptr = bitmap.bytes().as_ptr();
let data_len = bitmap.len() as usize;
let screenshot_data = unsafe {
std::slice::from_raw_parts(data_ptr, data_len).to_vec()
};
Ok(Screenshot::new(
display_id,
height,
width,
bytes_per_row,
Arc::new(screenshot_data),
scale_factor,
scale_factor,
))
}
pub fn get_sorted_colors(colors: &Vec<u8>, mappers: &Vec<SamplePointMapper>) -> Vec<u8> {
@ -271,4 +292,16 @@ impl ScreenshotManager {
pub async fn clone_merged_screenshot_rx(&self) -> broadcast::Receiver<Screenshot> {
self.merged_screenshot_tx.read().await.subscribe()
}
pub async fn subscribe_by_display_id(
&self,
display_id: u32,
) -> anyhow::Result<watch::Receiver<Screenshot>> {
let channels = self.channels.read().await;
if let Some(tx) = channels.get(&display_id) {
Ok(tx.read().await.subscribe())
} else {
Err(anyhow::anyhow!("display_id: {} not found", display_id))
}
}
}

View File

@ -0,0 +1,203 @@
use std::{mem, sync::Arc};
use coreaudio::{
audio_unit::macos_helpers::get_default_device_id,
sys::{
kAudioHardwareServiceDeviceProperty_VirtualMasterVolume, kAudioObjectPropertyScopeOutput,
AudioObjectGetPropertyData, AudioObjectHasProperty, AudioObjectPropertyAddress,
AudioObjectSetPropertyData,
},
};
use paris::error;
use tokio::sync::{OnceCell, RwLock};
use crate::rpc::BoardMessageChannels;
pub struct VolumeManager {
current_volume: Arc<RwLock<f32>>,
handler: Option<tokio::task::JoinHandle<()>>,
read_handler: Option<tokio::task::JoinHandle<()>>,
}
impl VolumeManager {
pub async fn global() -> &'static Self {
static VOLUME_MANAGER: OnceCell<VolumeManager> = OnceCell::const_new();
VOLUME_MANAGER
.get_or_init(|| async { Self::create() })
.await
}
pub fn create() -> Self {
let mut instance = Self {
current_volume: Arc::new(RwLock::new(0.0)),
handler: None,
read_handler: None,
};
instance.subscribe_volume_setting_request();
instance.auto_read_volume();
instance
}
fn subscribe_volume_setting_request(&mut self) {
let handler = tokio::spawn(async {
let channels = BoardMessageChannels::global().await;
let mut request_rx = channels.volume_setting_request_sender.subscribe();
while let Ok(volume) = request_rx.recv().await {
if let Err(err) = Self::set_volume(volume) {
error!("failed to set volume: {}", err);
}
}
});
self.handler = Some(handler);
}
fn auto_read_volume(&mut self) {
let current_volume = self.current_volume.clone();
let handler = tokio::spawn(async move {
let channel = BoardMessageChannels::global().await;
let volume_changed_tx = channel.volume_changed_sender.clone();
loop {
match Self::read_volume() {
Ok(value) => {
let mut volume = current_volume.write().await;
if *volume != value {
if let Err(err) = volume_changed_tx.send(value) {
error!("failed to send volume changed event: {}", err);
}
}
*volume = value;
}
Err(err) => {
error!("failed to read volume: {}", err);
}
}
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
});
self.read_handler = Some(handler);
}
fn set_volume(volume: f32) -> anyhow::Result<()> {
log::debug!("set volume: {}", volume);
let device_id = get_default_device_id(false);
if device_id.is_none() {
anyhow::bail!("default audio output device is not found.");
}
let device_id = device_id.unwrap();
let address = AudioObjectPropertyAddress {
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMasterVolume,
mScope: kAudioObjectPropertyScopeOutput,
mElement: 0,
};
log::debug!("device id: {}", device_id);
log::debug!("address: {:?}", address);
if 0 == unsafe { AudioObjectHasProperty(device_id, &address) } {
anyhow::bail!("Can not get audio property");
}
let size = mem::size_of::<f32>() as u32;
let result = unsafe {
AudioObjectSetPropertyData(
device_id,
&address,
0,
std::ptr::null(),
size,
&volume as *const f32 as *const std::ffi::c_void,
)
};
if result != 0 {
anyhow::bail!("Can not set audio property");
}
Ok(())
}
fn read_volume() -> anyhow::Result<f32> {
let device_id = get_default_device_id(false);
if device_id.is_none() {
anyhow::bail!("default audio output device is not found.");
}
let device_id = device_id.unwrap();
let address = AudioObjectPropertyAddress {
mSelector: kAudioHardwareServiceDeviceProperty_VirtualMasterVolume,
mScope: kAudioObjectPropertyScopeOutput,
mElement: 0,
};
log::debug!("device id: {}", device_id);
log::debug!("address: {:?}", address);
if 0 == unsafe { AudioObjectHasProperty(device_id, &address) } {
anyhow::bail!("Can not get audio property");
}
let mut size = mem::size_of::<f32>() as u32;
let mut volume = 0.0f32;
let result = unsafe {
AudioObjectGetPropertyData(
device_id,
&address,
0,
std::ptr::null(),
&mut size,
&mut volume as *mut f32 as *mut std::ffi::c_void,
)
};
if result != 0 {
anyhow::bail!("Can not get audio property. result: {}", result);
}
if size != mem::size_of::<f32>() as u32 {
anyhow::bail!("Can not get audio property. data size is not matched.");
}
log::debug!("current system volume of primary device: {}", volume);
Ok(volume)
}
pub async fn get_volume(&self) -> f32 {
self.current_volume.read().await.clone()
}
}
impl Drop for VolumeManager {
fn drop(&mut self) {
log::info!("drop volume manager");
if let Some(handler) = self.handler.take() {
tokio::task::block_in_place(move || {
handler.abort();
});
}
if let Some(handler) = self.read_handler.take() {
tokio::task::block_in_place(move || {
handler.abort();
});
}
}
}

View File

@ -0,0 +1,3 @@
mod manager;
pub use manager::*;

View File

@ -1,40 +1,23 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "test-demo",
"version": "0.0.1",
"identifier": "cc.ivanli.ambient-light.desktop",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"package": {
"productName": "test-demo",
"version": "0.0.1"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
}
},
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "cc.ivanli.ambient-light.desktop",
"targets": "all"
},
"app": {
"withGlobalTauri": true,
"security": {
"csp": null
},
"updater": {
"active": false
"csp": null,
"assetProtocol": {
"scope": [
"**"
]
}
},
"windows": [
{
@ -45,5 +28,19 @@
"height": 600
}
]
},
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"targets": "all",
"macOS": {
"minimumSystemVersion": "13"
}
}
}

View File

@ -1,104 +1,69 @@
import { createEffect, onCleanup } from 'solid-js';
import { invoke } from '@tauri-apps/api/tauri';
import { DisplayView } from './components/display-view';
import { DisplayListContainer } from './components/display-list-container';
import { displayStore, setDisplayStore } from './stores/display.store';
import { LedStripConfigContainer } from './models/led-strip-config';
import { Routes, Route } from '@solidjs/router';
import { LedStripConfiguration } from './components/led-strip-configuration/led-strip-configuration';
import { WhiteBalance } from './components/white-balance/white-balance';
import { createEffect } from 'solid-js';
import { invoke } from '@tauri-apps/api/core';
import { setLedStripStore } from './stores/led-strip.store';
import { listen } from '@tauri-apps/api/event';
import { LedStripPartsSorter } from './components/led-strip-parts-sorter';
import { createStore } from 'solid-js/store';
import {
LedStripConfigurationContext,
LedStripConfigurationContextType,
} from './contexts/led-strip-configuration.context';
import { LedStripConfigContainer } from './models/led-strip-config';
import { InfoIndex } from './components/info/info-index';
import { DisplayStateIndex } from './components/displays/display-state-index';
function App() {
createEffect(() => {
invoke<string>('list_display_info').then((displays) => {
setDisplayStore({
displays: JSON.parse(displays),
});
});
invoke<LedStripConfigContainer>('read_led_strip_configs').then((configs) => {
console.log(configs);
setLedStripStore(configs);
});
});
// listen to config_changed event
createEffect(() => {
const unlisten = listen('config_changed', (event) => {
const { strips, mappers } = event.payload as LedStripConfigContainer;
console.log(event.payload);
invoke<LedStripConfigContainer>('read_config').then((config) => {
console.log('App: read config', config);
setLedStripStore({
strips,
mappers,
strips: config.strips,
mappers: config.mappers,
colorCalibration: config.color_calibration,
});
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
}).catch((error) => {
console.error('App: Failed to read config:', error);
});
});
// listen to led_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_colors_changed', (event) => {
const colors = event.payload;
setLedStripStore({
colors,
});
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
// listen to led_sorted_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_sorted_colors_changed', (event) => {
const sortedColors = event.payload;
setLedStripStore({
sortedColors,
});
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
const [ledStripConfiguration, setLedStripConfiguration] = createStore<
LedStripConfigurationContextType[0]
>({
selectedStripPart: null,
});
const ledStripConfigurationContextValue: LedStripConfigurationContextType = [
ledStripConfiguration,
{
setSelectedStripPart: (v) => {
setLedStripConfiguration({
selectedStripPart: v,
});
},
},
];
return (
<div>
<LedStripConfigurationContext.Provider value={ledStripConfigurationContextValue}>
<LedStripPartsSorter />
<DisplayListContainer>
{displayStore.displays.map((display) => {
return <DisplayView display={display} />;
})}
</DisplayListContainer>
</LedStripConfigurationContext.Provider>
<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>
);
}

View File

@ -1,42 +0,0 @@
import { Component, JSX, ParentComponent, splitProps } from 'solid-js';
import { DisplayInfo } from '../models/display-info.model';
type DisplayInfoItemProps = {
label: string;
};
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>
);
};
type DisplayInfoPanelProps = {
display: DisplayInfo;
} & JSX.HTMLAttributes<HTMLElement>;
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}>
<DisplayInfoItem label="ID">
<code>{localProps.display.id}</code>
</DisplayInfoItem>
<DisplayInfoItem label="Position">
({localProps.display.x}, {localProps.display.y})
</DisplayInfoItem>
<DisplayInfoItem label="Size">
{localProps.display.width} x {localProps.display.height}
</DisplayInfoItem>
<DisplayInfoItem label="Scale Factor">
{localProps.display.scale_factor}
</DisplayInfoItem>
<DisplayInfoItem label="is Primary">
{localProps.display.is_primary ? 'True' : 'False'}
</DisplayInfoItem>
</section>
);
};

View File

@ -0,0 +1,69 @@
import { Component, ParentComponent } from 'solid-js';
import { DisplayState } from '../../models/display-state.model';
type DisplayStateCardProps = {
state: DisplayState;
};
type ItemProps = {
label: string;
};
const Item: ParentComponent<ItemProps> = (props) => {
return (
<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 (
<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>
);
};

View File

@ -0,0 +1,72 @@
import { Component, For, createEffect, createSignal } from 'solid-js';
import { listen } from '@tauri-apps/api/event';
import debug from 'debug';
import { invoke } from '@tauri-apps/api/core';
import { DisplayState, RawDisplayState } from '../../models/display-state.model';
import { DisplayStateCard } from './display-state-card';
const logger = debug('app:components:displays:display-state-index');
export const DisplayStateIndex: Component = () => {
const [states, setStates] = createSignal<DisplayState[]>([]);
createEffect(() => {
const unlisten = listen<RawDisplayState[]>('displays_changed', (ev) => {
logger('displays_changed', ev);
setStates(
ev.payload.map((it) => ({
...it,
last_modified_at: new Date(it.last_modified_at.secs_since_epoch * 1000),
})),
);
});
invoke<RawDisplayState[]>('get_displays').then((states) => {
logger('get_displays', states);
setStates(
states.map((it) => ({
...it,
last_modified_at: new Date(it.last_modified_at.secs_since_epoch * 1000),
})),
);
});
return () => {
unlisten.then((unlisten) => unlisten());
};
});
return (
<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) => (
<div class="relative">
<DisplayStateCard state={state} />
<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>
</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>
);
};

View File

@ -0,0 +1,62 @@
import { Component, For, createEffect, createSignal } from 'solid-js';
import { BoardInfo } from '../../models/board-info.model';
import { listen } from '@tauri-apps/api/event';
import debug from 'debug';
import { invoke } from '@tauri-apps/api/core';
import { BoardInfoPanel } from './board-info-panel';
const logger = debug('app:components:info:board-index');
export const BoardIndex: Component = () => {
const [boards, setBoards] = createSignal<BoardInfo[]>([]);
createEffect(() => {
const unlisten = listen<BoardInfo[]>('boards_changed', (ev) => {
logger('boards_changed', ev);
setBoards(ev.payload);
});
invoke<BoardInfo[]>('get_boards').then((boards) => {
logger('get_boards', boards);
setBoards(boards);
});
return () => {
unlisten.then((unlisten) => unlisten());
};
});
return (
<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) => (
<div class="relative">
<BoardInfoPanel board={board} />
<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>
</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>
);
};

View File

@ -0,0 +1,71 @@
import { Component, ParentComponent, createMemo } from 'solid-js';
import { BoardInfo } from '../../models/board-info.model';
type ItemProps = {
label: string;
};
const Item: ParentComponent<ItemProps> = (props) => {
return (
<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 BoardInfoPanel: Component<{ board: BoardInfo }> = (props) => {
const ttl = createMemo(() => {
if (props.board.connect_status !== 'Connected') {
return '--';
}
if (props.board.ttl == null) {
return 'timeout';
}
return (
<>
<span class="font-mono">{props.board.ttl.toFixed(0)}</span> ms
</>
);
});
const connectStatus = createMemo(() => {
if (typeof props.board.connect_status === 'string') {
return props.board.connect_status;
}
if ('Connecting' in props.board.connect_status) {
return `Connecting (${props.board.connect_status.Connecting.toFixed(0)})`;
}
});
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 (
<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>
);
};

View File

@ -0,0 +1,10 @@
import { Component } from 'solid-js';
import { BoardIndex } from './board-index';
export const InfoIndex: Component = () => {
return (
<div>
<BoardIndex />
</div>
);
};

View File

@ -0,0 +1,49 @@
import { Component, JSX, ParentComponent, splitProps } from 'solid-js';
import { DisplayInfo } from '../../models/display-info.model';
type DisplayInfoItemProps = {
label: string;
};
export const DisplayInfoItem: ParentComponent<DisplayInfoItemProps> = (props) => {
return (
<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>
);
};
type DisplayInfoPanelProps = {
display: DisplayInfo;
} & JSX.HTMLAttributes<HTMLElement>;
export const DisplayInfoPanel: Component<DisplayInfoPanelProps> = (props) => {
const [localProps, rootProps] = splitProps(props, ['display']);
return (
<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 class="bg-base-200 px-1 rounded text-xs">{localProps.display.id}</code>
</DisplayInfoItem>
<DisplayInfoItem label="位置">
({localProps.display.x}, {localProps.display.y})
</DisplayInfoItem>
<DisplayInfoItem label="尺寸">
{localProps.display.width} × {localProps.display.height}
</DisplayInfoItem>
<DisplayInfoItem label="缩放">
{localProps.display.scale_factor}×
</DisplayInfoItem>
</div>
</div>
</div>
);
};

View File

@ -6,8 +6,8 @@ import {
onMount,
ParentComponent,
} from 'solid-js';
import { displayStore, setDisplayStore } from '../stores/display.store';
import background from '../assets/transparent-grid-background.svg?url';
import { displayStore, setDisplayStore } from '../../stores/display.store';
import background from '../../assets/transparent-grid-background.svg?url';
export const DisplayListContainer: ParentComponent = (props) => {
let root: HTMLElement;

View File

@ -1,11 +1,12 @@
import { Component, createMemo } from 'solid-js';
import { DisplayInfo } from '../models/display-info.model';
import { displayStore } from '../stores/display.store';
import { ledStripStore } from '../stores/led-strip.store';
import { DisplayInfo } from '../../models/display-info.model';
import { displayStore } from '../../stores/display.store';
import { ledStripStore } from '../../stores/led-strip.store';
import { DisplayInfoPanel } from './display-info-panel';
import { LedStripPart } from './led-strip-part';
import { ScreenView } from './screen-view';
type DisplayViewProps = {
display: DisplayInfo;
};
@ -23,7 +24,6 @@ export const DisplayView: Component<DisplayViewProps> = (props) => {
}));
const ledStripConfigs = createMemo(() => {
console.log('ledStripConfigs', ledStripStore.strips);
return ledStripStore.strips.filter((c) => c.display_id === props.display.id);
});

View File

@ -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">
💡 +/- LED0-1000
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,165 @@
import { createEffect, onCleanup } from 'solid-js';
import { invoke } from '@tauri-apps/api/core';
import { DisplayView } from './display-view';
import { DisplayListContainer } from './display-list-container';
import { displayStore, setDisplayStore } from '../../stores/display.store';
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,
LedStripConfigurationContextType,
} from '../../contexts/led-strip-configuration.context';
export const LedStripConfiguration = () => {
createEffect(() => {
invoke<string>('list_display_info').then((displays) => {
const parsedDisplays = JSON.parse(displays);
console.log('LedStripConfiguration: Loaded displays:', parsedDisplays);
setDisplayStore({
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);
});
});
// listen to config_changed event
createEffect(() => {
const unlisten = listen('config_changed', (event) => {
const { strips, mappers } = event.payload as LedStripConfigContainer;
setLedStripStore({
strips,
mappers,
});
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
// listen to led_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_colors_changed', (event) => {
if (!window.document.hidden) {
const colors = event.payload;
setLedStripStore({
colors,
});
}
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
// listen to led_sorted_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_sorted_colors_changed', (event) => {
if (!window.document.hidden) {
const sortedColors = event.payload;
setLedStripStore({
sortedColors,
});
}
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
const [ledStripConfiguration, setLedStripConfiguration] = createStore<
LedStripConfigurationContextType[0]
>({
selectedStripPart: null,
});
const ledStripConfigurationContextValue: LedStripConfigurationContextType = [
ledStripConfiguration,
{
setSelectedStripPart: (v) => {
setLedStripConfiguration({
selectedStripPart: v,
});
},
},
];
return (
<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>
);
};

View File

@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import {
Component,
createEffect,
@ -12,9 +12,9 @@ import {
} from 'solid-js';
import { useTippy } from 'solid-tippy';
import { followCursor } from 'tippy.js';
import { LedStripConfig } from '../models/led-strip-config';
import { LedStripConfigurationContext } from '../contexts/led-strip-configuration.context';
import { ledStripStore } from '../stores/led-strip.store';
import { LedStripConfig } from '../../models/led-strip-config';
import { LedStripConfigurationContext } from '../../contexts/led-strip-configuration.context';
import { ledStripStore } from '../../stores/led-strip.store';
type LedStripPartProps = {
config?: LedStripConfig | null;
@ -24,12 +24,6 @@ type PixelProps = {
color: string;
};
async function subscribeScreenshotUpdate(displayId: number) {
await invoke('subscribe_encoded_screenshot_updated', {
displayId,
});
}
export const Pixel: Component<PixelProps> = (props) => {
const style = createMemo(() => ({
background: props.color,
@ -40,7 +34,7 @@ export const Pixel: Component<PixelProps> = (props) => {
title={props.color}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-300"
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>
@ -51,7 +45,6 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
const [localProps, rootProps] = splitProps(props, ['config']);
const [stripConfiguration] = useContext(LedStripConfigurationContext);
const [ledSamplePoints, setLedSamplePoints] = createSignal();
const [colors, setColors] = createSignal<string[]>([]);
// update led strip colors from global store
@ -67,37 +60,49 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
);
if (index === -1) {
console.log('🔍 LED: Strip config not found', {
displayId: localProps.config.display_id,
border: localProps.config.border,
availableStrips: ledStripStore.strips.length
});
return;
}
const mapper = ledStripStore.mappers[index];
if (!mapper) {
console.log('🔍 LED: Mapper not found', { index, mappersCount: ledStripStore.mappers.length });
return;
}
const offset = mapper.pos * 3;
const offset = mapper.start * 3;
console.log('🎨 LED: Updating colors', {
displayId: localProps.config.display_id,
border: localProps.config.border,
stripLength: localProps.config.len,
mapperPos: mapper.pos,
offset,
colorsArrayLength: ledStripStore.colors.length,
firstFewColors: Array.from(ledStripStore.colors.slice(offset, offset + 9))
});
const colors = new Array(localProps.config.len).fill(null).map((_, i) => {
const index = offset + i * 3;
return `rgb(${ledStripStore.colors[index]}, ${ledStripStore.colors[index + 1]}, ${
ledStripStore.colors[index + 2]
})`;
const r = ledStripStore.colors[index] || 0;
const g = ledStripStore.colors[index + 1] || 0;
const b = ledStripStore.colors[index + 2] || 0;
return `rgb(${r}, ${g}, ${b})`;
});
console.log('🎨 LED: Generated colors', {
border: localProps.config.border,
colorsCount: colors.length,
sampleColors: colors.slice(0, 3)
});
setColors(colors);
});
// get led strip sample points
createEffect(() => {
if (localProps.config) {
invoke('get_led_strips_sample_points', {
config: localProps.config,
}).then((points) => {
setLedSamplePoints(points);
});
}
});
const [anchor, setAnchor] = createSignal<HTMLElement>();
useTippy(anchor, {
@ -119,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 ' +
'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={{
@ -147,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>

View File

@ -0,0 +1,316 @@
import {
batch,
Component,
createEffect,
createMemo,
createSignal,
For,
Index,
JSX,
Match,
onCleanup,
onMount,
Switch,
untrack,
useContext,
} from 'solid-js';
import { LedStripConfig, LedStripPixelMapper } from '../../models/led-strip-config';
import { ledStripStore } from '../../stores/led-strip.store';
import { invoke } from '@tauri-apps/api/core';
import { LedStripConfigurationContext } from '../../contexts/led-strip-configuration.context';
import background from '../../assets/transparent-grid-background.svg?url';
const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper }> = (
props,
) => {
const [leds, setLeds] = createSignal<Array<string | null>>([]);
const [dragging, setDragging] = createSignal<boolean>(false);
const [dragStart, setDragStart] = createSignal<{ x: number; y: number } | null>(null);
const [dragCurr, setDragCurr] = createSignal<{ x: number; y: number } | null>(null);
const [dragStartIndex, setDragStartIndex] = createSignal<number>(0);
const [cellWidth, setCellWidth] = createSignal<number>(0);
const [, { setSelectedStripPart }] = useContext(LedStripConfigurationContext);
const [rootWidth, setRootWidth] = createSignal<number>(0);
let root: HTMLDivElement;
const move = (targetStart: number) => {
if (targetStart === props.mapper.start) {
return;
}
console.log(
`moving strip part ${props.strip.display_id} ${props.strip.border} from ${props.mapper.start} to ${targetStart}`,
);
invoke('move_strip_part', {
displayId: props.strip.display_id,
border: props.strip.border,
targetStart,
}).catch((err) => console.error(err));
};
// reset translateX on config updated
createEffect(() => {
const indexDiff = props.mapper.start - dragStartIndex();
const start = untrack(dragStart);
const curr = untrack(dragCurr);
const _dragging = untrack(dragging);
if (start === null || curr === null) {
return;
}
if (_dragging && indexDiff !== 0) {
const compensation = indexDiff * cellWidth();
batch(() => {
setDragStartIndex(props.mapper.start);
setDragStart({
x: start.x + compensation,
y: curr.y,
});
});
} else {
batch(() => {
setDragStartIndex(props.mapper.start);
setDragStart(null);
setDragCurr(null);
});
}
});
const onPointerDown = (ev: PointerEvent) => {
if (ev.button !== 0) {
return;
}
batch(() => {
setDragging(true);
if (dragStart() === null) {
setDragStart({ x: ev.clientX, y: ev.clientY });
}
setDragCurr({ x: ev.clientX, y: ev.clientY });
setDragStartIndex(props.mapper.start);
});
};
const onPointerUp = (ev: PointerEvent) => {
if (ev.button !== 0) {
return;
}
if (dragging() === false) {
return;
}
setDragging(false);
const diff = ev.clientX - dragStart()!.x;
const moved = Math.round(diff / cellWidth());
if (moved === 0) {
return;
}
move(props.mapper.start + moved);
};
const onPointerMove = (ev: PointerEvent) => {
if (dragging() === false) {
return;
}
setSelectedStripPart({
displayId: props.strip.display_id,
border: props.strip.border,
});
if (!(ev.buttons & 1)) {
return;
}
const draggingInfo = dragging();
if (!draggingInfo) {
return;
}
setDragCurr({ x: ev.clientX, y: ev.clientY });
};
const onPointerLeave = () => {
setSelectedStripPart(null);
};
createEffect(() => {
onMount(() => {
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerleave', onPointerLeave);
window.addEventListener('pointerup', onPointerUp);
});
onCleanup(() => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerleave', onPointerLeave);
window.removeEventListener('pointerup', onPointerUp);
});
});
const reverse = () => {
invoke('reverse_led_strip_part', {
displayId: props.strip.display_id,
border: props.strip.border,
}).catch((err) => console.error(err));
};
const setColor = (fullIndex: number, colorsIndex: number, fullLeds: string[]) => {
const colors = ledStripStore.colors;
let c1 = `rgb(${Math.floor(colors[colorsIndex * 3] * 0.8)}, ${Math.floor(
colors[colorsIndex * 3 + 1] * 0.8,
)}, ${Math.floor(colors[colorsIndex * 3 + 2] * 0.8)})`;
let c2 = `rgb(${Math.min(Math.floor(colors[colorsIndex * 3] * 1.2), 255)}, ${Math.min(
Math.floor(colors[colorsIndex * 3 + 1] * 1.2),
255,
)}, ${Math.min(Math.floor(colors[colorsIndex * 3 + 2] * 1.2), 255)})`;
if (fullLeds.length <= fullIndex) {
console.error('out of range', fullIndex, fullLeds.length);
return;
}
fullLeds[fullIndex] = `linear-gradient(70deg, ${c1} 10%, ${c2})`;
};
// update fullLeds
createEffect(() => {
const { start, end, pos } = props.mapper;
const leds = new Array(Math.abs(start - end)).fill(null);
if (start < end) {
for (let i = 0, j = pos; i < leds.length; i++, j++) {
setColor(i, j, leds);
}
} else {
for (let i = leds.length - 1, j = pos; i >= 0; i--, j++) {
setColor(i, j, leds);
}
}
setLeds(leds);
});
// update rootWidth
createEffect(() => {
let observer: ResizeObserver;
onMount(() => {
observer = new ResizeObserver(() => {
setRootWidth(root.clientWidth);
});
observer.observe(root);
});
onCleanup(() => {
observer?.unobserve(root);
});
});
// update cellWidth
createEffect(() => {
const cellWidth = rootWidth() / ledStripStore.totalLedCount;
setCellWidth(cellWidth);
});
const style = createMemo<JSX.CSSProperties>(() => {
return {
transform: `translateX(${
(dragCurr()?.x ?? 0) -
(dragStart()?.x ?? 0) +
cellWidth() * Math.min(props.mapper.start, props.mapper.end)
}px)`,
width: `${cellWidth() * leds().length}px`,
};
});
return (
<div
class="flex mx-2 select-none cursor-ew-resize focus:cursor-ew-resize"
onPointerDown={onPointerDown}
ondblclick={reverse}
ref={root!}
>
<div
style={style()}
class="rounded-full border border-white flex h-3"
classList={{
'bg-gradient-to-b from-yellow-500/60 to-orange-300/60': dragging(),
'bg-gradient-to-b from-white/50 to-stone-500/40': !dragging(),
}}
>
<For each={leds()}>
{(it) => (
<div
class="flex-auto flex h-full w-full justify-center items-center relative"
title={it ?? ''}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-100"
classList={{ 'ring-stone-300/50': !it }}
style={{ background: it ?? 'transparent' }}
/>
</div>
)}
</For>
</div>
</div>
);
};
const SorterResult: Component = () => {
const [fullLeds, setFullLeds] = createSignal<string[]>([]);
createEffect(() => {
const colors = ledStripStore.sortedColors;
const fullLeds = new Array(ledStripStore.totalLedCount)
.fill('rgba(255,255,255,0.1)')
.map((_, i) => {
let c1 = `rgb(${Math.floor(colors[i * 3] * 0.8)}, ${Math.floor(
colors[i * 3 + 1] * 0.8,
)}, ${Math.floor(colors[i * 3 + 2] * 0.8)})`;
let c2 = `rgb(${Math.min(Math.floor(colors[i * 3] * 1.2), 255)}, ${Math.min(
Math.floor(colors[i * 3 + 1] * 1.2),
255,
)}, ${Math.min(Math.floor(colors[i * 3 + 2] * 1.2), 255)})`;
return `linear-gradient(70deg, ${c1} 10%, ${c2})`;
});
setFullLeds(fullLeds);
});
return (
<div class="flex h-2 m-2">
<For each={fullLeds()}>
{(it) => (
<div
class="flex-auto flex h-full w-full justify-center items-center relative"
title={it}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-300"
style={{ background: it }}
/>
</div>
)}
</For>
</div>
);
};
export const LedStripPartsSorter: Component = () => {
return (
<div
class="select-none overflow-hidden"
style={{
'background-image': `url(${background})`,
}}
>
<SorterResult />
<Index each={ledStripStore.strips}>
{(strip, index) => (
<Switch>
<Match when={strip().len > 0}>
<SorterItem strip={strip()} mapper={ledStripStore.mappers[index]} />
</Match>
</Switch>
)}
</Index>
</div>
);
};

View 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>
);
};

View File

@ -0,0 +1,237 @@
import {
Component,
createEffect,
createSignal,
JSX,
onCleanup,
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', '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);
// Cache temporary canvas for scaling
let tempCanvas: HTMLCanvasElement | null = null;
let tempCtx: CanvasRenderingContext2D | null = null;
const [drawInfo, setDrawInfo] = createSignal({
drawX: 0,
drawY: 0,
drawWidth: 0,
drawHeight: 0,
});
const [imageData, setImageData] = createSignal<{
buffer: Uint8ClampedArray;
width: number;
height: number;
} | null>(null);
const [hidden, setHidden] = createSignal(false);
const [isLoading, setIsLoading] = createSignal(false);
let isMounted = true;
// Fetch screenshot data from backend with frame-based rendering
const fetchScreenshot = async () => {
if (isLoading()) {
return; // Skip if already loading - frame-based approach
}
try {
setIsLoading(true);
const timestamp = Date.now();
const response = await fetch(`ambient-light://displays/${localProps.displayId}?width=400&height=225&t=${timestamp}`);
if (!response.ok) {
console.error('Screenshot fetch failed:', response.status);
return;
}
const width = parseInt(response.headers.get('X-Image-Width') || '400');
const height = parseInt(response.headers.get('X-Image-Height') || '225');
const arrayBuffer = await response.arrayBuffer();
const buffer = new Uint8ClampedArray(arrayBuffer);
const expectedSize = width * height * 4;
// Validate buffer size
if (buffer.length !== expectedSize) {
console.error('Invalid buffer size:', buffer.length, 'expected:', expectedSize);
return;
}
setImageData({
buffer,
width,
height
});
// Draw immediately after data is set
setTimeout(() => {
draw(false);
}, 0);
// Frame-based rendering: wait for current frame to complete before scheduling next
const shouldContinue = !hidden() && isMounted;
if (shouldContinue) {
setTimeout(() => {
if (isMounted) {
fetchScreenshot(); // Start next frame only after current one completes
}
}, 500); // Reduced frequency to 500ms for better performance
}
} catch (error) {
console.error('Error fetching screenshot:', error);
// On error, wait longer before retry
const shouldContinueOnError = !hidden() && isMounted;
if (shouldContinueOnError) {
setTimeout(() => {
if (isMounted) {
fetchScreenshot();
}
}, 2000);
}
} finally {
setIsLoading(false);
}
};
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,
});
draw(true);
};
const draw = (cached: boolean = false) => {
const { drawX, drawY, drawWidth, drawHeight } = drawInfo();
let _ctx = ctx();
let raw = imageData();
if (_ctx && raw) {
_ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply transparency effect for cached images if needed
let buffer = raw.buffer;
if (cached) {
buffer = new Uint8ClampedArray(raw.buffer);
for (let i = 3; i < buffer.length; i += 4) {
buffer[i] = Math.floor(buffer[i] * 0.7);
}
}
try {
// Create ImageData and draw directly
const img = new ImageData(buffer, raw.width, raw.height);
// If the image size matches the draw size, use putImageData directly
if (raw.width === drawWidth && raw.height === drawHeight) {
_ctx.putImageData(img, drawX, drawY);
} else {
// 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');
}
if (tempCtx) {
tempCtx.putImageData(img, 0, 0);
_ctx.drawImage(tempCanvas, drawX, drawY, drawWidth, drawHeight);
}
}
} catch (error) {
console.error('Error in draw():', error);
}
}
};
// Initialize canvas and resize observer
onMount(() => {
const context = canvas.getContext('2d');
setCtx(context);
// Initial size setup
resetSize();
const resizeObserver = new ResizeObserver(() => {
resetSize();
});
resizeObserver.observe(root);
// Start screenshot fetching after context is ready
setTimeout(() => {
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);
});
});
// Note: Removed window focus/blur logic as it was causing screenshot loop to stop
// when user interacted with dev tools or other windows
return (
<div
ref={root!}
{...rootProps}
class={'overflow-hidden h-full w-full ' + rootProps.class}
>
<canvas
ref={canvas!}
style={{
display: 'block',
width: '100%',
height: '100%',
'background-color': '#f0f0f0'
}}
/>
{rootProps.children}
</div>
);
};

View File

@ -1,228 +0,0 @@
import {
batch,
Component,
createContext,
createEffect,
createMemo,
createSignal,
For,
Index,
JSX,
on,
untrack,
useContext,
} from 'solid-js';
import { LedStripConfig, LedStripPixelMapper } from '../models/led-strip-config';
import { ledStripStore } from '../stores/led-strip.store';
import { invoke } from '@tauri-apps/api';
import { LedStripConfigurationContext } from '../contexts/led-strip-configuration.context';
import background from '../assets/transparent-grid-background.svg?url';
const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper }> = (
props,
) => {
const [fullLeds, setFullLeds] = createSignal<Array<string | null>>([]);
const [dragging, setDragging] = createSignal<boolean>(false);
const [dragStart, setDragStart] = createSignal<{ x: number; y: number } | null>(null);
const [dragCurr, setDragCurr] = createSignal<{ x: number; y: number } | null>(null);
const [dragStartIndex, setDragStartIndex] = createSignal<number>(0);
const [cellWidth, setCellWidth] = createSignal<number>(0);
const [, { setSelectedStripPart }] = useContext(LedStripConfigurationContext);
const move = (targetStart: number) => {
if (targetStart === props.mapper.start) {
return;
}
invoke('move_strip_part', {
displayId: props.strip.display_id,
border: props.strip.border,
targetStart,
}).catch((err) => console.error(err));
};
// reset translateX on config updated
createEffect(() => {
const indexDiff = props.mapper.start - dragStartIndex();
untrack(() => {
if (!dragStart() || !dragCurr()) {
return;
}
const compensation = indexDiff * cellWidth();
batch(() => {
setDragStartIndex(props.mapper.start);
setDragStart({
x: dragStart()!.x + compensation,
y: dragCurr()!.y,
});
});
});
});
const onPointerDown = (ev: PointerEvent) => {
if (ev.button !== 0) {
return;
}
setDragging(true);
setDragStart({ x: ev.clientX, y: ev.clientY });
setDragCurr({ x: ev.clientX, y: ev.clientY });
setDragStartIndex(props.mapper.start);
};
const onPointerUp = () => (ev: PointerEvent) => {
if (ev.button !== 0) {
return;
}
setDragging(false);
};
const onPointerMove = (ev: PointerEvent) => {
setSelectedStripPart({
displayId: props.strip.display_id,
border: props.strip.border,
});
if (!(ev.buttons & 1)) {
return;
}
const draggingInfo = dragging();
if (!draggingInfo) {
return;
}
setDragCurr({ x: ev.clientX, y: ev.clientY });
const cellWidth =
(ev.currentTarget as HTMLDivElement).clientWidth / ledStripStore.totalLedCount;
const diff = ev.clientX - dragStart()!.x;
const moved = Math.round(diff / cellWidth);
if (moved === 0) {
return;
}
setCellWidth(cellWidth);
move(props.mapper.start + moved);
};
const onPointerLeave = () => {
setSelectedStripPart(null);
};
const reverse = () => {
invoke('reverse_led_strip_part', {
displayId: props.strip.display_id,
border: props.strip.border,
}).catch((err) => console.error(err));
};
// update fullLeds
createEffect(() => {
const fullLeds = new Array(ledStripStore.totalLedCount).fill(null);
const colors = ledStripStore.colors;
const { start, end, pos } = props.mapper;
const isForward = start < end;
const step = isForward ? 1 : -1;
for (let i = start, j = pos; i !== end; i += step, j++) {
let c1 = `rgb(${Math.floor(colors[j * 3] * 0.8)}, ${Math.floor(
colors[j * 3 + 1] * 0.8,
)}, ${Math.floor(colors[j * 3 + 2] * 0.8)})`;
let c2 = `rgb(${Math.min(Math.floor(colors[j * 3] * 1.2), 255)}, ${Math.min(
Math.floor(colors[j * 3 + 1] * 1.2),
255,
)}, ${Math.min(Math.floor(colors[j * 3 + 2] * 1.2), 255)})`;
fullLeds[i] = `linear-gradient(70deg, ${c1} 10%, ${c2})`;
}
setFullLeds(fullLeds);
});
const style = createMemo<JSX.CSSProperties>(() => {
return {
transform: `translateX(${(dragCurr()?.x ?? 0) - (dragStart()?.x ?? 0)}px)`,
};
});
return (
<div
class="flex h-2 m-2 select-none cursor-ew-resize focus:cursor-ew-resize"
style={style()}
onPointerMove={onPointerMove}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
ondblclick={reverse}
>
<For each={fullLeds()}>
{(it) => (
<div
class="flex-auto flex h-full w-full justify-center items-center relative"
title={it ?? ''}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-100"
classList={{ 'ring-stone-300/50': !it }}
style={{ background: it ?? 'transparent' }}
/>
</div>
)}
</For>
</div>
);
};
const SorterResult: Component = () => {
const [fullLeds, setFullLeds] = createSignal<string[]>([]);
createEffect(() => {
const colors = ledStripStore.sortedColors;
const fullLeds = new Array(ledStripStore.totalLedCount)
.fill('rgba(255,255,255,0.1)')
.map((_, i) => {
let c1 = `rgb(${Math.floor(colors[i * 3] * 0.8)}, ${Math.floor(
colors[i * 3 + 1] * 0.8,
)}, ${Math.floor(colors[i * 3 + 2] * 0.8)})`;
let c2 = `rgb(${Math.min(Math.floor(colors[i * 3] * 1.2), 255)}, ${Math.min(
Math.floor(colors[i * 3 + 1] * 1.2),
255,
)}, ${Math.min(Math.floor(colors[i * 3 + 2] * 1.2), 255)})`;
return `linear-gradient(70deg, ${c1} 10%, ${c2})`;
});
setFullLeds(fullLeds);
});
return (
<div class="flex h-2 m-2">
<For each={fullLeds()}>
{(it) => (
<div
class="flex-auto flex h-full w-full justify-center items-center relative"
title={it}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-300"
style={{ background: it }}
/>
</div>
)}
</For>
</div>
);
};
export const LedStripPartsSorter: Component = () => {
return (
<div
class="select-none overflow-hidden"
style={{
'background-image': `url(${background})`,
}}
>
<SorterResult />
<Index each={ledStripStore.strips}>
{(strip, index) => (
<SorterItem strip={strip()} mapper={ledStripStore.mappers[index]} />
)}
</Index>
</div>
);
};

View File

@ -1,165 +0,0 @@
import { convertFileSrc } from '@tauri-apps/api/tauri';
import {
Component,
createEffect,
createSignal,
JSX,
onCleanup,
onMount,
splitProps,
} from 'solid-js';
type ScreenViewProps = {
displayId: number;
} & JSX.HTMLAttributes<HTMLDivElement>;
export const ScreenView: Component<ScreenViewProps> = (props) => {
const [localProps, rootProps] = splitProps(props, ['displayId']);
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 [imageData, setImageData] = createSignal<{
buffer: Uint8ClampedArray;
width: number;
height: number;
} | null>(null);
const [hidden, setHidden] = createSignal(false);
const resetSize = () => {
const aspectRatio = canvas.width / canvas.height;
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,
});
canvas.width = root.clientWidth;
canvas.height = root.clientHeight;
draw(true);
};
const draw = (cached: boolean = false) => {
const { drawX, drawY } = drawInfo();
let _ctx = ctx();
let raw = imageData();
if (_ctx && raw) {
_ctx.clearRect(0, 0, canvas.width, canvas.height);
if (cached) {
for (let i = 3; i < raw.buffer.length; i += 8) {
raw.buffer[i] = Math.floor(raw.buffer[i] * 0.7);
}
}
const img = new ImageData(raw.buffer, raw.width, raw.height);
_ctx.putImageData(img, drawX, drawY);
}
};
// get screenshot
createEffect(() => {
let stopped = false;
const frame = async () => {
const { drawWidth, drawHeight } = drawInfo();
const url = convertFileSrc(
`displays/${localProps.displayId}?width=${drawWidth}&height=${drawHeight}`,
'ambient-light',
);
await fetch(url, {
mode: 'cors',
})
.then((res) => res.body?.getReader().read())
.then((buffer) => {
if (buffer?.value) {
setImageData({
buffer: new Uint8ClampedArray(buffer?.value),
width: drawWidth,
height: drawHeight,
});
} else {
setImageData(null);
}
draw();
});
};
(async () => {
while (!stopped) {
if (hidden()) {
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
await frame();
}
})();
onCleanup(() => {
stopped = true;
});
});
// resize
createEffect(() => {
let resizeObserver: ResizeObserver;
onMount(() => {
setCtx(canvas.getContext('2d'));
new ResizeObserver(() => {
resetSize();
}).observe(root);
});
onCleanup(() => {
resizeObserver?.unobserve(root);
});
});
// update hidden
createEffect(() => {
const hide = () => {
setHidden(true);
console.log('hide');
};
const show = () => {
setHidden(false);
console.log('show');
};
window.addEventListener('focus', show);
window.addEventListener('blur', hide);
onCleanup(() => {
window.removeEventListener('focus', show);
window.removeEventListener('blur', hide);
});
});
return (
<div
ref={root!}
{...rootProps}
class={'overflow-hidden h-full w-full ' + rootProps.class}
>
<canvas ref={canvas!} />
{rootProps.children}
</div>
);
};

View File

@ -0,0 +1,22 @@
import { Component, JSX } from 'solid-js';
type Props = {
value?: number;
} & JSX.HTMLAttributes<HTMLInputElement>;
export const ColorSlider: Component<Props> = (props) => {
return (
<input
type="range"
{...props}
max={1}
min={0}
step={0.01}
value={props.value}
class={
'range range-primary w-full bg-gradient-to-r ' +
props.class
}
/>
);
};

View File

@ -0,0 +1,100 @@
import { Component, createSignal } from 'solid-js';
const ColorItem: Component<{
color: string;
position: [number, number];
size?: [number, number];
onClick?: (color: string) => void;
}> = (props) => {
return (
<div
style={{
background: props.color,
'grid-row-start': props.position[0],
'grid-column-start': props.position[1],
'grid-row-end': props.position[0] + (props.size ? props.size[0] : 1),
'grid-column-end': props.position[1] + (props.size ? props.size[1] : 1),
cursor: props.onClick ? 'pointer' : 'default',
}}
onClick={() => {
props.onClick?.(props.color);
}}
title={props.color}
/>
);
};
export const TestColorsBg: Component = () => {
const [singleColor, setSingleColor] = createSignal<string | null>(null);
return (
<>
<section
class="grid grid-cols-[8] grid-rows-[8] h-full w-full"
classList={{
hidden: singleColor() !== null,
}}
>
<ColorItem color="#ff0000" position={[1, 1]} onClick={setSingleColor} />
<ColorItem color="#ffff00" position={[1, 2]} onClick={setSingleColor} />
<ColorItem color="#00ff00" position={[1, 3]} onClick={setSingleColor} />
<ColorItem color="#00ffff" position={[1, 4]} onClick={setSingleColor} />
<ColorItem color="#0000ff" position={[1, 5]} onClick={setSingleColor} />
<ColorItem color="#ff00ff" position={[1, 6]} onClick={setSingleColor} />
<ColorItem color="#ffffff" position={[1, 7]} onClick={setSingleColor} />
<ColorItem color="#000000" position={[1, 8]} onClick={setSingleColor} />
<ColorItem color="#ffff00" position={[2, 1]} onClick={setSingleColor} />
<ColorItem color="#00ff00" position={[3, 1]} onClick={setSingleColor} />
<ColorItem color="#00ffff" position={[4, 1]} onClick={setSingleColor} />
<ColorItem color="#0000ff" position={[5, 1]} onClick={setSingleColor} />
<ColorItem color="#ff00ff" position={[6, 1]} onClick={setSingleColor} />
<ColorItem color="#ffffff" position={[7, 1]} onClick={setSingleColor} />
<ColorItem color="#000000" position={[8, 1]} onClick={setSingleColor} />
<ColorItem color="#ffffff" position={[2, 8]} onClick={setSingleColor} />
<ColorItem color="#ff00ff" position={[3, 8]} onClick={setSingleColor} />
<ColorItem color="#0000ff" position={[4, 8]} onClick={setSingleColor} />
<ColorItem color="#00ffff" position={[5, 8]} onClick={setSingleColor} />
<ColorItem color="#00ff00" position={[6, 8]} onClick={setSingleColor} />
<ColorItem color="#ffff00" position={[7, 8]} onClick={setSingleColor} />
<ColorItem color="#ff0000" position={[8, 8]} onClick={setSingleColor} />
<ColorItem color="#ffffff" position={[8, 2]} onClick={setSingleColor} />
<ColorItem color="#ff00ff" position={[8, 3]} onClick={setSingleColor} />
<ColorItem color="#0000ff" position={[8, 4]} onClick={setSingleColor} />
<ColorItem color="#00ffff" position={[8, 5]} onClick={setSingleColor} />
<ColorItem color="#00ff00" position={[8, 6]} onClick={setSingleColor} />
<ColorItem color="#ffff00" position={[8, 7]} onClick={setSingleColor} />
</section>
<section
class="grid grid-cols-[8] grid-rows-[8] h-full w-full"
classList={{
hidden: singleColor() === null,
}}
>
<ColorItem
color={singleColor()!}
position={[1, 1]}
size={[1, 7]}
onClick={() => setSingleColor(null)}
/>
<ColorItem
color={singleColor()!}
position={[8, 2]}
size={[1, 7]}
onClick={() => setSingleColor(null)}
/>
<ColorItem
color={singleColor()!}
position={[2, 1]}
size={[7, 1]}
onClick={() => setSingleColor(null)}
/>
<ColorItem
color={singleColor()!}
position={[1, 8]}
size={[7, 1]}
onClick={() => setSingleColor(null)}
/>
</section>
</>
);
};

View File

@ -0,0 +1,432 @@
import { listen } from '@tauri-apps/api/event';
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';
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 (
<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) => {
const { strips, mappers, color_calibration } =
event.payload as LedStripConfigContainer;
console.log(event.payload);
setLedStripStore({
strips,
mappers,
colorCalibration: color_calibration,
});
});
onCleanup(async () => {
(await unlisten)();
});
});
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 = () => {
invoke('set_color_calibration', {
calibration: new ColorCalibration(),
}).catch((error) => console.log(error));
};
return (
<>
{/* 普通模式 */}
{!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="aspect-square rounded-lg overflow-hidden border border-base-300"
style={{
'background-image': `url(${transparentBg})`,
}}
>
<TestColorsBg />
</div>
<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}
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="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}
>
<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>
<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>
)}
</>
);
};

View File

@ -3,5 +3,13 @@ import { render } from "solid-js/web";
import "./styles.css";
import App from "./App";
import { Router } from '@solidjs/router';
render(() => <App />, document.getElementById("root") as HTMLElement);
render(
() => (
<Router>
<App />
</Router>
),
document.getElementById('root') as HTMLElement,
);

View File

@ -0,0 +1,9 @@
export type BoardInfo = {
fullname: string;
host: string;
address: string;
port: number;
ttl: number;
connect_status: 'Connected' | 'Disconnected' | { Connecting: number };
checked_at: Date;
};

View File

@ -0,0 +1,16 @@
export type DisplayState = {
brightness: number;
max_brightness: number;
min_brightness: number;
contrast: number;
max_contrast: number;
min_contrast: number;
mode: number;
max_mode: number;
min_mode: number;
last_modified_at: Date;
};
export type RawDisplayState = DisplayState & {
last_modified_at: { secs_since_epoch: number };
};

View File

@ -6,9 +6,16 @@ export type LedStripPixelMapper = {
pos: number;
};
export class ColorCalibration {
r: number = 1;
g: number = 1;
b: number = 1;
}
export type LedStripConfigContainer = {
strips: LedStripConfig[];
mappers: LedStripPixelMapper[];
color_calibration: ColorCalibration;
};
export class LedStripConfig {

View File

@ -1,12 +1,26 @@
import { createStore } from 'solid-js/store';
import { LedStripConfig, LedStripPixelMapper } from '../models/led-strip-config';
import {
ColorCalibration,
LedStripConfig,
LedStripPixelMapper,
} from '../models/led-strip-config';
export const [ledStripStore, setLedStripStore] = createStore({
strips: new Array<LedStripConfig>(),
mappers: new Array<LedStripPixelMapper>(),
colorCalibration: new ColorCalibration(),
colors: new Uint8ClampedArray(),
sortedColors: new Uint8ClampedArray(),
get totalLedCount() {
return Math.max(0, ...ledStripStore.mappers.map((m) => Math.max(m.start, m.end)));
return Math.max(
0,
...ledStripStore.mappers.map((m) => {
if (m.start === m.end) {
return 0;
} else {
return Math.max(m.start, m.end);
}
}),
);
},
});

View File

@ -1,3 +1,2 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@config "../tailwind.config.js";

View File

@ -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",
},
};

View File

@ -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,
},
}));
};
});