Compare commits
18 Commits
AVFoundati
...
main
Author | SHA1 | Date | |
---|---|---|---|
d1fc5713a1 | |||
d1a614fbbb | |||
2a49b081cb | |||
7e2dafa3d2 | |||
90cace679b | |||
99cbaf3b9f | |||
5de105960b | |||
3a44b96621 | |||
5da81e5f93 | |||
a10fae75d2 | |||
5f12b8312a | |||
1944c88b55 | |||
c8db28168c | |||
93ad9ae46c | |||
6c30a824b0 | |||
515b3a4ccb | |||
ddf61c861d | |||
b1fd751090 |
158
.github/README.md
vendored
Normal file
@ -0,0 +1,158 @@
|
||||
# GitHub Actions Workflows
|
||||
|
||||
This directory contains GitHub Actions workflows for automated CI/CD processes.
|
||||
|
||||
## Workflows Overview
|
||||
|
||||
### 🔨 `build.yml` - Build Desktop App
|
||||
**Triggers:** Push to main/develop, Pull Requests, Releases
|
||||
|
||||
**Purpose:** Builds the desktop application for all supported platforms (macOS, Windows, Linux)
|
||||
|
||||
**Features:**
|
||||
- Cross-platform builds (macOS Universal, Windows x64, Linux x64)
|
||||
- Automatic artifact uploads
|
||||
- Release asset publishing
|
||||
- Caching for faster builds
|
||||
|
||||
**Artifacts:**
|
||||
- **macOS**: DMG installer and .app bundle
|
||||
- **Windows**: MSI and NSIS installers
|
||||
- **Linux**: DEB package and AppImage
|
||||
|
||||
### 🧪 `ci.yml` - Continuous Integration
|
||||
**Triggers:** Push to main/develop, Pull Requests
|
||||
|
||||
**Purpose:** Code quality checks and testing
|
||||
|
||||
**Features:**
|
||||
- Frontend build verification
|
||||
- Rust formatting and linting (rustfmt, clippy)
|
||||
- Rust unit tests
|
||||
- Security audits for both frontend and backend dependencies
|
||||
|
||||
### 🚀 `release.yml` - Manual Release
|
||||
**Triggers:** Manual workflow dispatch
|
||||
|
||||
**Purpose:** Create tagged releases with built applications
|
||||
|
||||
**Features:**
|
||||
- Manual version input
|
||||
- Pre-release option
|
||||
- Automatic release notes generation
|
||||
- Cross-platform builds and uploads
|
||||
- Comprehensive installation instructions
|
||||
|
||||
**Usage:**
|
||||
1. Go to Actions tab in GitHub
|
||||
2. Select "Release" workflow
|
||||
3. Click "Run workflow"
|
||||
4. Enter version (e.g., v1.0.0)
|
||||
5. Choose if it's a pre-release
|
||||
6. Click "Run workflow"
|
||||
|
||||
### 🔄 `dependencies.yml` - Dependency Management
|
||||
**Triggers:** Weekly schedule (Mondays 9 AM UTC), Manual dispatch
|
||||
|
||||
**Purpose:** Automated dependency updates and security monitoring
|
||||
|
||||
**Features:**
|
||||
- Weekly dependency updates
|
||||
- Automatic PR creation for updates
|
||||
- Security vulnerability detection
|
||||
- Automatic issue creation for security alerts
|
||||
|
||||
## Setup Requirements
|
||||
|
||||
### Repository Secrets
|
||||
No additional secrets are required beyond the default `GITHUB_TOKEN`.
|
||||
|
||||
### Branch Protection (Recommended)
|
||||
Configure branch protection rules for `main` branch:
|
||||
- Require status checks to pass before merging
|
||||
- Require branches to be up to date before merging
|
||||
- Include status checks: `lint-and-test`, `security-audit`
|
||||
|
||||
### Release Process
|
||||
|
||||
#### Automated (Recommended)
|
||||
1. Merge changes to `main` branch
|
||||
2. Use the manual release workflow to create a new release
|
||||
3. The workflow will automatically build and upload all platform binaries
|
||||
|
||||
#### Manual
|
||||
1. Create a new tag: `git tag v1.0.0`
|
||||
2. Push the tag: `git push origin v1.0.0`
|
||||
3. Create a release on GitHub
|
||||
4. The build workflow will automatically attach binaries
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
### macOS
|
||||
- Builds universal binaries (Intel + Apple Silicon)
|
||||
- Requires macOS 13.0 or later
|
||||
- DMG installer includes code signing (if certificates are configured)
|
||||
|
||||
### Windows
|
||||
- Builds for x64 architecture
|
||||
- Provides both MSI and NSIS installers
|
||||
- Compatible with Windows 10 and later
|
||||
|
||||
### Linux
|
||||
- Builds for x64 architecture
|
||||
- Provides DEB package for Debian/Ubuntu
|
||||
- Provides AppImage for universal Linux compatibility
|
||||
- Requires WebKit2GTK and other system dependencies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failures
|
||||
1. Check the specific platform logs in the Actions tab
|
||||
2. Ensure all dependencies are properly declared
|
||||
3. Verify Tauri configuration is correct
|
||||
|
||||
### Security Audit Failures
|
||||
1. Review the security report in the workflow logs
|
||||
2. Update vulnerable dependencies
|
||||
3. Consider using `pnpm audit --fix` for frontend issues
|
||||
4. Use `cargo update` for Rust dependency updates
|
||||
|
||||
### Cache Issues
|
||||
If builds are failing due to cache corruption:
|
||||
1. Go to Actions tab
|
||||
2. Click on "Caches" in the sidebar
|
||||
3. Delete relevant caches
|
||||
4. Re-run the workflow
|
||||
|
||||
## Customization
|
||||
|
||||
### Adding New Platforms
|
||||
To add support for additional platforms, modify the `matrix` section in `build.yml`:
|
||||
|
||||
```yaml
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
target: 'aarch64-apple-darwin'
|
||||
```
|
||||
|
||||
### Modifying Build Steps
|
||||
Each workflow can be customized by:
|
||||
1. Adding new steps
|
||||
2. Modifying existing commands
|
||||
3. Adding environment variables
|
||||
4. Configuring different Node.js/Rust versions
|
||||
|
||||
### Adding Code Quality Tools (Optional)
|
||||
If you want to add code quality tools in the future:
|
||||
1. **ESLint**: Add ESLint configuration and dependencies for JavaScript/TypeScript linting
|
||||
2. **Prettier**: Add Prettier for consistent code formatting
|
||||
3. **TypeScript strict checking**: Enable stricter TypeScript rules and type checking
|
||||
|
||||
### Changing Schedule
|
||||
Modify the `cron` expression in `dependencies.yml` to change the update frequency:
|
||||
```yaml
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Every Monday at 9 AM UTC
|
||||
```
|
124
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,124 @@
|
||||
name: Build Desktop App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target universal-apple-darwin'
|
||||
target: 'universal-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
target: 'x86_64-unknown-linux-gnu'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
target: 'x86_64-pc-windows-msvc'
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Upload artifacts (macOS)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-app
|
||||
path: |
|
||||
src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg
|
||||
src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app
|
||||
|
||||
- name: Upload artifacts (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-app
|
||||
path: |
|
||||
src-tauri/target/release/bundle/deb/*.deb
|
||||
src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
|
||||
- name: Upload artifacts (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-app
|
||||
path: |
|
||||
src-tauri/target/release/bundle/msi/*.msi
|
||||
src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
release:
|
||||
if: github.event_name == 'release'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -la
|
||||
|
||||
- name: Upload release assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
macos-app/**/*
|
||||
linux-app/**/*
|
||||
windows-app/**/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
99
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,99 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --all --check
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Lint Rust code
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --all-features
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Check Tauri build
|
||||
run: cargo check --all-targets --all-features
|
||||
working-directory: src-tauri
|
||||
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run npm audit
|
||||
run: pnpm audit --audit-level moderate
|
126
.github/workflows/dependencies.yml
vendored
Normal file
@ -0,0 +1,126 @@
|
||||
name: Update Dependencies
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Monday at 9:00 AM UTC
|
||||
- cron: '0 9 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-dependencies:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install cargo-edit
|
||||
run: cargo install cargo-edit
|
||||
|
||||
- name: Update frontend dependencies
|
||||
run: |
|
||||
pnpm update --latest
|
||||
pnpm install
|
||||
|
||||
- name: Update Rust dependencies
|
||||
run: |
|
||||
cargo update
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Check if build still works
|
||||
run: |
|
||||
pnpm build
|
||||
cargo check --all-targets --all-features
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: update dependencies'
|
||||
title: 'chore: update dependencies'
|
||||
body: |
|
||||
## Automated Dependency Update
|
||||
|
||||
This PR updates all dependencies to their latest versions.
|
||||
|
||||
### Changes
|
||||
- Updated frontend dependencies via `pnpm update --latest`
|
||||
- Updated Rust dependencies via `cargo update`
|
||||
|
||||
### Testing
|
||||
- ✅ Frontend build passes
|
||||
- ✅ Rust compilation check passes
|
||||
|
||||
Please review the changes and run full tests before merging.
|
||||
branch: chore/update-dependencies
|
||||
delete-branch: true
|
||||
|
||||
security-updates:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Check for security vulnerabilities
|
||||
run: |
|
||||
echo "## Frontend Security Audit" >> security-report.md
|
||||
pnpm audit --audit-level moderate >> security-report.md || true
|
||||
|
||||
echo "## Rust Security Audit" >> security-report.md
|
||||
cd src-tauri
|
||||
cargo audit >> ../security-report.md || true
|
||||
|
||||
- name: Create security issue if vulnerabilities found
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const report = fs.readFileSync('security-report.md', 'utf8');
|
||||
|
||||
if (report.includes('vulnerabilities') || report.includes('RUSTSEC')) {
|
||||
github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: '🔒 Security vulnerabilities detected',
|
||||
body: `## Security Audit Report\n\n\`\`\`\n${report}\n\`\`\`\n\nPlease review and update the affected dependencies.`,
|
||||
labels: ['security', 'dependencies']
|
||||
});
|
||||
}
|
140
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,140 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g., v1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Mark as pre-release'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create_release.outputs.id }}
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
release_name: Release ${{ github.event.inputs.version }}
|
||||
draft: false
|
||||
prerelease: ${{ github.event.inputs.prerelease }}
|
||||
body: |
|
||||
## Changes in this Release
|
||||
|
||||
- Auto-generated release for version ${{ github.event.inputs.version }}
|
||||
|
||||
## Downloads
|
||||
|
||||
Choose the appropriate installer for your operating system:
|
||||
|
||||
### macOS
|
||||
- **DMG**: Universal binary for Intel and Apple Silicon Macs
|
||||
|
||||
### Windows
|
||||
- **MSI**: Windows Installer package
|
||||
- **EXE**: NSIS installer
|
||||
|
||||
### Linux
|
||||
- **DEB**: Debian/Ubuntu package
|
||||
- **AppImage**: Portable application
|
||||
|
||||
## Installation Notes
|
||||
|
||||
### macOS
|
||||
1. Download the DMG file
|
||||
2. Open the DMG and drag the app to Applications folder
|
||||
3. On first launch, you may need to right-click and select "Open" due to Gatekeeper
|
||||
|
||||
### Windows
|
||||
1. Download the MSI or EXE installer
|
||||
2. Run the installer as administrator
|
||||
3. Follow the installation wizard
|
||||
|
||||
### Linux
|
||||
1. **DEB**: `sudo dpkg -i ambient-light-desktop_*.deb`
|
||||
2. **AppImage**: Make executable and run directly
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **macOS**: 13.0 or later
|
||||
- **Windows**: Windows 10 or later
|
||||
- **Linux**: Ubuntu 22.04 or equivalent
|
||||
|
||||
build-and-upload:
|
||||
needs: create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target universal-apple-darwin'
|
||||
target: 'universal-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
target: 'x86_64-unknown-linux-gnu'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
target: 'x86_64-pc-windows-msvc'
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build and release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.args }}
|
36
.gitignore
vendored
@ -1,3 +1,39 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode/settings.json
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# ESLint cache
|
||||
.eslintcache
|
||||
|
||||
# Prettier cache
|
||||
.prettiercache
|
||||
|
||||
# Build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/Cargo.lock
|
||||
|
||||
# OS generated files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
node_modules/*
|
||||
src-tauri
|
@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "all",
|
||||
singleQuote: true,
|
||||
printWidth: 90,
|
||||
tabWidth: 2,
|
||||
endOfLine: "auto",
|
||||
};
|
3
.vscode/tasks.json
vendored
@ -12,9 +12,6 @@
|
||||
"tauri",
|
||||
"dev"
|
||||
],
|
||||
"problemMatcher": [
|
||||
"$eslint-stylish"
|
||||
],
|
||||
"options": {
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
|
193
README.md
@ -1,7 +1,192 @@
|
||||
# Tauri + Solid + Typescript
|
||||
# Display Ambient Light Desktop App
|
||||
|
||||
This template should help get you started developing with Tauri, Solid and Typescript in Vite.
|
||||
[](https://github.com/USERNAME/REPOSITORY/actions/workflows/build.yml)
|
||||
[](https://github.com/USERNAME/REPOSITORY/actions/workflows/ci.yml)
|
||||
[](https://github.com/USERNAME/REPOSITORY/actions/workflows/release.yml)
|
||||
|
||||
## Recommended IDE Setup
|
||||
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.
|
||||
|
||||
- [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)
|
||||
## ✨ Features
|
||||
|
||||
- 🖥️ **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
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
99
docs/device-auto-refresh-implementation.md
Normal file
@ -0,0 +1,99 @@
|
||||
# LED Strip Test Device Auto-Refresh Implementation
|
||||
|
||||
## Overview
|
||||
Implemented automatic refresh functionality for the device dropdown in the LED strip test interface. The device list now updates in real-time when devices are discovered, connected, or disconnected.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Frontend Changes (`src/components/led-strip-test/led-strip-test.tsx`)
|
||||
|
||||
#### Added Event Listener Import
|
||||
```typescript
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
```
|
||||
|
||||
#### Enhanced Device Loading Logic
|
||||
- **Initial Load**: Still loads devices on component mount using `get_boards()`
|
||||
- **Real-time Updates**: Added listener for `boards_changed` events from backend
|
||||
- **Smart Selection**: Automatically handles device selection when devices are added/removed:
|
||||
- If current device disconnects, automatically selects first available device
|
||||
- If no device was selected and devices become available, selects the first one
|
||||
- Properly cleans up event listeners on component unmount
|
||||
|
||||
#### Improved UI Display
|
||||
- **Device Count**: Shows number of devices found in label
|
||||
- **Connection Status**: Each device option shows:
|
||||
- Status icon (🟢 Connected, 🟡 Connecting, 🔴 Disconnected)
|
||||
- Device name and address
|
||||
- Connection status text
|
||||
- **Empty State**: Shows "Searching..." when no devices found
|
||||
|
||||
#### Type Safety Improvements
|
||||
- Updated `BoardInfo` interface to match backend types
|
||||
- Proper handling of `connect_status` union type
|
||||
- Type-safe status checking functions
|
||||
|
||||
### 2. Backend Integration
|
||||
The implementation leverages existing backend infrastructure:
|
||||
- **UdpRpc Manager**: Continuously searches for devices via mDNS
|
||||
- **Device Monitoring**: Checks device connectivity every second
|
||||
- **Event Broadcasting**: Sends `boards_changed` events to frontend
|
||||
- **Status Tracking**: Maintains real-time connection status for each device
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Event Flow
|
||||
1. Backend `UdpRpc` discovers devices via mDNS service discovery
|
||||
2. Backend monitors device connectivity with periodic health checks
|
||||
3. Backend broadcasts `boards_changed` events when device list changes
|
||||
4. Frontend listens for events and updates UI automatically
|
||||
5. Frontend handles device selection logic intelligently
|
||||
|
||||
### Connection Status Types
|
||||
- `Connected`: Device is responding to ping requests
|
||||
- `Connecting`: Device is in retry state (with retry count)
|
||||
- `Disconnected`: Device is not responding
|
||||
|
||||
### Error Handling
|
||||
- Graceful fallback if initial device load fails
|
||||
- Proper cleanup of event listeners
|
||||
- Maintains UI state consistency during device changes
|
||||
|
||||
## Benefits
|
||||
1. **Real-time Updates**: No need to manually refresh device list
|
||||
2. **Better UX**: Visual indicators for device status
|
||||
3. **Automatic Recovery**: Handles device disconnections gracefully
|
||||
4. **Type Safety**: Proper TypeScript types prevent runtime errors
|
||||
5. **Performance**: Efficient event-driven updates instead of polling
|
||||
|
||||
## Implementation Status
|
||||
✅ **Completed**: LED Strip Test device dropdown auto-refresh
|
||||
✅ **Already Implemented**: Board Index page auto-refresh (was already working)
|
||||
✅ **Type Safety**: Fixed TypeScript type definitions for BoardInfo
|
||||
✅ **UI Improvements**: Added status indicators and device count display
|
||||
|
||||
## Testing
|
||||
To test the functionality:
|
||||
1. Start the application with `npm run tauri dev`
|
||||
2. Navigate to LED Strip Test page
|
||||
3. Observe device list updates as devices come online/offline
|
||||
4. Verify status indicators show correct connection states:
|
||||
- 🟢 Connected devices
|
||||
- 🟡 Connecting devices (with retry count)
|
||||
- 🔴 Disconnected devices
|
||||
5. Test device selection behavior when devices disconnect
|
||||
6. Check that device count is displayed in the label
|
||||
|
||||
## Code Quality
|
||||
- ✅ No TypeScript errors
|
||||
- ✅ Proper event listener cleanup
|
||||
- ✅ Type-safe status checking
|
||||
- ✅ Consistent with existing codebase patterns
|
||||
- ✅ Follows SolidJS best practices
|
||||
|
||||
## Future Enhancements
|
||||
- Add device refresh button for manual refresh
|
||||
- Show device discovery progress indicator
|
||||
- Add device connection retry controls
|
||||
- Display device ping latency information
|
||||
- Add device connection history/logs
|
105
docs/device-auto-refresh-testing.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Device Auto-Refresh Testing Guide
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Initial Load Test
|
||||
**Expected Behavior**: Device list loads automatically when component mounts
|
||||
|
||||
**Steps**:
|
||||
1. Start the application: `npm run tauri dev`
|
||||
2. Navigate to LED Strip Test page
|
||||
3. Observe the device dropdown
|
||||
|
||||
**Expected Results**:
|
||||
- Device dropdown shows "Searching..." if no devices found
|
||||
- Device dropdown shows device count if devices are found
|
||||
- First available device is automatically selected
|
||||
- Status icons appear next to device names
|
||||
|
||||
### 2. Device Discovery Test
|
||||
**Expected Behavior**: New devices appear automatically when discovered
|
||||
|
||||
**Steps**:
|
||||
1. Start with no devices connected
|
||||
2. Connect a device to the network
|
||||
3. Wait for device discovery (should be automatic)
|
||||
|
||||
**Expected Results**:
|
||||
- Device count updates automatically
|
||||
- New device appears in dropdown
|
||||
- If no device was selected, new device gets selected automatically
|
||||
- Status icon shows connection state
|
||||
|
||||
### 3. Device Disconnection Test
|
||||
**Expected Behavior**: Disconnected devices are handled gracefully
|
||||
|
||||
**Steps**:
|
||||
1. Start with connected devices
|
||||
2. Select a device in the dropdown
|
||||
3. Disconnect the selected device from network
|
||||
4. Wait for connection timeout
|
||||
|
||||
**Expected Results**:
|
||||
- Device status changes to disconnected (🔴)
|
||||
- If device becomes unavailable, another device is selected automatically
|
||||
- Device count updates
|
||||
- UI remains responsive
|
||||
|
||||
### 4. Connection Status Test
|
||||
**Expected Behavior**: Status indicators reflect actual device states
|
||||
|
||||
**Steps**:
|
||||
1. Observe devices in different connection states
|
||||
2. Check status icons and text
|
||||
|
||||
**Expected Results**:
|
||||
- 🟢 "Connected" for responsive devices
|
||||
- 🟡 "Connecting" for devices in retry state
|
||||
- 🔴 "Disconnected" for unresponsive devices
|
||||
- Status text matches icon state
|
||||
|
||||
### 5. UI Responsiveness Test
|
||||
**Expected Behavior**: Interface remains responsive during device changes
|
||||
|
||||
**Steps**:
|
||||
1. Rapidly connect/disconnect devices
|
||||
2. Interact with other UI elements during device changes
|
||||
3. Switch between pages and return
|
||||
|
||||
**Expected Results**:
|
||||
- No UI freezing or lag
|
||||
- Event listeners are properly cleaned up
|
||||
- No memory leaks
|
||||
- Smooth transitions
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Device dropdown shows correct device count
|
||||
- [ ] Status icons display correctly (🟢🟡🔴)
|
||||
- [ ] Automatic device selection works
|
||||
- [ ] Event listeners are cleaned up on component unmount
|
||||
- [ ] No TypeScript errors in console
|
||||
- [ ] No runtime errors in console
|
||||
- [ ] Performance remains good with multiple devices
|
||||
- [ ] UI updates smoothly without flickering
|
||||
|
||||
## Common Issues to Watch For
|
||||
|
||||
1. **Memory Leaks**: Event listeners not cleaned up
|
||||
2. **Type Errors**: Incorrect BoardInfo type handling
|
||||
3. **Selection Logic**: Device selection not updating correctly
|
||||
4. **Performance**: UI lag during rapid device changes
|
||||
5. **State Consistency**: UI state not matching actual device state
|
||||
|
||||
## Debug Information
|
||||
|
||||
Check browser console for:
|
||||
- `boards_changed` events
|
||||
- Device list updates
|
||||
- Selection changes
|
||||
- Any error messages
|
||||
|
||||
Check Tauri logs for:
|
||||
- Device discovery messages
|
||||
- Connection status changes
|
||||
- mDNS service events
|
378
docs/hardware-protocol.md
Normal file
@ -0,0 +1,378 @@
|
||||
# LED Hardware Communication Protocol
|
||||
|
||||
## Overview
|
||||
|
||||
UDP-based bidirectional protocol for communication between desktop application and ambient light hardware boards. The protocol supports LED color data transmission, device health monitoring, and remote control capabilities.
|
||||
|
||||
## Connection
|
||||
|
||||
- **Protocol**: UDP
|
||||
- **Port**: 23042
|
||||
- **Discovery**: mDNS (`_ambient_light._udp.local.`)
|
||||
- **Example Board**: `192.168.31.206:23042`
|
||||
|
||||
## mDNS Service Discovery
|
||||
|
||||
### Service Registration (Hardware Side)
|
||||
|
||||
Hardware boards must register the following mDNS service:
|
||||
|
||||
- **Service Type**: `_ambient_light._udp.local.`
|
||||
- **Port**: 23042
|
||||
- **TXT Records**: Optional, can include device information
|
||||
|
||||
### Service Discovery (Desktop Side)
|
||||
|
||||
Desktop application continuously browses for `_ambient_light._udp.local.` services and automatically connects to discovered devices.
|
||||
|
||||
## Protocol Messages
|
||||
|
||||
The protocol uses different message headers to distinguish message types:
|
||||
|
||||
| Header | Direction | Purpose | Format |
|
||||
|--------|-----------|---------|---------|
|
||||
| 0x01 | Desktop → Hardware | Ping (Health Check) | `[0x01]` |
|
||||
| 0x01 | Hardware → Desktop | Pong (Health Response) | `[0x01]` |
|
||||
| 0x02 | Desktop → Hardware | LED Color Data | `[0x02][Offset_H][Offset_L][Color_Data...]` |
|
||||
| 0x03 | Hardware → Desktop | Display Brightness Control | `[0x03][Display_Index][Brightness]` |
|
||||
| 0x04 | Hardware → Desktop | Volume Control | `[0x04][Volume_Percent]` |
|
||||
|
||||
## Health Check Protocol (Ping/Pong)
|
||||
|
||||
### Desktop → Hardware (Ping)
|
||||
|
||||
```text
|
||||
Byte 0: Header (0x01)
|
||||
```
|
||||
|
||||
### Hardware → Desktop (Pong)
|
||||
|
||||
```text
|
||||
Byte 0: Header (0x01)
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Desktop sends ping every 1 second to each connected device
|
||||
- Hardware must respond with pong within 1 second
|
||||
- Timeout or incorrect response triggers reconnection logic
|
||||
- After 10 failed attempts, device is marked as disconnected
|
||||
|
||||
## LED Color Data Protocol
|
||||
|
||||
### Packet Format
|
||||
|
||||
```text
|
||||
Byte 0: Header (0x02)
|
||||
Byte 1: Offset High (upper 8 bits of LED start position)
|
||||
Byte 2: Offset Low (lower 8 bits of LED start position)
|
||||
Byte 3+: LED Color Data (variable length)
|
||||
```
|
||||
|
||||
## LED Color Data
|
||||
|
||||
### RGB LEDs (3 bytes per LED)
|
||||
|
||||
```text
|
||||
[R][G][B][R][G][B][R][G][B]...
|
||||
```
|
||||
|
||||
### RGBW LEDs (4 bytes per LED)
|
||||
|
||||
```text
|
||||
[R][G][B][W][R][G][B][W][R][G][B][W]...
|
||||
```
|
||||
|
||||
All values are 0-255.
|
||||
|
||||
## Color Calibration
|
||||
|
||||
Colors are calibrated before transmission:
|
||||
|
||||
**RGB:**
|
||||
|
||||
```rust
|
||||
calibrated_r = (original_r * calibration_r) / 255
|
||||
calibrated_g = (original_g * calibration_g) / 255
|
||||
calibrated_b = (original_b * calibration_b) / 255
|
||||
```
|
||||
|
||||
**RGBW:**
|
||||
|
||||
```rust
|
||||
calibrated_r = (original_r * calibration_r) / 255
|
||||
calibrated_g = (original_g * calibration_g) / 255
|
||||
calibrated_b = (original_b * calibration_b) / 255
|
||||
calibrated_w = calibration_w // Direct value
|
||||
```
|
||||
|
||||
## Hardware Control Protocol (Hardware → Desktop)
|
||||
|
||||
### Display Brightness Control
|
||||
|
||||
Hardware can send display brightness adjustment commands to the desktop:
|
||||
|
||||
```text
|
||||
Byte 0: Header (0x03)
|
||||
Byte 1: Display Index (0-based display number)
|
||||
Byte 2: Brightness (0-255, where 255 = 100% brightness)
|
||||
```
|
||||
|
||||
**Example:** Set display 0 to 50% brightness
|
||||
|
||||
```text
|
||||
03 00 80
|
||||
│ │ └─ Brightness (128 = ~50%)
|
||||
│ └─ Display Index (0)
|
||||
└─ Header (0x03)
|
||||
```
|
||||
|
||||
### Volume Control
|
||||
|
||||
Hardware can send system volume adjustment commands to the desktop:
|
||||
|
||||
```text
|
||||
Byte 0: Header (0x04)
|
||||
Byte 1: Volume Percent (0-100)
|
||||
```
|
||||
|
||||
**Example:** Set system volume to 75%
|
||||
|
||||
```text
|
||||
04 4B
|
||||
│ └─ Volume (75%)
|
||||
└─ Header (0x04)
|
||||
```
|
||||
|
||||
## Connection State Management
|
||||
|
||||
### Connection States
|
||||
|
||||
- **Unknown**: Initial state when device is first discovered
|
||||
- **Connecting**: Device is being tested, includes retry count (1-10)
|
||||
- **Connected**: Device is responding to ping requests normally
|
||||
- **Disconnected**: Device failed to respond after 10 retry attempts
|
||||
|
||||
### State Transitions
|
||||
|
||||
```text
|
||||
Unknown → Connecting(1) → Connected
|
||||
↓ ↓ ↓
|
||||
↓ Connecting(2-10) ↓
|
||||
↓ ↓ ↓
|
||||
└─→ Disconnected ←────────┘
|
||||
```
|
||||
|
||||
### Retry Logic
|
||||
|
||||
1. **Initial Connection**: When device discovered via mDNS
|
||||
2. **Health Check Failure**: If ping timeout or wrong response
|
||||
3. **Retry Attempts**: Up to 10 attempts with 1-second intervals
|
||||
4. **Disconnection**: After 10 failed attempts, mark as disconnected
|
||||
5. **Recovery**: Disconnected devices continue to receive ping attempts
|
||||
|
||||
## Packet Examples
|
||||
|
||||
### RGB Example
|
||||
|
||||
3 RGB LEDs starting at position 0: Red, Green, Blue
|
||||
|
||||
```text
|
||||
02 00 00 FF 00 00 00 FF 00 00 00 FF
|
||||
│ │ │ └─────────────────────────── 9 bytes color data
|
||||
│ │ └─ Offset Low (0)
|
||||
│ └─ Offset High (0)
|
||||
└─ Header (0x02)
|
||||
```
|
||||
|
||||
### RGBW Example
|
||||
|
||||
2 RGBW LEDs starting at position 10: White, Warm White
|
||||
|
||||
```text
|
||||
02 00 0A FF FF FF FF FF C8 96 C8
|
||||
│ │ │ └─────────────────────── 8 bytes color data
|
||||
│ │ └─ Offset Low (10)
|
||||
│ └─ Offset High (0)
|
||||
└─ Header (0x02)
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Byte Order**: Big-endian for multi-byte values (offset field)
|
||||
- **Delivery**: Fire-and-forget UDP (no acknowledgment required)
|
||||
- **Hardware Role**: Simple UDP-to-WS2812 bridge, no data processing
|
||||
- **LED Type Logic**: Handled entirely on desktop side, not hardware
|
||||
- **Mixed Types**: Same display can have both RGB and RGBW strips
|
||||
- **Data Flow**: Desktop → UDP → Hardware → WS2812 (direct forward)
|
||||
|
||||
## Hardware Implementation
|
||||
|
||||
The hardware board handles multiple protocol functions: UDP-to-WS2812 bridge for LED data, health monitoring, and optional control input capabilities.
|
||||
|
||||
### Required Functions
|
||||
|
||||
1. **mDNS Service Registration**: Advertise `_ambient_light._udp.local.` service
|
||||
2. **UDP Server**: Listen on port 23042 for incoming packets
|
||||
3. **Packet Processing**: Handle different message types based on header
|
||||
4. **Health Monitoring**: Respond to ping requests with pong
|
||||
5. **LED Control**: Forward color data to WS2812 strips
|
||||
6. **Optional Control**: Send brightness/volume commands to desktop
|
||||
|
||||
### Packet Processing Logic
|
||||
|
||||
```c
|
||||
void process_packet(uint8_t* data, size_t len) {
|
||||
if (len < 1) return;
|
||||
|
||||
switch (data[0]) {
|
||||
case 0x01: // Ping request
|
||||
handle_ping(data, len);
|
||||
break;
|
||||
|
||||
case 0x02: // LED color data
|
||||
handle_led_data(data, len);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown packet type, ignore
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void handle_ping(uint8_t* data, size_t len) {
|
||||
if (len != 1) return;
|
||||
|
||||
// Respond with pong
|
||||
uint8_t pong = 0x01;
|
||||
udp_send_response(&pong, 1);
|
||||
}
|
||||
|
||||
void handle_led_data(uint8_t* data, size_t len) {
|
||||
if (len < 3) return;
|
||||
|
||||
uint16_t offset = (data[1] << 8) | data[2];
|
||||
uint8_t* color_data = &data[3];
|
||||
size_t color_len = len - 3;
|
||||
|
||||
// Direct forward to WS2812 - no RGB/RGBW distinction needed
|
||||
ws2812_update(offset, color_data, color_len);
|
||||
}
|
||||
```
|
||||
|
||||
### Optional Control Features
|
||||
|
||||
Hardware can optionally send control commands to desktop:
|
||||
|
||||
```c
|
||||
// Send display brightness control
|
||||
void send_brightness_control(uint8_t display_index, uint8_t brightness) {
|
||||
uint8_t packet[3] = {0x03, display_index, brightness};
|
||||
udp_send_to_desktop(packet, 3);
|
||||
}
|
||||
|
||||
// Send volume control
|
||||
void send_volume_control(uint8_t volume_percent) {
|
||||
uint8_t packet[2] = {0x04, volume_percent};
|
||||
udp_send_to_desktop(packet, 2);
|
||||
}
|
||||
```
|
||||
|
||||
### Key Implementation Notes
|
||||
|
||||
- **Ping Response**: Must respond to ping (0x01) within 1 second
|
||||
- **LED Data**: Direct forward to WS2812, no processing required
|
||||
- **Control Commands**: Optional feature for hardware with input capabilities
|
||||
- **mDNS Registration**: Essential for automatic device discovery
|
||||
- **UDP Server**: Must handle concurrent connections from multiple desktops
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Device Discovery Issues
|
||||
|
||||
**Device Not Found**:
|
||||
|
||||
- Verify mDNS service registration on hardware
|
||||
- Check service type: `_ambient_light._udp.local.`
|
||||
- Ensure port 23042 is accessible
|
||||
- Verify network connectivity between desktop and hardware
|
||||
|
||||
**Device Shows as Disconnected**:
|
||||
|
||||
- Check ping/pong response implementation
|
||||
- Verify hardware responds to 0x01 packets within 1 second
|
||||
- Monitor network latency and packet loss
|
||||
- Check UDP server implementation on hardware
|
||||
|
||||
### LED Control Issues
|
||||
|
||||
**No LED Updates**:
|
||||
|
||||
- Verify hardware processes 0x02 packets correctly
|
||||
- Check WS2812 wiring and power supply
|
||||
- Monitor packet reception on hardware side
|
||||
- Verify offset calculations and LED strip configuration
|
||||
|
||||
**Wrong Colors**:
|
||||
|
||||
- Check color calibration settings on desktop
|
||||
- Verify RGB/RGBW data format matches LED strip type
|
||||
- Monitor color data in packets (bytes 3+)
|
||||
- Check WS2812 color order (GRB vs RGB)
|
||||
|
||||
**Flickering or Lag**:
|
||||
|
||||
- Monitor packet rate and network congestion
|
||||
- Check power supply stability for LED strips
|
||||
- Verify WS2812 data signal integrity
|
||||
- Consider reducing update frequency
|
||||
|
||||
### Control Protocol Issues
|
||||
|
||||
**Brightness/Volume Control Not Working**:
|
||||
|
||||
- Verify hardware sends correct packet format (0x03/0x04)
|
||||
- Check desktop receives and processes control packets
|
||||
- Monitor packet transmission from hardware
|
||||
- Verify display index and value ranges
|
||||
|
||||
### Connection State Issues
|
||||
|
||||
**Frequent Disconnections**:
|
||||
|
||||
- Check network stability and latency
|
||||
- Verify ping response timing (< 1 second)
|
||||
- Monitor retry logic and connection state transitions
|
||||
- Check for UDP packet loss
|
||||
|
||||
**Stuck in Connecting State**:
|
||||
|
||||
- Verify ping/pong packet format
|
||||
- Check hardware UDP server implementation
|
||||
- Monitor ping response timing
|
||||
- Verify network firewall settings
|
||||
|
||||
### Network Debugging
|
||||
|
||||
**Packet Monitoring**:
|
||||
|
||||
```bash
|
||||
# Monitor UDP traffic on port 23042
|
||||
tcpdump -i any -X port 23042
|
||||
|
||||
# Check mDNS service discovery
|
||||
dns-sd -B _ambient_light._udp.local.
|
||||
```
|
||||
|
||||
**Hardware Debug Output**:
|
||||
|
||||
- Log received packet headers and lengths
|
||||
- Monitor ping/pong timing
|
||||
- Track LED data processing
|
||||
- Log mDNS service registration status
|
||||
|
||||
## Protocol Version
|
||||
|
||||
- **Current**: 1.0
|
||||
- **Headers**: 0x01 (Ping/Pong), 0x02 (LED Data), 0x03 (Brightness), 0x04 (Volume)
|
||||
- **Future**: Additional headers for new features, backward compatibility maintained
|
29
package.json
@ -11,23 +11,26 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.8.2",
|
||||
"@tauri-apps/api": "^1.3.0",
|
||||
"debug": "^4.3.4",
|
||||
"solid-icons": "^1.0.8",
|
||||
"solid-js": "^1.7.6",
|
||||
"@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.3.1",
|
||||
"@types/debug": "^4.1.8",
|
||||
"@types/node": "^18.16.17",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"@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": "^4.3.9",
|
||||
"vite-plugin-solid": "^2.7.0"
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-solid": "^2.11.7"
|
||||
}
|
||||
}
|
||||
|
2773
pnpm-lock.yaml
generated
@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
4451
src-tauri/Cargo.lock
generated
@ -10,16 +10,20 @@ 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"
|
||||
anyhow = "1.0.69"
|
||||
tokio = {version = "1.26.0", features = ["full"] }
|
||||
tokio-util = "0.7"
|
||||
paris = { version = "1.5", features = ["timestamps", "macros"] }
|
||||
log = "0.4.17"
|
||||
env_logger = "0.10.0"
|
||||
@ -28,8 +32,8 @@ 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"
|
||||
@ -37,7 +41,11 @@ mdns-sd = "0.7.2"
|
||||
futures = "0.3.28"
|
||||
ddc-hi = "0.4.1"
|
||||
coreaudio-rs = "0.11.2"
|
||||
rust_swift_screencapture = { version = "0.1.1", path = "../../../../demo/rust-swift-screencapture" }
|
||||
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
|
||||
|
12
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal 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"]}}
|
2504
src-tauri/gen/schemas/desktop-schema.json
Normal file
2504
src-tauri/gen/schemas/macOS-schema.json
Normal file
3
src-tauri/src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
26
src-tauri/src-tauri/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
default-run = "app"
|
||||
edition = "2021"
|
||||
rust-version = "1.60"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.5.6" }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.8.2" }
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
|
||||
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
|
||||
# DO NOT REMOVE!!
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
3
src-tauri/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
BIN
src-tauri/src-tauri/icons/128x128.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/src-tauri/icons/128x128@2x.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/src-tauri/icons/32x32.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/src-tauri/icons/Square107x107Logo.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/src-tauri/icons/Square142x142Logo.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/src-tauri/icons/Square150x150Logo.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/src-tauri/icons/Square284x284Logo.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/src-tauri/icons/Square30x30Logo.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/src-tauri/icons/Square310x310Logo.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/src-tauri/icons/Square44x44Logo.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/src-tauri/icons/Square71x71Logo.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/src-tauri/icons/Square89x89Logo.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/src-tauri/icons/StoreLogo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/src-tauri/icons/icon.ico
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/src-tauri/icons/icon.png
Normal file
After Width: | Height: | Size: 49 KiB |
8
src-tauri/src-tauri/src/main.rs
Normal file
@ -0,0 +1,8 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
65
src-tauri/src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devPath": "http://localhost:4000",
|
||||
"distDir": "../dist"
|
||||
},
|
||||
"package": {
|
||||
"productName": "Tauri App",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"externalBin": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "com.tauri.dev",
|
||||
"longDescription": "",
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
"title": "Tauri",
|
||||
"width": 800
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@ 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::LedSamplePoints;
|
||||
|
||||
@ -17,6 +16,18 @@ pub enum Border {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum LedType {
|
||||
RGB,
|
||||
RGBW,
|
||||
}
|
||||
|
||||
impl Default for LedType {
|
||||
fn default() -> Self {
|
||||
LedType::RGB
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
pub struct LedStripConfig {
|
||||
pub index: usize,
|
||||
@ -24,6 +35,8 @@ pub struct LedStripConfig {
|
||||
pub display_id: u32,
|
||||
pub start_pos: usize,
|
||||
pub len: usize,
|
||||
#[serde(default)]
|
||||
pub led_type: LedType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
|
||||
@ -31,6 +44,12 @@ pub struct ColorCalibration {
|
||||
r: f32,
|
||||
g: f32,
|
||||
b: f32,
|
||||
#[serde(default = "default_w_value")]
|
||||
w: f32,
|
||||
}
|
||||
|
||||
fn default_w_value() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl ColorCalibration {
|
||||
@ -41,6 +60,15 @@ impl ColorCalibration {
|
||||
(self.b * 255.0) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn to_bytes_rgbw(&self) -> [u8; 4] {
|
||||
[
|
||||
(self.r * 255.0) as u8,
|
||||
(self.g * 255.0) as u8,
|
||||
(self.b * 255.0) as u8,
|
||||
(self.w * 255.0) as u8,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
@ -55,7 +83,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);
|
||||
|
||||
@ -83,7 +111,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);
|
||||
|
||||
@ -123,6 +151,7 @@ impl LedStripConfigGroup {
|
||||
},
|
||||
start_pos: j + i * 4 * 30,
|
||||
len: 30,
|
||||
led_type: LedType::RGB,
|
||||
};
|
||||
configs.push(item);
|
||||
strips.push(item);
|
||||
@ -137,6 +166,7 @@ impl LedStripConfigGroup {
|
||||
r: 1.0,
|
||||
g: 1.0,
|
||||
b: 1.0,
|
||||
w: 1.0,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
|
@ -5,7 +5,7 @@ use tokio::{sync::OnceCell, task::yield_now};
|
||||
|
||||
use crate::ambient_light::{config, LedStripConfigGroup};
|
||||
|
||||
use super::{Border, SamplePointMapper, ColorCalibration};
|
||||
use super::{Border, SamplePointMapper, ColorCalibration, LedType};
|
||||
|
||||
pub struct ConfigManager {
|
||||
config: Arc<RwLock<LedStripConfigGroup>>,
|
||||
@ -94,6 +94,33 @@ impl ConfigManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn patch_led_strip_type(
|
||||
&self,
|
||||
display_id: u32,
|
||||
border: Border,
|
||||
led_type: LedType,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut config = self.config.write().await;
|
||||
|
||||
for strip in config.strips.iter_mut() {
|
||||
if strip.display_id == display_id && strip.border == border {
|
||||
strip.led_type = led_type;
|
||||
}
|
||||
}
|
||||
|
||||
let cloned_config = config.clone();
|
||||
|
||||
drop(config);
|
||||
|
||||
self.update(&cloned_config).await?;
|
||||
|
||||
self.config_update_sender
|
||||
.send(cloned_config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send config update: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_strip_part(
|
||||
&self,
|
||||
display_id: u32,
|
||||
|
@ -18,7 +18,7 @@ use crate::{
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use super::{LedStripConfigGroup, SamplePointMapper};
|
||||
use super::{LedStripConfigGroup, SamplePointMapper, LedStripConfig, ColorCalibration, LedType};
|
||||
|
||||
pub struct LedColorsPublisher {
|
||||
sorted_colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
|
||||
@ -26,6 +26,7 @@ pub struct LedColorsPublisher {
|
||||
colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
|
||||
colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
|
||||
inner_tasks_version: Arc<RwLock<usize>>,
|
||||
test_mode_active: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl LedColorsPublisher {
|
||||
@ -44,6 +45,7 @@ impl LedColorsPublisher {
|
||||
colors_rx: Arc::new(RwLock::new(rx)),
|
||||
colors_tx: Arc::new(RwLock::new(tx)),
|
||||
inner_tasks_version: Arc::new(RwLock::new(0)),
|
||||
test_mode_active: Arc::new(RwLock::new(false)),
|
||||
}
|
||||
})
|
||||
.await
|
||||
@ -56,6 +58,8 @@ impl LedColorsPublisher {
|
||||
bound_scale_factor: f32,
|
||||
mappers: Vec<SamplePointMapper>,
|
||||
display_colors_tx: broadcast::Sender<(u32, Vec<u8>)>,
|
||||
strips: Vec<LedStripConfig>,
|
||||
color_calibration: ColorCalibration,
|
||||
) {
|
||||
let internal_tasks_version = self.inner_tasks_version.clone();
|
||||
let screenshot_manager = ScreenshotManager::global().await;
|
||||
@ -79,7 +83,14 @@ impl LedColorsPublisher {
|
||||
|
||||
let mappers = mappers.clone();
|
||||
|
||||
match Self::send_colors_by_display(colors, mappers).await {
|
||||
// Check if test mode is active before sending normal colors
|
||||
let test_mode_active = {
|
||||
let publisher = LedColorsPublisher::global().await;
|
||||
*publisher.test_mode_active.read().await
|
||||
};
|
||||
|
||||
if !test_mode_active {
|
||||
match Self::send_colors_by_display(colors, mappers, &strips, &color_calibration).await {
|
||||
Ok(_) => {
|
||||
// log::info!("sent colors: #{: >15}", display_id);
|
||||
}
|
||||
@ -87,22 +98,23 @@ impl LedColorsPublisher {
|
||||
warn!("Failed to send colors: #{: >15}\t{}", display_id, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// match display_colors_tx.send((
|
||||
// display_id,
|
||||
// colors_copy
|
||||
// .into_iter()
|
||||
// .map(|color| color.get_rgb())
|
||||
// .flatten()
|
||||
// .collect::<Vec<_>>(),
|
||||
// )) {
|
||||
// Ok(_) => {
|
||||
// // log::info!("sent colors: {:?}", color_len);
|
||||
// }
|
||||
// Err(err) => {
|
||||
// warn!("Failed to send display_colors: {}", err);
|
||||
// }
|
||||
// };
|
||||
match display_colors_tx.send((
|
||||
display_id,
|
||||
colors_copy
|
||||
.into_iter()
|
||||
.map(|color| color.get_rgb())
|
||||
.flatten()
|
||||
.collect::<Vec<_>>(),
|
||||
)) {
|
||||
Ok(_) => {
|
||||
// log::info!("sent colors: {:?}", color_len);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to send display_colors: {}", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the inner task version changed
|
||||
let version = internal_tasks_version.read().await.clone();
|
||||
@ -127,7 +139,7 @@ impl LedColorsPublisher {
|
||||
) {
|
||||
let sorted_colors_tx = self.sorted_colors_tx.clone();
|
||||
let colors_tx = self.colors_tx.clone();
|
||||
log::debug!("start all_colors_worker");
|
||||
|
||||
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..10 {
|
||||
@ -137,7 +149,7 @@ impl LedColorsPublisher {
|
||||
let mut all_colors: Vec<Option<Vec<u8>>> = vec![None; display_ids.len()];
|
||||
let mut start: tokio::time::Instant = tokio::time::Instant::now();
|
||||
|
||||
log::debug!("start all_colors_worker task");
|
||||
|
||||
loop {
|
||||
let color_info = display_colors_rx.recv().await;
|
||||
|
||||
@ -186,7 +198,7 @@ impl LedColorsPublisher {
|
||||
warn!("Failed to send sorted colors: {}", err);
|
||||
}
|
||||
};
|
||||
log::debug!("tick: {}ms", start.elapsed().as_millis());
|
||||
|
||||
start = tokio::time::Instant::now();
|
||||
}
|
||||
}
|
||||
@ -195,7 +207,7 @@ impl LedColorsPublisher {
|
||||
}
|
||||
|
||||
pub async fn start(&self) {
|
||||
log::info!("start colors worker");
|
||||
|
||||
|
||||
let config_manager = ConfigManager::global().await;
|
||||
let mut config_receiver = config_manager.clone_config_update_receiver();
|
||||
@ -203,17 +215,15 @@ impl LedColorsPublisher {
|
||||
|
||||
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();
|
||||
self.handle_config_change(configs).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_config_change(&self, configs: LedStripConfigGroup) {
|
||||
async fn handle_config_change(&self, original_configs: LedStripConfigGroup) {
|
||||
let inner_tasks_version = self.inner_tasks_version.clone();
|
||||
let configs = Self::get_colors_configs(&configs).await;
|
||||
let configs = Self::get_colors_configs(&original_configs).await;
|
||||
|
||||
if let Err(err) = configs {
|
||||
warn!("Failed to get configs: {}", err);
|
||||
@ -233,12 +243,22 @@ impl LedColorsPublisher {
|
||||
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;
|
||||
|
||||
// Get strips for this display
|
||||
let display_strips: Vec<LedStripConfig> = original_configs.strips
|
||||
.iter()
|
||||
.filter(|strip| strip.display_id == display_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
self.start_one_display_colors_fetcher(
|
||||
display_id,
|
||||
sample_points,
|
||||
bound_scale_factor,
|
||||
sample_point_group.mappers,
|
||||
display_colors_tx.clone(),
|
||||
display_strips,
|
||||
original_configs.color_calibration,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@ -268,6 +288,8 @@ impl LedColorsPublisher {
|
||||
pub async fn send_colors_by_display(
|
||||
colors: Vec<LedColor>,
|
||||
mappers: Vec<SamplePointMapper>,
|
||||
strips: &[LedStripConfig],
|
||||
color_calibration: &ColorCalibration,
|
||||
) -> anyhow::Result<()> {
|
||||
// let color_len = colors.len();
|
||||
let display_led_offset = mappers
|
||||
@ -284,7 +306,7 @@ impl LedColorsPublisher {
|
||||
let udp_rpc = udp_rpc.as_ref().unwrap();
|
||||
|
||||
// let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
for group in mappers.clone() {
|
||||
for (group_index, group) in mappers.clone().iter().enumerate() {
|
||||
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(): {}",
|
||||
@ -295,21 +317,116 @@ impl LedColorsPublisher {
|
||||
}
|
||||
|
||||
let group_size = group.start.abs_diff(group.end);
|
||||
let mut buffer = Vec::<u8>::with_capacity(group_size * 3);
|
||||
|
||||
// Find the corresponding LED strip config to get LED type
|
||||
let led_type = if group_index < strips.len() {
|
||||
strips[group_index].led_type
|
||||
} else {
|
||||
LedType::RGB // fallback to RGB
|
||||
};
|
||||
|
||||
let bytes_per_led = match led_type {
|
||||
LedType::RGB => 3,
|
||||
LedType::RGBW => 4,
|
||||
};
|
||||
|
||||
let mut buffer = Vec::<u8>::with_capacity(group_size * bytes_per_led);
|
||||
|
||||
if group.end > group.start {
|
||||
for i in group.pos - display_led_offset..group_size + group.pos - display_led_offset
|
||||
{
|
||||
let bytes = colors[i].as_bytes();
|
||||
buffer.append(&mut bytes.to_vec());
|
||||
// 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 = match led_type {
|
||||
LedType::RGB => {
|
||||
let calibration_bytes = color_calibration.to_bytes();
|
||||
let color_bytes = colors[i].as_bytes();
|
||||
// Apply calibration to RGB values
|
||||
vec![
|
||||
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||
]
|
||||
}
|
||||
LedType::RGBW => {
|
||||
let calibration_bytes = color_calibration.to_bytes_rgbw();
|
||||
let color_bytes = colors[i].as_bytes();
|
||||
// Apply calibration to RGB values and use calibrated W
|
||||
vec![
|
||||
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||
calibration_bytes[3], // W channel
|
||||
]
|
||||
}
|
||||
};
|
||||
buffer.extend_from_slice(&bytes);
|
||||
} else {
|
||||
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
|
||||
// Add black color as fallback
|
||||
match led_type {
|
||||
LedType::RGB => buffer.extend_from_slice(&[0, 0, 0]),
|
||||
LedType::RGBW => buffer.extend_from_slice(&[0, 0, 0, 0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in (group.pos - display_led_offset
|
||||
..group_size + group.pos - display_led_offset)
|
||||
.rev()
|
||||
{
|
||||
let bytes = colors[i].as_bytes();
|
||||
buffer.append(&mut bytes.to_vec());
|
||||
// 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 = match led_type {
|
||||
LedType::RGB => {
|
||||
let calibration_bytes = color_calibration.to_bytes();
|
||||
let color_bytes = colors[i].as_bytes();
|
||||
// Apply calibration to RGB values
|
||||
vec![
|
||||
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||
]
|
||||
}
|
||||
LedType::RGBW => {
|
||||
let calibration_bytes = color_calibration.to_bytes_rgbw();
|
||||
let color_bytes = colors[i].as_bytes();
|
||||
// Apply calibration to RGB values and use calibrated W
|
||||
vec![
|
||||
((color_bytes[0] as f32 * calibration_bytes[0] as f32 / 255.0) as u8),
|
||||
((color_bytes[1] as f32 * calibration_bytes[1] as f32 / 255.0) as u8),
|
||||
((color_bytes[2] as f32 * calibration_bytes[2] as f32 / 255.0) as u8),
|
||||
calibration_bytes[3], // W channel
|
||||
]
|
||||
}
|
||||
};
|
||||
buffer.extend_from_slice(&bytes);
|
||||
} else {
|
||||
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
|
||||
// Add black color as fallback
|
||||
match led_type {
|
||||
LedType::RGB => buffer.extend_from_slice(&[0, 0, 0]),
|
||||
LedType::RGBW => buffer.extend_from_slice(&[0, 0, 0, 0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -350,7 +467,7 @@ impl LedColorsPublisher {
|
||||
let mut screenshots = HashMap::new();
|
||||
|
||||
loop {
|
||||
log::info!("waiting merged screenshot...");
|
||||
|
||||
let screenshot = merged_screenshot_receiver.recv().await;
|
||||
|
||||
if let Err(err) = screenshot {
|
||||
@ -382,7 +499,7 @@ impl LedColorsPublisher {
|
||||
.filter(|(_, c)| c.display_id == display_id);
|
||||
|
||||
let screenshot = screenshots.get(&display_id).unwrap();
|
||||
log::debug!("screenshot updated: {:?}", display_id);
|
||||
|
||||
|
||||
let points: Vec<_> = led_strip_configs
|
||||
.clone()
|
||||
@ -412,7 +529,7 @@ impl LedColorsPublisher {
|
||||
led_start = led_end;
|
||||
}
|
||||
|
||||
log::debug!("got all colors configs: {:?}", colors_configs.len());
|
||||
|
||||
|
||||
return Ok(AllColorConfig {
|
||||
sample_point_groups: colors_configs,
|
||||
@ -425,6 +542,35 @@ impl LedColorsPublisher {
|
||||
pub async fn clone_colors_receiver(&self) -> watch::Receiver<Vec<u8>> {
|
||||
self.colors_rx.read().await.clone()
|
||||
}
|
||||
|
||||
/// Enable test mode - this will pause normal LED data publishing
|
||||
pub async fn enable_test_mode(&self) {
|
||||
let mut test_mode = self.test_mode_active.write().await;
|
||||
*test_mode = true;
|
||||
log::info!("Test mode enabled - normal LED publishing paused");
|
||||
}
|
||||
|
||||
/// Disable test mode - this will resume normal LED data publishing
|
||||
pub async fn disable_test_mode(&self) {
|
||||
let mut test_mode = self.test_mode_active.write().await;
|
||||
*test_mode = false;
|
||||
log::info!("Test mode disabled - normal LED publishing resumed");
|
||||
}
|
||||
|
||||
/// Disable test mode with a delay to ensure clean transition
|
||||
pub async fn disable_test_mode_with_delay(&self, delay_ms: u64) {
|
||||
// Wait for the specified delay
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
|
||||
let mut test_mode = self.test_mode_active.write().await;
|
||||
*test_mode = false;
|
||||
log::info!("Test mode disabled with delay - normal LED publishing resumed");
|
||||
}
|
||||
|
||||
/// Check if test mode is currently active
|
||||
pub async fn is_test_mode_active(&self) -> bool {
|
||||
*self.test_mode_active.read().await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -5,9 +5,27 @@ 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<Display>>,
|
||||
pub controller: Arc<RwLock<SafeDisplay>>,
|
||||
}
|
||||
|
||||
impl DisplayHandler {
|
||||
@ -16,7 +34,7 @@ impl DisplayHandler {
|
||||
|
||||
let mut temp_state = self.state.read().await.clone();
|
||||
|
||||
match controller.handle.get_vcp_feature(0x10) {
|
||||
match controller.get_mut().handle.get_vcp_feature(0x10) {
|
||||
Ok(value) => {
|
||||
temp_state.max_brightness = value.maximum();
|
||||
temp_state.min_brightness = 0;
|
||||
@ -24,7 +42,7 @@ impl DisplayHandler {
|
||||
}
|
||||
Err(_) => {}
|
||||
};
|
||||
match controller.handle.get_vcp_feature(0x12) {
|
||||
match controller.get_mut().handle.get_vcp_feature(0x12) {
|
||||
Ok(value) => {
|
||||
temp_state.max_contrast = value.maximum();
|
||||
temp_state.min_contrast = 0;
|
||||
@ -32,7 +50,7 @@ impl DisplayHandler {
|
||||
}
|
||||
Err(_) => {}
|
||||
};
|
||||
match controller.handle.get_vcp_feature(0xdc) {
|
||||
match controller.get_mut().handle.get_vcp_feature(0xdc) {
|
||||
Ok(value) => {
|
||||
temp_state.max_mode = value.maximum();
|
||||
temp_state.min_mode = 0;
|
||||
@ -52,6 +70,7 @@ impl DisplayHandler {
|
||||
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))?;
|
||||
@ -69,6 +88,7 @@ impl DisplayHandler {
|
||||
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))?;
|
||||
@ -84,6 +104,7 @@ impl DisplayHandler {
|
||||
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))?;
|
||||
|
@ -2,7 +2,7 @@ use std::{env::current_dir, sync::Arc, time::Duration};
|
||||
|
||||
use ddc_hi::Display;
|
||||
use paris::{error, info, warn};
|
||||
use tauri::api::path::config_dir;
|
||||
use dirs::config_dir;
|
||||
use tokio::{
|
||||
sync::{broadcast, watch, OnceCell, RwLock},
|
||||
task::yield_now,
|
||||
@ -13,7 +13,7 @@ use crate::{
|
||||
rpc::{BoardMessageChannels, DisplaySetting},
|
||||
};
|
||||
|
||||
use super::{display_handler::DisplayHandler, display_state::DisplayState};
|
||||
use super::{display_handler::{DisplayHandler, SafeDisplay}, display_state::DisplayState};
|
||||
|
||||
const CONFIG_FILE_NAME: &str = "cc.ivanli.ambient_light/displays.toml";
|
||||
|
||||
@ -85,7 +85,8 @@ impl DisplayManager {
|
||||
let controllers = Display::enumerate();
|
||||
|
||||
for display in controllers {
|
||||
let controller = Arc::new(RwLock::new(display));
|
||||
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(),
|
||||
|
@ -43,6 +43,10 @@ impl LedColor {
|
||||
pub fn as_bytes (&self) -> [u8; 3] {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn as_bytes_rgbw(&self, w: u8) -> [u8; 4] {
|
||||
[self.0[0], self.0[1], self.0[2], w]
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for LedColor {
|
||||
|
239
src-tauri/src/led_test_effects.rs
Normal file
@ -0,0 +1,239 @@
|
||||
use std::f64::consts::PI;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum TestEffectType {
|
||||
FlowingRainbow,
|
||||
GroupCounting,
|
||||
SingleScan,
|
||||
Breathing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestEffectConfig {
|
||||
pub effect_type: TestEffectType,
|
||||
pub led_count: u32,
|
||||
pub led_type: LedType,
|
||||
pub speed: f64, // Speed multiplier
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum LedType {
|
||||
RGB,
|
||||
RGBW,
|
||||
}
|
||||
|
||||
pub struct LedTestEffects;
|
||||
|
||||
impl LedTestEffects {
|
||||
/// Generate LED colors for a specific test effect at a given time
|
||||
pub fn generate_colors(config: &TestEffectConfig, time_ms: u64) -> Vec<u8> {
|
||||
let time_seconds = time_ms as f64 / 1000.0;
|
||||
|
||||
match config.effect_type {
|
||||
TestEffectType::FlowingRainbow => {
|
||||
Self::flowing_rainbow(config.led_count, config.led_type.clone(), time_seconds, config.speed)
|
||||
}
|
||||
TestEffectType::GroupCounting => {
|
||||
Self::group_counting(config.led_count, config.led_type.clone())
|
||||
}
|
||||
TestEffectType::SingleScan => {
|
||||
Self::single_scan(config.led_count, config.led_type.clone(), time_seconds, config.speed)
|
||||
}
|
||||
TestEffectType::Breathing => {
|
||||
Self::breathing(config.led_count, config.led_type.clone(), time_seconds, config.speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flowing rainbow effect - smooth rainbow colors flowing along the strip
|
||||
fn flowing_rainbow(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
|
||||
let mut buffer = Vec::new();
|
||||
let time_offset = (time * speed * 60.0) % 360.0; // 60 degrees per second at speed 1.0
|
||||
|
||||
for i in 0..led_count {
|
||||
// Create longer wavelength for smoother color transitions
|
||||
let hue = ((i as f64 * 720.0 / led_count as f64) + time_offset) % 360.0;
|
||||
let rgb = Self::hsv_to_rgb(hue, 1.0, 1.0);
|
||||
|
||||
buffer.push(rgb.0);
|
||||
buffer.push(rgb.1);
|
||||
buffer.push(rgb.2);
|
||||
|
||||
if matches!(led_type, LedType::RGBW) {
|
||||
buffer.push(0); // White channel
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Group counting effect - every 10 LEDs have different colors
|
||||
fn group_counting(led_count: u32, led_type: LedType) -> Vec<u8> {
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let group_colors = [
|
||||
(255, 0, 0), // Red (1-10)
|
||||
(0, 255, 0), // Green (11-20)
|
||||
(0, 0, 255), // Blue (21-30)
|
||||
(255, 255, 0), // Yellow (31-40)
|
||||
(255, 0, 255), // Magenta (41-50)
|
||||
(0, 255, 255), // Cyan (51-60)
|
||||
(255, 128, 0), // Orange (61-70)
|
||||
(128, 255, 0), // Lime (71-80)
|
||||
(255, 255, 255), // White (81-90)
|
||||
(128, 128, 128), // Gray (91-100)
|
||||
];
|
||||
|
||||
for i in 0..led_count {
|
||||
let group_index = (i / 10) % group_colors.len() as u32;
|
||||
let color = group_colors[group_index as usize];
|
||||
|
||||
buffer.push(color.0);
|
||||
buffer.push(color.1);
|
||||
buffer.push(color.2);
|
||||
|
||||
if matches!(led_type, LedType::RGBW) {
|
||||
buffer.push(0); // White channel
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Single LED scan effect - one LED moves along the strip
|
||||
fn single_scan(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
|
||||
let mut buffer = Vec::new();
|
||||
let scan_period = 2.0 / speed; // 2 seconds per full scan at speed 1.0
|
||||
let active_index = ((time / scan_period * led_count as f64) as u32) % led_count;
|
||||
|
||||
for i in 0..led_count {
|
||||
if i == active_index {
|
||||
// Bright white LED
|
||||
buffer.push(255);
|
||||
buffer.push(255);
|
||||
buffer.push(255);
|
||||
|
||||
if matches!(led_type, LedType::RGBW) {
|
||||
buffer.push(255); // White channel
|
||||
}
|
||||
} else {
|
||||
// Off
|
||||
buffer.push(0);
|
||||
buffer.push(0);
|
||||
buffer.push(0);
|
||||
|
||||
if matches!(led_type, LedType::RGBW) {
|
||||
buffer.push(0); // White channel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Breathing effect - entire strip breathes with white light
|
||||
fn breathing(led_count: u32, led_type: LedType, time: f64, speed: f64) -> Vec<u8> {
|
||||
let mut buffer = Vec::new();
|
||||
let breathing_period = 4.0 / speed; // 4 seconds per breath at speed 1.0
|
||||
let brightness = ((time / breathing_period * 2.0 * PI).sin() * 0.5 + 0.5) * 255.0;
|
||||
let brightness = brightness as u8;
|
||||
|
||||
for _i in 0..led_count {
|
||||
buffer.push(brightness);
|
||||
buffer.push(brightness);
|
||||
buffer.push(brightness);
|
||||
|
||||
if matches!(led_type, LedType::RGBW) {
|
||||
buffer.push(brightness); // White channel
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
}
|
||||
|
||||
/// Convert HSV to RGB
|
||||
/// H: 0-360, S: 0-1, V: 0-1
|
||||
/// Returns: (R, G, B) where each component is 0-255
|
||||
fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
|
||||
let c = v * s;
|
||||
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
||||
let m = v - c;
|
||||
|
||||
let (r_prime, g_prime, b_prime) = if h < 60.0 {
|
||||
(c, x, 0.0)
|
||||
} else if h < 120.0 {
|
||||
(x, c, 0.0)
|
||||
} else if h < 180.0 {
|
||||
(0.0, c, x)
|
||||
} else if h < 240.0 {
|
||||
(0.0, x, c)
|
||||
} else if h < 300.0 {
|
||||
(x, 0.0, c)
|
||||
} else {
|
||||
(c, 0.0, x)
|
||||
};
|
||||
|
||||
let r = ((r_prime + m) * 255.0).round() as u8;
|
||||
let g = ((g_prime + m) * 255.0).round() as u8;
|
||||
let b = ((b_prime + m) * 255.0).round() as u8;
|
||||
|
||||
(r, g, b)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hsv_to_rgb() {
|
||||
// Test red
|
||||
let (r, g, b) = LedTestEffects::hsv_to_rgb(0.0, 1.0, 1.0);
|
||||
assert_eq!((r, g, b), (255, 0, 0));
|
||||
|
||||
// Test green
|
||||
let (r, g, b) = LedTestEffects::hsv_to_rgb(120.0, 1.0, 1.0);
|
||||
assert_eq!((r, g, b), (0, 255, 0));
|
||||
|
||||
// Test blue
|
||||
let (r, g, b) = LedTestEffects::hsv_to_rgb(240.0, 1.0, 1.0);
|
||||
assert_eq!((r, g, b), (0, 0, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flowing_rainbow() {
|
||||
let config = TestEffectConfig {
|
||||
effect_type: TestEffectType::FlowingRainbow,
|
||||
led_count: 10,
|
||||
led_type: LedType::RGB,
|
||||
speed: 1.0,
|
||||
};
|
||||
|
||||
let colors = LedTestEffects::generate_colors(&config, 0);
|
||||
assert_eq!(colors.len(), 30); // 10 LEDs * 3 bytes each
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_counting() {
|
||||
let config = TestEffectConfig {
|
||||
effect_type: TestEffectType::GroupCounting,
|
||||
led_count: 20,
|
||||
led_type: LedType::RGB,
|
||||
speed: 1.0,
|
||||
};
|
||||
|
||||
let colors = LedTestEffects::generate_colors(&config, 0);
|
||||
assert_eq!(colors.len(), 60); // 20 LEDs * 3 bytes each
|
||||
|
||||
// First 10 should be red
|
||||
assert_eq!(colors[0], 255); // R
|
||||
assert_eq!(colors[1], 0); // G
|
||||
assert_eq!(colors[2], 0); // B
|
||||
|
||||
// Next 10 should be green
|
||||
assert_eq!(colors[30], 0); // R
|
||||
assert_eq!(colors[31], 255); // G
|
||||
assert_eq!(colors[32], 0); // B
|
||||
}
|
||||
}
|
@ -4,23 +4,36 @@
|
||||
mod ambient_light;
|
||||
mod display;
|
||||
mod led_color;
|
||||
mod led_test_effects;
|
||||
mod rpc;
|
||||
mod screenshot;
|
||||
mod screenshot_manager;
|
||||
mod screen_stream;
|
||||
mod volume;
|
||||
|
||||
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup};
|
||||
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup, LedType};
|
||||
use display::{DisplayManager, DisplayState};
|
||||
use display_info::DisplayInfo;
|
||||
use led_test_effects::{LedTestEffects, TestEffectConfig, TestEffectType};
|
||||
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;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// Global static variables for LED test effect management
|
||||
static EFFECT_HANDLE: tokio::sync::OnceCell<Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>> =
|
||||
tokio::sync::OnceCell::const_new();
|
||||
static CANCEL_TOKEN: tokio::sync::OnceCell<Arc<RwLock<Option<tokio_util::sync::CancellationToken>>>> =
|
||||
tokio::sync::OnceCell::const_new();
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(remote = "DisplayInfo")]
|
||||
struct DisplayInfoDef {
|
||||
@ -135,6 +148,20 @@ async fn patch_led_strip_len(display_id: u32, border: Border, delta_len: i8) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn patch_led_strip_type(display_id: u32, border: Border, led_type: LedType) -> Result<(), String> {
|
||||
let config_manager = ambient_light::ConfigManager::global().await;
|
||||
config_manager
|
||||
.patch_led_strip_type(display_id, border, led_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("can not patch led strip type: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
|
||||
ambient_light::LedColorsPublisher::send_colors(offset, buffer)
|
||||
@ -145,6 +172,193 @@ async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_test_colors_to_board(board_address: String, offset: u16, buffer: Vec<u8>) -> Result<(), String> {
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await.map_err(|e| {
|
||||
error!("Failed to bind UDP socket: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let mut packet = vec![0x02]; // Header
|
||||
packet.push((offset >> 8) as u8); // Offset high
|
||||
packet.push((offset & 0xff) as u8); // Offset low
|
||||
packet.extend_from_slice(&buffer); // Color data
|
||||
|
||||
socket.send_to(&packet, &board_address).await.map_err(|e| {
|
||||
error!("Failed to send test colors to board {}: {}", board_address, e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
info!("Sent test colors to board {} with offset {} and {} bytes", board_address, offset, buffer.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enable_test_mode() -> Result<(), String> {
|
||||
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||
publisher.enable_test_mode().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn disable_test_mode() -> Result<(), String> {
|
||||
info!("🔄 disable_test_mode command called from frontend");
|
||||
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||
publisher.disable_test_mode().await;
|
||||
info!("✅ disable_test_mode command completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn is_test_mode_active() -> Result<bool, String> {
|
||||
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||
Ok(publisher.is_test_mode_active().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_led_test_effect(
|
||||
board_address: String,
|
||||
effect_config: TestEffectConfig,
|
||||
update_interval_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
use tokio::time::{interval, Duration};
|
||||
|
||||
// Enable test mode first
|
||||
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||
publisher.enable_test_mode().await;
|
||||
|
||||
let handle_storage = EFFECT_HANDLE.get_or_init(|| async {
|
||||
Arc::new(RwLock::new(None))
|
||||
}).await;
|
||||
|
||||
let cancel_storage = CANCEL_TOKEN.get_or_init(|| async {
|
||||
Arc::new(RwLock::new(None))
|
||||
}).await;
|
||||
|
||||
// Stop any existing effect
|
||||
{
|
||||
let mut cancel_guard = cancel_storage.write().await;
|
||||
if let Some(token) = cancel_guard.take() {
|
||||
token.cancel();
|
||||
}
|
||||
|
||||
let mut handle_guard = handle_storage.write().await;
|
||||
if let Some(handle) = handle_guard.take() {
|
||||
let _ = handle.await; // Wait for graceful shutdown
|
||||
}
|
||||
}
|
||||
|
||||
// Start new effect
|
||||
let effect_config = Arc::new(effect_config);
|
||||
let board_address = Arc::new(board_address);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Create new cancellation token
|
||||
let cancel_token = tokio_util::sync::CancellationToken::new();
|
||||
let cancel_token_clone = cancel_token.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut interval = interval(Duration::from_millis(update_interval_ms));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
let elapsed_ms = start_time.elapsed().as_millis() as u64;
|
||||
let colors = LedTestEffects::generate_colors(&effect_config, elapsed_ms);
|
||||
|
||||
// Send to board
|
||||
if let Err(e) = send_test_colors_to_board_internal(&board_address, 0, colors).await {
|
||||
error!("Failed to send test effect colors: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = cancel_token_clone.cancelled() => {
|
||||
info!("LED test effect cancelled gracefully");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("LED test effect task ended");
|
||||
});
|
||||
|
||||
// Store the handle and cancel token
|
||||
{
|
||||
let mut handle_guard = handle_storage.write().await;
|
||||
*handle_guard = Some(handle);
|
||||
|
||||
let mut cancel_guard = cancel_storage.write().await;
|
||||
*cancel_guard = Some(cancel_token);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_led_test_effect(board_address: String, led_count: u32, led_type: led_test_effects::LedType) -> Result<(), String> {
|
||||
// Stop the effect task first
|
||||
|
||||
info!("🛑 Stopping LED test effect - board: {}", board_address);
|
||||
|
||||
// Cancel the task gracefully first
|
||||
if let Some(cancel_storage) = CANCEL_TOKEN.get() {
|
||||
let mut cancel_guard = cancel_storage.write().await;
|
||||
if let Some(token) = cancel_guard.take() {
|
||||
info!("🔄 Cancelling test effect task gracefully");
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the task to finish
|
||||
if let Some(handle_storage) = EFFECT_HANDLE.get() {
|
||||
let mut handle_guard = handle_storage.write().await;
|
||||
if let Some(handle) = handle_guard.take() {
|
||||
info!("⏳ Waiting for test effect task to finish");
|
||||
match handle.await {
|
||||
Ok(_) => info!("✅ Test effect task finished successfully"),
|
||||
Err(e) => warn!("⚠️ Test effect task finished with error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Turn off all LEDs
|
||||
let bytes_per_led = match led_type {
|
||||
led_test_effects::LedType::RGB => 3,
|
||||
led_test_effects::LedType::RGBW => 4,
|
||||
};
|
||||
let buffer = vec![0u8; (led_count * bytes_per_led) as usize];
|
||||
|
||||
send_test_colors_to_board_internal(&board_address, 0, buffer).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!("💡 Sent LED off command");
|
||||
|
||||
// Disable test mode to resume normal publishing
|
||||
let publisher = ambient_light::LedColorsPublisher::global().await;
|
||||
publisher.disable_test_mode().await;
|
||||
|
||||
info!("🔄 Test mode disabled, normal publishing resumed");
|
||||
info!("✅ LED test effect stopped completely");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Internal helper function
|
||||
async fn send_test_colors_to_board_internal(board_address: &str, offset: u16, buffer: Vec<u8>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
|
||||
let mut packet = vec![0x02]; // Header
|
||||
packet.push((offset >> 8) as u8); // Offset high
|
||||
packet.push((offset & 0xff) as u8); // Offset low
|
||||
packet.extend_from_slice(&buffer); // Color data
|
||||
|
||||
socket.send_to(&packet, board_address).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn move_strip_part(
|
||||
display_id: u32,
|
||||
@ -213,10 +427,129 @@ async fn get_displays() -> Vec<DisplayState> {
|
||||
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();
|
||||
|
||||
// Initialize display info (removed debug output)
|
||||
|
||||
tokio::spawn(async move {
|
||||
let screenshot_manager = ScreenshotManager::global().await;
|
||||
screenshot_manager.start().await.unwrap_or_else(|e| {
|
||||
@ -229,9 +562,17 @@ async fn main() {
|
||||
led_color_publisher.start().await;
|
||||
});
|
||||
|
||||
// Start WebSocket server for screen streaming
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = start_websocket_server().await {
|
||||
error!("Failed to start WebSocket server: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let _volume = VolumeManager::global().await;
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
list_display_info,
|
||||
@ -240,7 +581,14 @@ async fn main() {
|
||||
get_led_strips_sample_points,
|
||||
get_one_edge_colors,
|
||||
patch_led_strip_len,
|
||||
patch_led_strip_type,
|
||||
send_colors,
|
||||
send_test_colors_to_board,
|
||||
enable_test_mode,
|
||||
disable_test_mode,
|
||||
is_test_mode_active,
|
||||
start_led_test_effect,
|
||||
stop_led_test_effect,
|
||||
move_strip_part,
|
||||
reverse_led_strip_part,
|
||||
set_color_calibration,
|
||||
@ -248,143 +596,9 @@ async fn main() {
|
||||
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 rx: Result<tokio::sync::watch::Receiver<Screenshot>, anyhow::Error> =
|
||||
screenshot_manager.subscribe_by_display_id(display_id).await;
|
||||
|
||||
if let Err(err) = rx {
|
||||
anyhow::bail!("Display#{}: not found. {}", display_id, err);
|
||||
}
|
||||
let mut rx = rx.unwrap();
|
||||
|
||||
if rx.changed().await.is_err() {
|
||||
anyhow::bail!("Display#{}: no more screenshot.", display_id);
|
||||
}
|
||||
let screenshot = rx.borrow().clone();
|
||||
let bytes = screenshot.bytes.read().await;
|
||||
if bytes.len() == 0 {
|
||||
anyhow::bail!("Display#{}: no screenshot.", display_id);
|
||||
}
|
||||
|
||||
log::debug!("Display#{}: screenshot size: {}", display_id, bytes.len());
|
||||
|
||||
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())
|
||||
})
|
||||
});
|
||||
|
||||
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 {
|
||||
@ -400,7 +614,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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -417,7 +631,7 @@ 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();
|
||||
}
|
||||
});
|
||||
@ -435,7 +649,7 @@ async fn main() {
|
||||
let publisher = publisher_update_receiver.borrow().clone();
|
||||
|
||||
app_handle
|
||||
.emit_all("led_colors_changed", publisher)
|
||||
.emit("led_colors_changed", publisher)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
@ -456,7 +670,7 @@ async fn main() {
|
||||
|
||||
let boards = boards.into_iter().collect::<Vec<_>>();
|
||||
|
||||
app_handle.emit_all("boards_changed", boards).unwrap();
|
||||
app_handle.emit("boards_changed", boards).unwrap();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@ -477,7 +691,7 @@ async fn main() {
|
||||
|
||||
log::info!("displays changed. emit displays_changed event.");
|
||||
|
||||
app_handle.emit_all("displays_changed", displays).unwrap();
|
||||
app_handle.emit("displays_changed", displays).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
@ -486,3 +700,30 @@ async fn main() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
// WebSocket server for screen streaming
|
||||
async fn start_websocket_server() -> anyhow::Result<()> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:8765").await?;
|
||||
info!("WebSocket server listening on ws://127.0.0.1:8765");
|
||||
|
||||
while let Ok((stream, addr)) = listener.accept().await {
|
||||
info!("New WebSocket connection from: {}", addr);
|
||||
|
||||
tokio::spawn(async move {
|
||||
info!("Starting WebSocket handler for connection from: {}", addr);
|
||||
match screen_stream::handle_websocket_connection(stream).await {
|
||||
Ok(_) => {
|
||||
info!("WebSocket connection from {} completed successfully", addr);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("WebSocket connection error from {}: {}", addr, e);
|
||||
}
|
||||
}
|
||||
info!("WebSocket handler task completed for: {}", addr);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
520
src-tauri/src/screen_stream.rs
Normal 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(())
|
||||
}
|
@ -145,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;
|
||||
|
||||
// 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);
|
||||
@ -169,9 +176,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;
|
||||
|
||||
// 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);
|
||||
|
@ -6,9 +6,11 @@ use core_graphics::display::{
|
||||
};
|
||||
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
|
||||
use paris::{info, warn};
|
||||
use rust_swift_screencapture::display::CGDisplayId;
|
||||
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, Mutex, OnceCell};
|
||||
use tokio::sync::{broadcast, watch, OnceCell};
|
||||
use tokio::task::yield_now;
|
||||
use tokio::time::sleep;
|
||||
|
||||
@ -20,7 +22,7 @@ pub fn get_display_colors(
|
||||
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![];
|
||||
@ -106,20 +108,28 @@ impl ScreenshotManager {
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
let displays = display_info::DisplayInfo::all()?;
|
||||
|
||||
log::info!("ScreenshotManager starting with {} displays:", displays.len());
|
||||
for display in &displays {
|
||||
log::info!(" Display ID: {}, Scale: {}", display.id, display.scale_factor);
|
||||
}
|
||||
|
||||
let futures = displays.iter().map(|display| async {
|
||||
self.start_one(display.id, display.scale_factor)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!("start_one failed: display_id: {}, err: {}", display.id, err);
|
||||
});
|
||||
info!("start_one finished: display_id: {}", display.id);
|
||||
|
||||
});
|
||||
|
||||
futures::future::join_all(futures).await;
|
||||
log::info!("ScreenshotManager started successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_one(&self, display_id: u32, scale_factor: f32) -> anyhow::Result<()> {
|
||||
log::info!("Starting screenshot capture for display_id: {}", display_id);
|
||||
|
||||
let merged_screenshot_tx = self.merged_screenshot_tx.clone();
|
||||
|
||||
let (tx, _) = watch::channel(Screenshot::new(
|
||||
@ -138,43 +148,96 @@ impl ScreenshotManager {
|
||||
|
||||
drop(channels);
|
||||
|
||||
|
||||
|
||||
// Implement screen capture using screen-capture-kit
|
||||
loop {
|
||||
let display = rust_swift_screencapture::display::Display::new(display_id);
|
||||
let mut frame_rx = display.subscribe_frame().await;
|
||||
|
||||
display.start_capture(30).await;
|
||||
|
||||
match Self::capture_display_screenshot(display_id, scale_factor).await {
|
||||
Ok(screenshot) => {
|
||||
let tx_for_send = tx.read().await;
|
||||
|
||||
while frame_rx.changed().await.is_ok() {
|
||||
let frame = frame_rx.borrow().clone();
|
||||
let screenshot = Screenshot::new(
|
||||
display_id,
|
||||
frame.height as u32,
|
||||
frame.width as u32,
|
||||
frame.bytes_per_row as usize,
|
||||
frame.bytes,
|
||||
scale_factor,
|
||||
scale_factor,
|
||||
);
|
||||
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);
|
||||
} else {
|
||||
log::debug!("screenshot: {:?}", screenshot);
|
||||
}
|
||||
}
|
||||
Err(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep for a frame duration (5 FPS for much better CPU performance)
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
yield_now().await;
|
||||
}
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
info!(
|
||||
"display {} frame_rx.changed() failed, try to restart",
|
||||
display_id
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
let cg_display = CGDisplay::new(display_id);
|
||||
let bounds = cg_display.bounds();
|
||||
|
||||
|
||||
|
||||
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> {
|
||||
@ -232,7 +295,7 @@ impl ScreenshotManager {
|
||||
|
||||
pub async fn subscribe_by_display_id(
|
||||
&self,
|
||||
display_id: CGDisplayId,
|
||||
display_id: u32,
|
||||
) -> anyhow::Result<watch::Receiver<Screenshot>> {
|
||||
let channels = self.channels.read().await;
|
||||
if let Some(tx) = channels.get(&display_id) {
|
||||
|
@ -1,43 +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
|
||||
},
|
||||
"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",
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "13"
|
||||
}
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
@ -48,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
76
src/App.tsx
@ -1,39 +1,95 @@
|
||||
import { Routes, Route } from '@solidjs/router';
|
||||
import { Routes, Route, useLocation, A } 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';
|
||||
import { LedStripTest } from './components/led-strip-test/led-strip-test';
|
||||
import { createEffect, createSignal } from 'solid-js';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { setLedStripStore } from './stores/led-strip.store';
|
||||
import { LedStripConfigContainer } from './models/led-strip-config';
|
||||
import { InfoIndex } from './components/info/info-index';
|
||||
import { DisplayStateIndex } from './components/displays/display-state-index';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const [previousPath, setPreviousPath] = createSignal<string>('');
|
||||
|
||||
// Monitor route changes and cleanup LED tests when leaving the test page
|
||||
createEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
const prevPath = previousPath();
|
||||
|
||||
// Check if we're leaving the LED test page
|
||||
const isLeavingTestPage = prevPath === '/led-strip-test' && currentPath !== '/led-strip-test';
|
||||
|
||||
if (isLeavingTestPage) {
|
||||
// The LED test component will handle stopping the test effect via onCleanup
|
||||
// We just need to ensure test mode is disabled to resume normal LED publishing
|
||||
invoke('disable_test_mode').catch((error) => {
|
||||
console.error('Failed to disable test mode:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Update previousPath after the condition check
|
||||
setPreviousPath(currentPath);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
invoke<LedStripConfigContainer>('read_config').then((config) => {
|
||||
console.log('read config', config);
|
||||
setLedStripStore({
|
||||
strips: config.strips,
|
||||
mappers: config.mappers,
|
||||
colorCalibration: config.color_calibration,
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('Failed to read config:', error);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<a href="/info">基本信息</a>
|
||||
<a href="/displays">显示器信息</a>
|
||||
<a href="/led-strips-configuration">灯条配置</a>
|
||||
<a href="/white-balance">白平衡</a>
|
||||
<div class="min-h-screen bg-base-100" data-theme="dark">
|
||||
{/* Fixed Navigation */}
|
||||
<div class="navbar bg-base-200 shadow-lg fixed top-0 left-0 right-0 z-50">
|
||||
<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>
|
||||
<li><A href="/led-strip-test" 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>
|
||||
<li><A href="/led-strip-test" 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 with top padding to account for fixed navbar */}
|
||||
<main class="container mx-auto p-4 pt-20">
|
||||
<Routes>
|
||||
<Route path="/info" component={InfoIndex} />
|
||||
<Route path="/displays" component={DisplayStateIndex} />
|
||||
<Route path="/led-strips-configuration" component={LedStripConfiguration} />
|
||||
<Route path="/white-balance" component={WhiteBalance} />
|
||||
<Route path="/led-strip-test" element={<LedStripTest />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -11,26 +11,59 @@ type ItemProps = {
|
||||
|
||||
const Item: ParentComponent<ItemProps> = (props) => {
|
||||
return (
|
||||
<dl class="flex">
|
||||
<dt class="w-20">{props.label}</dt>
|
||||
<dd class="flex-auto">{props.children}</dd>
|
||||
</dl>
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<dt class="text-sm font-medium text-base-content/70">{props.label}</dt>
|
||||
<dd class="text-sm font-mono text-base-content">{props.children}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DisplayStateCard: Component<DisplayStateCardProps> = (props) => {
|
||||
return (
|
||||
<section class="p-2 rounded shadow">
|
||||
<Item label="Brightness">{props.state.brightness}</Item>
|
||||
<Item label="Max Brightness">{props.state.max_brightness}</Item>
|
||||
<Item label="Min Brightness">{props.state.min_brightness}</Item>
|
||||
<Item label="Contrast">{props.state.contrast}</Item>
|
||||
<Item label="Max Contrast">{props.state.max_contrast}</Item>
|
||||
<Item label="Min Contrast">{props.state.min_contrast}</Item>
|
||||
<Item label="Max Mode">{props.state.max_mode}</Item>
|
||||
<Item label="Min Mode">{props.state.min_mode}</Item>
|
||||
<Item label="Mode">{props.state.mode}</Item>
|
||||
<Item label="Last Modified At">{props.state.last_modified_at.toISOString()}</Item>
|
||||
</section>
|
||||
<div class="card bg-base-200 shadow-lg hover:shadow-xl transition-shadow duration-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex items-center justify-between">
|
||||
<span>显示器状态</span>
|
||||
<div class="badge badge-primary badge-outline">实时</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
{/* 亮度信息 */}
|
||||
<div class="bg-base-100 rounded-lg p-3">
|
||||
<h4 class="text-sm font-semibold text-base-content mb-2">亮度设置</h4>
|
||||
<div class="space-y-1">
|
||||
<Item label="当前亮度">{props.state.brightness}</Item>
|
||||
<Item label="最大亮度">{props.state.max_brightness}</Item>
|
||||
<Item label="最小亮度">{props.state.min_brightness}</Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对比度信息 */}
|
||||
<div class="bg-base-100 rounded-lg p-3">
|
||||
<h4 class="text-sm font-semibold text-base-content mb-2">对比度设置</h4>
|
||||
<div class="space-y-1">
|
||||
<Item label="当前对比度">{props.state.contrast}</Item>
|
||||
<Item label="最大对比度">{props.state.max_contrast}</Item>
|
||||
<Item label="最小对比度">{props.state.min_contrast}</Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式信息 */}
|
||||
<div class="bg-base-100 rounded-lg p-3">
|
||||
<h4 class="text-sm font-semibold text-base-content mb-2">模式设置</h4>
|
||||
<div class="space-y-1">
|
||||
<Item label="当前模式">{props.state.mode}</Item>
|
||||
<Item label="最大模式">{props.state.max_mode}</Item>
|
||||
<Item label="最小模式">{props.state.min_mode}</Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新时间 */}
|
||||
<div class="text-xs text-base-content/50 text-center pt-2 border-t border-base-300">
|
||||
最后更新: {props.state.last_modified_at.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { DisplayState, RawDisplayState } from '../../models/display-state.model';
|
||||
import { DisplayStateCard } from './display-state-card';
|
||||
|
||||
@ -36,17 +36,37 @@ export const DisplayStateIndex: Component = () => {
|
||||
};
|
||||
});
|
||||
return (
|
||||
<ol class="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 p-2 gap-2">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">显示器状态</h1>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">显示器数量</div>
|
||||
<div class="stat-value text-primary">{states().length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<For each={states()}>
|
||||
{(state, index) => (
|
||||
<li class="bg-slate-50 text-gray-800 relative border-2 border-slate-50 hover:border-sky-300 focus:border-sky-300 transition">
|
||||
<div class="relative">
|
||||
<DisplayStateCard state={state} />
|
||||
<span class="absolute left-2 -top-3 bg-sky-300 text-white px-1 py-0.5 text-xs rounded-sm font-mono">
|
||||
#{index() + 1}
|
||||
</span>
|
||||
</li>
|
||||
<div class="absolute -top-2 -left-2 w-6 h-6 bg-primary text-primary-content rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{index() + 1}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{states().length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🖥️</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-2">未检测到显示器</h3>
|
||||
<p class="text-base-content/70">请检查显示器连接状态</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ 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';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { BoardInfoPanel } from './board-info-panel';
|
||||
|
||||
const logger = debug('app:components:info:board-index');
|
||||
@ -26,17 +26,37 @@ export const BoardIndex: Component = () => {
|
||||
};
|
||||
});
|
||||
return (
|
||||
<ol class="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 p-2 gap-2">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">设备信息</h1>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">设备总数</div>
|
||||
<div class="stat-value text-primary">{boards().length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={boards()}>
|
||||
{(board, index) => (
|
||||
<li class="bg-slate-50 text-gray-800 relative border-2 border-slate-50 hover:border-sky-300 focus:border-sky-300 transition">
|
||||
<div class="relative">
|
||||
<BoardInfoPanel board={board} />
|
||||
<span class="absolute left-2 -top-3 bg-sky-300 text-white px-1 py-0.5 text-xs rounded-sm font-mono">
|
||||
#{index() + 1}
|
||||
</span>
|
||||
</li>
|
||||
<div class="absolute -top-2 -left-2 w-6 h-6 bg-primary text-primary-content rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{index() + 1}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{boards().length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🔍</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-2">未发现设备</h3>
|
||||
<p class="text-base-content/70">请检查设备连接状态</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -7,10 +7,10 @@ type ItemProps = {
|
||||
|
||||
const Item: ParentComponent<ItemProps> = (props) => {
|
||||
return (
|
||||
<dl class="flex">
|
||||
<dt class="w-20">{props.label}</dt>
|
||||
<dd class="flex-auto">{props.children}</dd>
|
||||
</dl>
|
||||
<div class="flex justify-between items-center py-1">
|
||||
<dt class="text-sm font-medium text-base-content/70">{props.label}</dt>
|
||||
<dd class="text-sm font-mono text-base-content">{props.children}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -41,20 +41,31 @@ export const BoardInfoPanel: Component<{ board: BoardInfo }> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
const statusBadgeClass = createMemo(() => {
|
||||
const status = connectStatus();
|
||||
if (status === 'Connected') {
|
||||
return 'badge badge-success badge-sm';
|
||||
} else if (status?.startsWith('Connecting')) {
|
||||
return 'badge badge-warning badge-sm';
|
||||
} else {
|
||||
return 'badge badge-error badge-sm';
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class="p-2 rounded shadow">
|
||||
<Item label="Host">{props.board.fullname}</Item>
|
||||
<Item label="Host">{props.board.host}</Item>
|
||||
<Item label="Ip Addr">
|
||||
<span class="font-mono">{props.board.address}</span>
|
||||
</Item>
|
||||
<Item label="Port">
|
||||
<span class="font-mono">{props.board.port}</span>
|
||||
</Item>
|
||||
<Item label="Status">
|
||||
<span class="font-mono">{connectStatus()}</span>
|
||||
</Item>
|
||||
<Item label="TTL">{ttl()}</Item>
|
||||
</section>
|
||||
<div class="card bg-base-200 shadow-lg hover:shadow-xl transition-shadow duration-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex items-center justify-between">
|
||||
<span class="truncate">{props.board.fullname}</span>
|
||||
<div class={statusBadgeClass()}>{connectStatus()}</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Item label="主机名">{props.board.host}</Item>
|
||||
<Item label="IP地址">{props.board.address}</Item>
|
||||
<Item label="端口">{props.board.port}</Item>
|
||||
<Item label="延迟">{ttl()}</Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -7,10 +7,10 @@ type DisplayInfoItemProps = {
|
||||
|
||||
export const DisplayInfoItem: ParentComponent<DisplayInfoItemProps> = (props) => {
|
||||
return (
|
||||
<dl class="px-3 py-1 flex hover:bg-slate-900/50 gap-2 text-white drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)] rounded">
|
||||
<dt class="uppercase w-1/2 select-all whitespace-nowrap">{props.label}</dt>
|
||||
<dd class="select-all w-1/2 whitespace-nowrap">{props.children}</dd>
|
||||
</dl>
|
||||
<div class="flex justify-between items-center py-1 px-2 hover:bg-base-300/50 rounded transition-colors">
|
||||
<dt class="text-sm font-medium text-base-content/80 uppercase">{props.label}</dt>
|
||||
<dd class="text-sm font-mono text-base-content select-all">{props.children}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -21,22 +21,29 @@ type DisplayInfoPanelProps = {
|
||||
export const DisplayInfoPanel: Component<DisplayInfoPanelProps> = (props) => {
|
||||
const [localProps, rootProps] = splitProps(props, ['display']);
|
||||
return (
|
||||
<section {...rootProps} class={'m-2 flex flex-col gap-1 py-2 ' + rootProps.class}>
|
||||
<div {...rootProps} class={'card bg-base-100/95 backdrop-blur shadow-lg border border-base-300 ' + rootProps.class}>
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-sm mb-3 flex items-center justify-between">
|
||||
<span class="text-base-content">显示器信息</span>
|
||||
{localProps.display.is_primary && (
|
||||
<div class="badge badge-primary badge-sm">主显示器</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<DisplayInfoItem label="ID">
|
||||
<code>{localProps.display.id}</code>
|
||||
<code class="bg-base-200 px-1 rounded text-xs">{localProps.display.id}</code>
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="Position">
|
||||
<DisplayInfoItem label="位置">
|
||||
({localProps.display.x}, {localProps.display.y})
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="Size">
|
||||
{localProps.display.width} x {localProps.display.height}
|
||||
<DisplayInfoItem label="尺寸">
|
||||
{localProps.display.width} × {localProps.display.height}
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="Scale Factor">
|
||||
{localProps.display.scale_factor}
|
||||
<DisplayInfoItem label="缩放">
|
||||
{localProps.display.scale_factor}×
|
||||
</DisplayInfoItem>
|
||||
<DisplayInfoItem label="is Primary">
|
||||
{localProps.display.is_primary ? 'True' : 'False'}
|
||||
</DisplayInfoItem>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import { DisplayInfoPanel } from './display-info-panel';
|
||||
import { LedStripPart } from './led-strip-part';
|
||||
import { ScreenView } from './screen-view';
|
||||
|
||||
|
||||
type DisplayViewProps = {
|
||||
display: DisplayInfo;
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
@ -0,0 +1,204 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Component, createMemo, For, JSX, splitProps, useContext } from 'solid-js';
|
||||
import { DisplayInfo } from '../../models/display-info.model';
|
||||
import { ledStripStore } from '../../stores/led-strip.store';
|
||||
import { Borders } from '../../constants/border';
|
||||
import { LedType } from '../../models/led-strip-config';
|
||||
import { LedStripConfigurationContext } from '../../contexts/led-strip-configuration.context';
|
||||
|
||||
type LedCountControlItemProps = {
|
||||
displayId: number;
|
||||
border: Borders;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const LedCountControlItem: Component<LedCountControlItemProps> = (props) => {
|
||||
const [stripConfiguration, { setHoveredStripPart }] = useContext(LedStripConfigurationContext);
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLedTypeChange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
const newType = target.value as LedType;
|
||||
|
||||
invoke('patch_led_strip_type', {
|
||||
displayId: props.displayId,
|
||||
border: props.border,
|
||||
ledType: newType,
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseEnter = () => {
|
||||
setHoveredStripPart({
|
||||
displayId: props.displayId,
|
||||
border: props.border,
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
setHoveredStripPart(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class="card bg-base-100 border border-base-300/50 p-2 transition-all duration-200 cursor-pointer"
|
||||
classList={{
|
||||
'ring-2 ring-primary bg-primary/20 border-primary':
|
||||
stripConfiguration.hoveredStripPart?.border === props.border &&
|
||||
stripConfiguration.hoveredStripPart?.displayId === props.displayId,
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<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 class="mt-1">
|
||||
<select
|
||||
class="select select-xs w-full text-xs"
|
||||
value={config()?.led_type || LedType.RGB}
|
||||
onChange={handleLedTypeChange}
|
||||
title="LED类型"
|
||||
>
|
||||
<option value={LedType.RGB}>RGB</option>
|
||||
<option value={LedType.RGBW}>RGBW</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LedCountControlPanelProps = {
|
||||
display: DisplayInfo;
|
||||
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const LedCountControlPanel: Component<LedCountControlPanelProps> = (props) => {
|
||||
const [localProps, rootProps] = splitProps(props, ['display']);
|
||||
|
||||
const borders: { border: Borders; label: string }[] = [
|
||||
{ border: 'Top', label: '上' },
|
||||
{ border: 'Bottom', label: '下' },
|
||||
{ border: 'Left', label: '左' },
|
||||
{ border: 'Right', label: '右' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div {...rootProps} class={'card bg-base-200 shadow-lg border border-base-300 ' + (rootProps.class || '')}>
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex items-center justify-between">
|
||||
<span>LED数量控制</span>
|
||||
<div class="badge badge-info badge-outline">显示器 {localProps.display.id}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<For each={borders}>
|
||||
{(item) => (
|
||||
<LedCountControlItem
|
||||
displayId={localProps.display.id}
|
||||
border={item.border}
|
||||
label={item.label}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-base-content/50 mt-3 p-2 bg-base-300/50 rounded">
|
||||
💡 提示:点击 +/- 按钮或直接输入数值来调整LED数量(范围:0-1000)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
import { createEffect, onCleanup } from 'solid-js';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
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';
|
||||
@ -7,22 +7,29 @@ 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);
|
||||
setDisplayStore({
|
||||
displays: JSON.parse(displays),
|
||||
displays: parsedDisplays,
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load displays:', error);
|
||||
});
|
||||
|
||||
invoke<LedStripConfigContainer>('read_led_strip_configs').then((configs) => {
|
||||
console.log(configs);
|
||||
setLedStripStore(configs);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load LED strip configs:', error);
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,7 +37,6 @@ export const LedStripConfiguration = () => {
|
||||
createEffect(() => {
|
||||
const unlisten = listen('config_changed', (event) => {
|
||||
const { strips, mappers } = event.payload as LedStripConfigContainer;
|
||||
console.log(event.payload);
|
||||
setLedStripStore({
|
||||
strips,
|
||||
mappers,
|
||||
@ -78,6 +84,7 @@ export const LedStripConfiguration = () => {
|
||||
LedStripConfigurationContextType[0]
|
||||
>({
|
||||
selectedStripPart: null,
|
||||
hoveredStripPart: null,
|
||||
});
|
||||
|
||||
const ledStripConfigurationContextValue: LedStripConfigurationContextType = [
|
||||
@ -88,18 +95,73 @@ export const LedStripConfiguration = () => {
|
||||
selectedStripPart: v,
|
||||
});
|
||||
},
|
||||
setHoveredStripPart: (v) => {
|
||||
setLedStripConfiguration({
|
||||
hoveredStripPart: v,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">灯条配置</h1>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">显示器数量</div>
|
||||
<div class="stat-value text-primary">{displayStore.displays.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LedStripConfigurationContext.Provider value={ledStripConfigurationContextValue}>
|
||||
{/* LED Strip Sorter Panel */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>灯条排序</span>
|
||||
<div class="badge badge-info badge-outline">实时预览</div>
|
||||
</div>
|
||||
<LedStripPartsSorter />
|
||||
<div class="text-xs text-base-content/50 mt-2">
|
||||
💡 提示:拖拽灯条段落来调整顺序,双击可反转方向
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Configuration Panel */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>显示器配置</span>
|
||||
<div class="badge badge-secondary badge-outline">可视化编辑</div>
|
||||
</div>
|
||||
<div class="h-96 mb-4">
|
||||
<DisplayListContainer>
|
||||
{displayStore.displays.map((display) => {
|
||||
return <DisplayView display={display} />;
|
||||
})}
|
||||
{displayStore.displays.map((display) => (
|
||||
<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>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
@ -34,7 +34,7 @@ export const Pixel: Component<PixelProps> = (props) => {
|
||||
title={props.color}
|
||||
>
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 h-2.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>
|
||||
@ -68,13 +68,14 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = mapper.pos * 3;
|
||||
const offset = mapper.start * 3;
|
||||
|
||||
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})`;
|
||||
});
|
||||
|
||||
setColors(colors);
|
||||
@ -120,7 +121,7 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
|
||||
{...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={{
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
} 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 { invoke } from '@tauri-apps/api/core';
|
||||
import { LedStripConfigurationContext } from '../../contexts/led-strip-configuration.context';
|
||||
import background from '../../assets/transparent-grid-background.svg?url';
|
||||
|
||||
@ -29,7 +29,7 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
||||
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 [stripConfiguration, { setSelectedStripPart, setHoveredStripPart }] = useContext(LedStripConfigurationContext);
|
||||
const [rootWidth, setRootWidth] = createSignal<number>(0);
|
||||
|
||||
let root: HTMLDivElement;
|
||||
@ -38,9 +38,6 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
||||
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,
|
||||
@ -151,6 +148,17 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
||||
}).catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const onMouseEnter = () => {
|
||||
setHoveredStripPart({
|
||||
displayId: props.strip.display_id,
|
||||
border: props.strip.border,
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
setHoveredStripPart(null);
|
||||
};
|
||||
|
||||
const setColor = (fullIndex: number, colorsIndex: number, fullLeds: string[]) => {
|
||||
const colors = ledStripStore.colors;
|
||||
let c1 = `rgb(${Math.floor(colors[colorsIndex * 3] * 0.8)}, ${Math.floor(
|
||||
@ -162,7 +170,6 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
||||
)}, ${Math.min(Math.floor(colors[colorsIndex * 3 + 2] * 1.2), 255)})`;
|
||||
|
||||
if (fullLeds.length <= fullIndex) {
|
||||
console.error('out of range', fullIndex, fullLeds.length);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -221,9 +228,16 @@ const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper
|
||||
|
||||
return (
|
||||
<div
|
||||
class="flex mx-2 select-none cursor-ew-resize focus:cursor-ew-resize"
|
||||
class="flex mx-2 select-none cursor-ew-resize focus:cursor-ew-resize transition-colors duration-200"
|
||||
classList={{
|
||||
'bg-primary/20 rounded-lg':
|
||||
stripConfiguration.hoveredStripPart?.border === props.strip.border &&
|
||||
stripConfiguration.hoveredStripPart?.displayId === props.strip.display_id,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
ondblclick={reverse}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={root!}
|
||||
>
|
||||
<div
|
||||
|
255
src/components/led-strip-configuration/screen-view-websocket.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = `ws://127.0.0.1:8765`;
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
websocket = new WebSocket(wsUrl);
|
||||
websocket.binaryType = 'arraybuffer';
|
||||
|
||||
websocket.onopen = () => {
|
||||
setConnectionStatus('connected');
|
||||
|
||||
// Send initial configuration
|
||||
const config = {
|
||||
display_id: localProps.displayId,
|
||||
width: localProps.width || 320,
|
||||
height: localProps.height || 180,
|
||||
quality: localProps.quality || 50
|
||||
};
|
||||
websocket?.send(JSON.stringify(config));
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
handleJpegFrame(new Uint8Array(event.data));
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
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) => {
|
||||
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(() => {
|
||||
const context = canvas.getContext('2d');
|
||||
setCtx(context);
|
||||
|
||||
// Initial size setup
|
||||
resetSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
resetSize();
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
|
||||
// Connect WebSocket
|
||||
connectWebSocket();
|
||||
|
||||
onCleanup(() => {
|
||||
isMounted = false;
|
||||
disconnect();
|
||||
resizeObserver?.unobserve(root);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rootProps.children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,4 +1,3 @@
|
||||
import { convertFileSrc } from '@tauri-apps/api/tauri';
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
@ -8,16 +7,29 @@ import {
|
||||
onMount,
|
||||
splitProps,
|
||||
} from 'solid-js';
|
||||
import { ScreenViewWebSocket } from './screen-view-websocket';
|
||||
|
||||
type ScreenViewProps = {
|
||||
displayId: number;
|
||||
useWebSocket?: boolean;
|
||||
} & JSX.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
const [localProps, rootProps] = splitProps(props, ['displayId']);
|
||||
const [localProps, rootProps] = splitProps(props, ['displayId', 'useWebSocket']);
|
||||
|
||||
// Use WebSocket by default for better performance
|
||||
if (localProps.useWebSocket !== false) {
|
||||
return <ScreenViewWebSocket displayId={localProps.displayId} {...rootProps} />;
|
||||
}
|
||||
|
||||
// Fallback to HTTP polling (legacy mode)
|
||||
let canvas: HTMLCanvasElement;
|
||||
let root: HTMLDivElement;
|
||||
const [ctx, setCtx] = createSignal<CanvasRenderingContext2D | null>(null);
|
||||
|
||||
// Cache temporary canvas for scaling
|
||||
let tempCanvas: HTMLCanvasElement | null = null;
|
||||
let tempCtx: CanvasRenderingContext2D | null = null;
|
||||
const [drawInfo, setDrawInfo] = createSignal({
|
||||
drawX: 0,
|
||||
drawY: 0,
|
||||
@ -30,9 +42,84 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
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 = () => {
|
||||
const aspectRatio = canvas.width / canvas.height;
|
||||
// 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),
|
||||
@ -51,106 +138,83 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
drawHeight,
|
||||
});
|
||||
|
||||
canvas.width = root.clientWidth;
|
||||
canvas.height = root.clientHeight;
|
||||
|
||||
draw(true);
|
||||
};
|
||||
|
||||
const draw = (cached: boolean = false) => {
|
||||
const { drawX, drawY } = drawInfo();
|
||||
|
||||
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) {
|
||||
for (let i = 3; i < raw.buffer.length; i += 8) {
|
||||
raw.buffer[i] = Math.floor(raw.buffer[i] * 0.7);
|
||||
buffer = new Uint8ClampedArray(raw.buffer);
|
||||
for (let i = 3; i < buffer.length; i += 4) {
|
||||
buffer[i] = Math.floor(buffer[i] * 0.7);
|
||||
}
|
||||
}
|
||||
const img = new ImageData(raw.buffer, raw.width, raw.height);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
draw();
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
while (!stopped) {
|
||||
if (hidden()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
continue;
|
||||
}
|
||||
await frame();
|
||||
}
|
||||
})();
|
||||
|
||||
onCleanup(() => {
|
||||
stopped = true;
|
||||
});
|
||||
});
|
||||
|
||||
// resize
|
||||
createEffect(() => {
|
||||
let resizeObserver: ResizeObserver;
|
||||
|
||||
// Initialize canvas and resize observer
|
||||
onMount(() => {
|
||||
setCtx(canvas.getContext('2d'));
|
||||
new ResizeObserver(() => {
|
||||
const context = canvas.getContext('2d');
|
||||
setCtx(context);
|
||||
|
||||
// Initial size setup
|
||||
resetSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
resetSize();
|
||||
}).observe(root);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
// 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
|
||||
@ -158,7 +222,15 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
|
||||
{...rootProps}
|
||||
class={'overflow-hidden h-full w-full ' + rootProps.class}
|
||||
>
|
||||
<canvas ref={canvas!} />
|
||||
<canvas
|
||||
ref={canvas!}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'background-color': '#f0f0f0'
|
||||
}}
|
||||
/>
|
||||
{rootProps.children}
|
||||
</div>
|
||||
);
|
||||
|
343
src/components/led-strip-test/led-strip-test.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
import { createSignal, createEffect, For, Show, onCleanup } from 'solid-js';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
interface BoardInfo {
|
||||
fullname: string;
|
||||
host: string;
|
||||
address: string;
|
||||
port: number;
|
||||
connect_status: 'Connected' | 'Disconnected' | { Connecting: number };
|
||||
}
|
||||
|
||||
interface TestPattern {
|
||||
name: string;
|
||||
description: string;
|
||||
effect_type: string;
|
||||
}
|
||||
|
||||
interface TestEffectConfig {
|
||||
effect_type: string;
|
||||
led_count: number;
|
||||
led_type: string;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export const LedStripTest = () => {
|
||||
const [boards, setBoards] = createSignal<BoardInfo[]>([]);
|
||||
const [selectedBoard, setSelectedBoard] = createSignal<BoardInfo | null>(null);
|
||||
const [ledCount, setLedCount] = createSignal(60);
|
||||
const [ledType, setLedType] = createSignal<'RGB' | 'RGBW'>('RGB');
|
||||
const [isRunning, setIsRunning] = createSignal(false);
|
||||
const [currentPattern, setCurrentPattern] = createSignal<TestPattern | null>(null);
|
||||
const [animationSpeed, setAnimationSpeed] = createSignal(33); // ~30fps
|
||||
|
||||
// Load available boards and listen for changes
|
||||
createEffect(() => {
|
||||
// Initial load
|
||||
invoke<BoardInfo[]>('get_boards').then((boardList) => {
|
||||
setBoards(boardList);
|
||||
if (boardList.length > 0 && !selectedBoard()) {
|
||||
setSelectedBoard(boardList[0]);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load boards:', error);
|
||||
});
|
||||
|
||||
// Listen for board changes
|
||||
const unlisten = listen<BoardInfo[]>('boards_changed', (event) => {
|
||||
const boardList = event.payload;
|
||||
setBoards(boardList);
|
||||
|
||||
// If currently selected board is no longer available, select the first available one
|
||||
const currentBoard = selectedBoard();
|
||||
if (currentBoard) {
|
||||
const stillExists = boardList.find(board =>
|
||||
board.host === currentBoard.host &&
|
||||
board.address === currentBoard.address &&
|
||||
board.port === currentBoard.port
|
||||
);
|
||||
|
||||
if (!stillExists) {
|
||||
// Current board is no longer available, select first available or null
|
||||
setSelectedBoard(boardList.length > 0 ? boardList[0] : null);
|
||||
}
|
||||
} else if (boardList.length > 0) {
|
||||
// No board was selected, select the first one
|
||||
setSelectedBoard(boardList[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup listener when effect is disposed
|
||||
onCleanup(() => {
|
||||
unlisten.then((unlistenFn) => unlistenFn());
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup when component is unmounted
|
||||
onCleanup(() => {
|
||||
if (isRunning() && selectedBoard()) {
|
||||
// Stop the test effect in backend
|
||||
invoke('stop_led_test_effect', {
|
||||
boardAddress: `${selectedBoard()!.address}:${selectedBoard()!.port}`,
|
||||
ledCount: ledCount(),
|
||||
ledType: ledType()
|
||||
}).catch((error) => {
|
||||
console.error('Failed to stop test during cleanup:', error);
|
||||
});
|
||||
|
||||
// Update local state immediately
|
||||
setIsRunning(false);
|
||||
setCurrentPattern(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Test patterns
|
||||
const testPatterns: TestPattern[] = [
|
||||
{
|
||||
name: '流光效果',
|
||||
description: '彩虹色流光,用于测试灯带方向',
|
||||
effect_type: 'FlowingRainbow'
|
||||
},
|
||||
{
|
||||
name: '十个一组计数',
|
||||
description: '每十个LED一组不同颜色,用于快速计算灯珠数量',
|
||||
effect_type: 'GroupCounting'
|
||||
},
|
||||
{
|
||||
name: '单色扫描',
|
||||
description: '单个LED依次点亮,用于精确测试每个LED位置',
|
||||
effect_type: 'SingleScan'
|
||||
},
|
||||
{
|
||||
name: '呼吸灯',
|
||||
description: '整条灯带呼吸效果,用于测试整体亮度',
|
||||
effect_type: 'Breathing'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
||||
// Test effect management - now handled by Rust backend
|
||||
|
||||
const startTest = async (pattern: TestPattern) => {
|
||||
if (isRunning()) {
|
||||
await stopTest();
|
||||
}
|
||||
|
||||
if (!selectedBoard()) {
|
||||
console.error('No board selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const effectConfig: TestEffectConfig = {
|
||||
effect_type: pattern.effect_type,
|
||||
led_count: ledCount(),
|
||||
led_type: ledType(),
|
||||
speed: 1.0 / (animationSpeed() / 50) // Convert animation speed to effect speed
|
||||
};
|
||||
|
||||
// Start the test effect in Rust backend
|
||||
await invoke('start_led_test_effect', {
|
||||
boardAddress: `${selectedBoard()!.address}:${selectedBoard()!.port}`,
|
||||
effectConfig: effectConfig,
|
||||
updateIntervalMs: animationSpeed()
|
||||
});
|
||||
|
||||
setCurrentPattern(pattern);
|
||||
setIsRunning(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to start test effect:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopTest = async () => {
|
||||
if (!selectedBoard()) {
|
||||
setIsRunning(false);
|
||||
setCurrentPattern(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop the test effect in Rust backend
|
||||
await invoke('stop_led_test_effect', {
|
||||
boardAddress: `${selectedBoard()!.address}:${selectedBoard()!.port}`,
|
||||
ledCount: ledCount(),
|
||||
ledType: ledType()
|
||||
});
|
||||
|
||||
// Only update UI state after successful backend call
|
||||
setIsRunning(false);
|
||||
setCurrentPattern(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop test effect:', error);
|
||||
// Still update UI state even if backend call fails
|
||||
setIsRunning(false);
|
||||
setCurrentPattern(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div class="container mx-auto p-6 space-y-6">
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">LED Strip Testing</h2>
|
||||
|
||||
{/* Hardware Selection */}
|
||||
<div class="form-control w-full max-w-xs">
|
||||
<label class="label">
|
||||
<span class="label-text">Select Hardware Board</span>
|
||||
<span class="label-text-alt">
|
||||
{boards().length > 0 ? `${boards().length} device(s) found` : 'Searching...'}
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full max-w-xs"
|
||||
value={selectedBoard()?.host || ''}
|
||||
onChange={(e) => {
|
||||
const board = boards().find(b => b.host === e.target.value);
|
||||
setSelectedBoard(board || null);
|
||||
}}
|
||||
>
|
||||
<option disabled value="">
|
||||
{boards().length > 0 ? 'Choose a board' : 'No boards found'}
|
||||
</option>
|
||||
<For each={boards()}>
|
||||
{(board) => {
|
||||
const getStatusIcon = (status: BoardInfo['connect_status']) => {
|
||||
if (status === 'Connected') return '🟢';
|
||||
if (typeof status === 'object' && 'Connecting' in status) return '🟡';
|
||||
return '🔴';
|
||||
};
|
||||
|
||||
const getStatusText = (status: BoardInfo['connect_status']) => {
|
||||
if (status === 'Connected') return 'Connected';
|
||||
if (typeof status === 'object' && 'Connecting' in status) return 'Connecting';
|
||||
return 'Disconnected';
|
||||
};
|
||||
|
||||
return (
|
||||
<option value={board.host}>
|
||||
{getStatusIcon(board.connect_status)} {board.host} ({board.address}:{board.port}) - {getStatusText(board.connect_status)}
|
||||
</option>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* LED Configuration */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">LED Count</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-bordered w-full text-center text-lg"
|
||||
value={ledCount()}
|
||||
min="1"
|
||||
max="1000"
|
||||
onInput={(e) => setLedCount(parseInt(e.target.value) || 60)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">LED Type</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
value={ledType()}
|
||||
onChange={(e) => setLedType(e.target.value as 'RGB' | 'RGBW')}
|
||||
>
|
||||
<option value="RGB">RGB</option>
|
||||
<option value="RGBW">RGBW</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Animation Speed (ms)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-bordered w-full text-center"
|
||||
value={animationSpeed()}
|
||||
min="16"
|
||||
max="200"
|
||||
step="1"
|
||||
onInput={(e) => setAnimationSpeed(parseInt(e.target.value) || 33)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Patterns */}
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-xl mb-4">Test Patterns</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<For each={testPatterns}>
|
||||
{(pattern) => (
|
||||
<div class="card bg-base-100 shadow-md">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-lg">{pattern.name}</h4>
|
||||
<p class="text-sm opacity-70 mb-4">{pattern.description}</p>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<Show
|
||||
when={currentPattern() === pattern && isRunning()}
|
||||
fallback={
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onClick={() => startTest(pattern)}
|
||||
disabled={!selectedBoard()}
|
||||
>
|
||||
Start Test
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
class="btn btn-error"
|
||||
onClick={() => stopTest()}
|
||||
>
|
||||
Stop Test
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={isRunning()}>
|
||||
<div class="alert alert-info mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>Test pattern "{currentPattern()?.name}" is running on {selectedBoard()?.host}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!selectedBoard()}>
|
||||
<div class="alert alert-warning mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span>Please select a hardware board to start testing</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -14,7 +14,7 @@ export const ColorSlider: Component<Props> = (props) => {
|
||||
step={0.01}
|
||||
value={props.value}
|
||||
class={
|
||||
'w-full h-2 bg-gradient-to-r rounded-lg appearance-none cursor-pointer dark:bg-gray-700 drop-shadow ' +
|
||||
'range range-primary w-full bg-gradient-to-r ' +
|
||||
props.class
|
||||
}
|
||||
/>
|
||||
|
@ -1,30 +1,106 @@
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { Component, createEffect, onCleanup } from 'solid-js';
|
||||
import { Component, createEffect, onCleanup, createSignal } from 'solid-js';
|
||||
import { ColorCalibration, LedStripConfigContainer } from '../../models/led-strip-config';
|
||||
import { ledStripStore, setLedStripStore } from '../../stores/led-strip.store';
|
||||
import { ColorSlider } from './color-slider';
|
||||
import { TestColorsBg } from './test-colors-bg';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { VsClose } from 'solid-icons/vs';
|
||||
import { BiRegularReset } from 'solid-icons/bi';
|
||||
import { BsFullscreen, BsFullscreenExit } from 'solid-icons/bs';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import transparentBg from '../../assets/transparent-grid-background.svg?url';
|
||||
|
||||
const Value: Component<{ value: number }> = (props) => {
|
||||
return (
|
||||
<span class="w-10 text-sm block font-mono text-right ">
|
||||
{(props.value * 100).toFixed(0)}
|
||||
<span class="text-xs text-stone-600">%</span>
|
||||
</span>
|
||||
<div class="badge badge-outline badge-sm font-mono">
|
||||
{(props.value * 100).toFixed(0)}%
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WhiteBalance = () => {
|
||||
const [isFullscreen, setIsFullscreen] = createSignal(false);
|
||||
const [panelPosition, setPanelPosition] = createSignal({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const [dragOffset, setDragOffset] = createSignal({ x: 0, y: 0 });
|
||||
|
||||
// 自动进入全屏模式
|
||||
createEffect(() => {
|
||||
const autoEnterFullscreen = async () => {
|
||||
try {
|
||||
const window = getCurrentWindow();
|
||||
const currentFullscreen = await window.isFullscreen();
|
||||
if (!currentFullscreen) {
|
||||
await window.setFullscreen(true);
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
setIsFullscreen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle 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,
|
||||
@ -32,41 +108,117 @@ export const WhiteBalance = () => {
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unlisten.then((unlisten) => unlisten());
|
||||
onCleanup(async () => {
|
||||
(await unlisten)();
|
||||
});
|
||||
});
|
||||
|
||||
const updateColorCalibration = (field: keyof ColorCalibration, value: number) => {
|
||||
const calibration = { ...ledStripStore.colorCalibration, [field]: value };
|
||||
invoke('set_color_calibration', {
|
||||
calibration,
|
||||
}).catch((error) => console.log(error));
|
||||
const updateColorCalibration = (
|
||||
key: keyof ColorCalibration,
|
||||
value: number,
|
||||
) => {
|
||||
const calibration = { ...ledStripStore.colorCalibration };
|
||||
calibration[key] = value;
|
||||
setLedStripStore('colorCalibration', calibration);
|
||||
invoke('set_color_calibration', { calibration }).catch(() => {
|
||||
// Silently handle 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) {
|
||||
// Silently handle 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));
|
||||
}).catch(() => {
|
||||
// Silently handle error
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section class="select-none text-stone-800">
|
||||
<>
|
||||
{/* 普通模式 */}
|
||||
{!isFullscreen() && (
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-base-content">白平衡调节</h1>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline btn-sm" onClick={toggleFullscreen} title="进入全屏">
|
||||
<BsFullscreen size={16} />
|
||||
全屏
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" onClick={reset} title="重置到100%">
|
||||
<BiRegularReset size={16} />
|
||||
重置
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onClick={exit} title="返回">
|
||||
<VsClose size={16} />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 颜色测试区域 */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>颜色测试</span>
|
||||
<div class="badge badge-info badge-outline">点击测试</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 bottom-0"
|
||||
class="aspect-square rounded-lg overflow-hidden border border-base-300"
|
||||
style={{
|
||||
'background-image': `url(${transparentBg})`,
|
||||
}}
|
||||
>
|
||||
<TestColorsBg />
|
||||
</div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10/12 max-w-lg bg-stone-100/20 backdrop-blur p-5 rounded-xl shadow-lg">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">R:</span>
|
||||
<div class="text-xs text-base-content/50 mt-2">
|
||||
💡 提示:点击颜色块进行单色测试,再次点击返回多色模式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 白平衡控制面板 */}
|
||||
<div class="card bg-base-200 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3">
|
||||
<span>RGB调节</span>
|
||||
<div class="badge badge-secondary badge-outline">实时调节</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-red-500">红色 (R)</span>
|
||||
<Value value={ledStripStore.colorCalibration.r} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-cyan-500 to-red-500"
|
||||
value={ledStripStore.colorCalibration.r}
|
||||
@ -77,10 +229,13 @@ export const WhiteBalance = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Value value={ledStripStore.colorCalibration.r} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-green-500">绿色 (G)</span>
|
||||
<Value value={ledStripStore.colorCalibration.g} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">G:</span>
|
||||
<ColorSlider
|
||||
class="from-pink-500 to-green-500"
|
||||
value={ledStripStore.colorCalibration.g}
|
||||
@ -91,10 +246,13 @@ export const WhiteBalance = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Value value={ledStripStore.colorCalibration.g} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-blue-500">蓝色 (B)</span>
|
||||
<Value value={ledStripStore.colorCalibration.b} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">B:</span>
|
||||
<ColorSlider
|
||||
class="from-yellow-500 to-blue-500"
|
||||
value={ledStripStore.colorCalibration.b}
|
||||
@ -105,27 +263,197 @@ export const WhiteBalance = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Value value={ledStripStore.colorCalibration.b} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-amber-500">白色 (W)</span>
|
||||
<Value value={ledStripStore.colorCalibration.w} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="w-3 block">W:</span>
|
||||
<ColorSlider class="from-yellow-50 to-cyan-50" />
|
||||
</label>
|
||||
<button
|
||||
class="absolute -right-4 -top-4 rounded-full aspect-square bg-stone-100/20 backdrop-blur p-1 shadow hover:bg-stone-200/20 active:bg-stone-300"
|
||||
onClick={exit}
|
||||
title="Go Back"
|
||||
<ColorSlider
|
||||
class="from-amber-100 to-amber-50"
|
||||
value={ledStripStore.colorCalibration.w}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'w',
|
||||
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 - 可展开 */}
|
||||
<div class="collapse collapse-arrow bg-base-100 mt-4">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title text-sm font-medium text-base-content/80">
|
||||
💡 白平衡调节使用说明
|
||||
</div>
|
||||
<div class="collapse-content text-xs text-base-content/70 space-y-3">
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold text-primary">🎯 推荐使用方法:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>点击上方"全屏"按钮进入全屏模式</li>
|
||||
<li>全屏模式下屏幕边缘会显示彩色条带</li>
|
||||
<li>将RGB控制面板拖拽到合适位置</li>
|
||||
<li>对比LED灯条颜色与屏幕边缘颜色</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold text-secondary">🔧 调节技巧:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li><span class="text-red-500 font-medium">红色偏强</span>:降低R值,LED会减少红色成分</li>
|
||||
<li><span class="text-green-500 font-medium">绿色偏强</span>:降低G值,LED会减少绿色成分</li>
|
||||
<li><span class="text-blue-500 font-medium">蓝色偏强</span>:降低B值,LED会减少蓝色成分</li>
|
||||
<li><span class="text-base-content font-medium">白色发黄</span>:适当提高B值,降低R/G值</li>
|
||||
<li><span class="text-base-content font-medium">白色发蓝</span>:适当降低B值,提高R/G值</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold text-accent">📋 对比方法:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>重点观察白色区域,确保LED白光与屏幕白色一致</li>
|
||||
<li>检查彩色区域,确保LED颜色饱和度合适</li>
|
||||
<li>在不同环境光下测试,确保效果稳定</li>
|
||||
<li>调节完成后可点击"重置"按钮恢复默认值</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全屏模式 */}
|
||||
{isFullscreen() && (
|
||||
<div class="fixed inset-0 w-screen h-screen bg-black z-50">
|
||||
{/* 全屏颜色测试区域 - 紧贴边缘 */}
|
||||
<div class="absolute inset-0 w-full h-full">
|
||||
<TestColorsBg />
|
||||
</div>
|
||||
|
||||
{/* 可拖拽的RGB控制面板 */}
|
||||
<div
|
||||
class="fixed w-80 bg-base-200/95 backdrop-blur-sm rounded-lg shadow-xl z-60 cursor-move select-none"
|
||||
style={{
|
||||
left: `${panelPosition().x}px`,
|
||||
top: `${panelPosition().y}px`,
|
||||
transform: 'none'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<VsClose size={24} />
|
||||
</button>
|
||||
<button
|
||||
class="absolute -right-4 -bottom-4 rounded-full aspect-square bg-stone-100/20 backdrop-blur p-1 shadow hover:bg-stone-200/20 active:bg-stone-300"
|
||||
onClick={reset}
|
||||
title="Reset to 100%"
|
||||
>
|
||||
<BiRegularReset size={24} />
|
||||
<div class="card-body p-4">
|
||||
<div class="card-title text-base mb-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs opacity-60">⋮⋮</span>
|
||||
<span>RGB调节</span>
|
||||
<div class="badge badge-secondary badge-outline">可拖拽</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" onClick={toggleFullscreen} title="退出全屏">
|
||||
<BsFullscreenExit size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-red-500">红色 (R)</span>
|
||||
<Value value={ledStripStore.colorCalibration.r} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-cyan-500 to-red-500"
|
||||
value={ledStripStore.colorCalibration.r}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'r',
|
||||
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-green-500">绿色 (G)</span>
|
||||
<Value value={ledStripStore.colorCalibration.g} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-pink-500 to-green-500"
|
||||
value={ledStripStore.colorCalibration.g}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'g',
|
||||
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-blue-500">蓝色 (B)</span>
|
||||
<Value value={ledStripStore.colorCalibration.b} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-yellow-500 to-blue-500"
|
||||
value={ledStripStore.colorCalibration.b}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'b',
|
||||
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-amber-500">白色 (W)</span>
|
||||
<Value value={ledStripStore.colorCalibration.w} />
|
||||
</label>
|
||||
<ColorSlider
|
||||
class="from-amber-100 to-amber-50"
|
||||
value={ledStripStore.colorCalibration.w}
|
||||
onInput={(ev) =>
|
||||
updateColorCalibration(
|
||||
'w',
|
||||
(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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -7,9 +7,14 @@ export type LedStripConfigurationContextType = [
|
||||
displayId: number;
|
||||
border: Borders;
|
||||
} | null;
|
||||
hoveredStripPart: {
|
||||
displayId: number;
|
||||
border: Borders;
|
||||
} | null;
|
||||
},
|
||||
{
|
||||
setSelectedStripPart: (v: { displayId: number; border: Borders } | null) => void;
|
||||
setHoveredStripPart: (v: { displayId: number; border: Borders } | null) => void;
|
||||
},
|
||||
];
|
||||
|
||||
@ -17,8 +22,10 @@ export const LedStripConfigurationContext =
|
||||
createContext<LedStripConfigurationContextType>([
|
||||
{
|
||||
selectedStripPart: null,
|
||||
hoveredStripPart: null,
|
||||
},
|
||||
{
|
||||
setSelectedStripPart: () => {},
|
||||
setHoveredStripPart: () => { },
|
||||
},
|
||||
]);
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { Borders } from '../constants/border';
|
||||
|
||||
export enum LedType {
|
||||
RGB = 'RGB',
|
||||
RGBW = 'RGBW',
|
||||
}
|
||||
|
||||
export type LedStripPixelMapper = {
|
||||
start: number;
|
||||
end: number;
|
||||
@ -10,6 +15,7 @@ export class ColorCalibration {
|
||||
r: number = 1;
|
||||
g: number = 1;
|
||||
b: number = 1;
|
||||
w: number = 1;
|
||||
}
|
||||
|
||||
export type LedStripConfigContainer = {
|
||||
@ -23,5 +29,6 @@ export class LedStripConfig {
|
||||
public readonly display_id: number,
|
||||
public readonly border: Borders,
|
||||
public len: number,
|
||||
public led_type: LedType = LedType.RGB,
|
||||
) {}
|
||||
}
|
||||
|
@ -1,3 +1,2 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
@ -1,9 +1,20 @@
|
||||
import daisyui from 'daisyui';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
mode: 'jit',
|
||||
export default {
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [daisyui],
|
||||
daisyui: {
|
||||
themes: ["dark", "light"],
|
||||
darkTheme: "dark",
|
||||
base: true,
|
||||
styled: true,
|
||||
utils: true,
|
||||
prefix: "",
|
||||
logs: true,
|
||||
themeRoot: ":root",
|
||||
},
|
||||
};
|
||||
|
@ -6,8 +6,14 @@ const mobile =
|
||||
process.env.TAURI_PLATFORM === "ios";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [solidPlugin()],
|
||||
export default defineConfig(async () => {
|
||||
const tailwindcss = (await import("@tailwindcss/vite")).default;
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
solidPlugin(),
|
||||
tailwindcss(),
|
||||
],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
// prevent vite from obscuring rust errors
|
||||
@ -28,4 +34,5 @@ export default defineConfig(async () => ({
|
||||
// produce sourcemaps for debug builds
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|