Compare commits

...

No commits in common. "master" and "AVFoundation" have entirely different histories.

106 changed files with 7022 additions and 6100 deletions

View File

@ -1,7 +0,0 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
node_modules/*
src-tauri

View File

@ -1,52 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
env: {
browser: true,
amd: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:jsx-a11y/recommended',
'plugin:prettier/recommended', // Make sure this is always the last element in the array.
],
plugins: ['simple-import-sort', 'prettier'],
rules: {
'prettier/prettier': ['error', {}, { usePrettierrc: true }],
'react/react-in-jsx-scope': 'off',
'jsx-a11y/accessible-emoji': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'jsx-a11y/anchor-is-valid': [
'error',
{
components: ['Link'],
specialLink: ['hrefLeft', 'hrefRight'],
aspects: ['invalidHref', 'preferButton'],
},
],
},
};

21
.gitignore vendored
View File

@ -1,24 +1,3 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

35
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,35 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Tauri Development Debug",
"cargo": {
"args": [
"build",
"--manifest-path=./src-tauri/Cargo.toml",
"--no-default-features"
]
},
// task for the `beforeDevCommand` if used, must be configured in `.vscode/tasks.json`
"preLaunchTask": "ui:dev"
},
{
"type": "lldb",
"request": "launch",
"name": "Tauri Production Debug",
"cargo": {
"args": [
"build",
"--release",
"--manifest-path=./src-tauri/Cargo.toml"
]
},
// task for the `beforeBuildCommand` if used, must be configured in `.vscode/tasks.json`
"preLaunchTask": "ui:build"
}
]

11
.vscode/settings.json vendored Normal file
View File

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

42
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,42 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "dev",
"type": "shell",
"isBackground": true,
"command": "pnpm",
"args": [
"tauri",
"dev"
],
"problemMatcher": [
"$eslint-stylish"
],
"options": {
"env": {
"RUST_LOG": "info"
}
}
},
{
"label": "ui:dev",
"type": "shell",
"isBackground": true,
"command": "pnpm",
"args": [
"dev"
]
},
{
"label": "ui:build",
"type": "shell",
"command": "pnpm",
"args": [
"build"
]
}
]
}

View File

@ -1,6 +1,6 @@
# Tauri + React + Typescript
# Tauri + Solid + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
This template should help get you started developing with Tauri, Solid and Typescript in Vite.
## Recommended IDE Setup

View File

@ -1,5 +0,0 @@
module.exports = {
twin: {
preset: 'emotion',
},
};

View File

@ -1,14 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + TS</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<title>Tauri + Solid + Typescript App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View File

@ -1,54 +1,33 @@
{
"name": "display-ambient-light-desktop",
"private": true,
"name": "test-demo",
"version": "0.0.0",
"type": "module",
"description": "",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"build": "vite build",
"serve": "vite preview",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-regular-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@mui/material": "^5.11.4",
"@tauri-apps/api": "^1.2.0",
"clsx": "^1.2.1",
"@solidjs/router": "^0.8.2",
"@tauri-apps/api": "^1.3.0",
"debug": "^4.3.4",
"notistack": "^2.0.8",
"ramda": "^0.28.0",
"react": "^18.2.0",
"react-async-hook": "^4.0.0",
"react-dom": "^18.2.0"
"solid-icons": "^1.0.8",
"solid-js": "^1.7.6",
"solid-tippy": "^0.2.1",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@babel/plugin-transform-react-jsx": "^7.20.7",
"@emotion/babel-plugin-jsx-pragmatic": "^0.2.0",
"@emotion/serialize": "^1.1.1",
"@tauri-apps/cli": "^1.2.2",
"@types/debug": "^4.1.7",
"@types/node": "^18.11.18",
"@types/ramda": "^0.28.20",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^2.2.0",
"autoprefixer": "^10.4.13",
"babel-plugin-macros": "^3.1.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^8.0.0",
"postcss": "^8.4.21",
"prettier": "^2.8.3",
"tailwindcss": "^3.2.4",
"twin.macro": "^3.1.0",
"typescript": "^4.9.4",
"vite": "^3.2.5"
"@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",
"typescript": "^4.9.5",
"vite": "^4.3.9",
"vite-plugin-solid": "^2.7.0"
}
}

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

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

2719
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,45 @@
[package]
name = "display-ambient-light-desktop"
name = "test-demo"
version = "0.0.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.57"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.1", features = [] }
tauri-build = { version = "1.2", features = [] }
[dependencies]
tauri = { version = "1.2", features = ["shell-open"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
tauri = { version = "1.1", features = ["api-all"] }
scrap = "0.5"
bmp = "0.5.0"
webp = "0.2.2"
base64 = "0.13.1"
anyhow = "1.0.66"
once_cell = "1.16.0"
core-graphics = "0.22.3"
display-info = "0.4.1"
anyhow = "1.0.69"
tokio = {version = "1.26.0", features = ["full"] }
paris = { version = "1.5", features = ["timestamps", "macros"] }
tokio = { version = "1.22.0", features = ["full"] }
tracing = "0.1.37"
tracing-subscriber = "0.3.16"
hex = "0.4.3"
rumqttc = "0.17.0"
time = { version = "0.3.17", features = ["formatting"] }
log = "0.4.17"
env_logger = "0.10.0"
percent-encoding = "2.2.0"
url-build-parse = "9.0.0"
color_space = "0.5.3"
futures = "0.3.25"
either = "1.8.0"
image = "0.24.5"
mdns = "3.0.0"
macos-app-nap = "0.0.1"
hex = "0.4.3"
toml = "0.7.3"
paho-mqtt = "0.12.1"
time = {version="0.3.20", features= ["formatting"] }
itertools = "0.10.5"
core-foundation = "0.9.3"
tokio-stream = "0.1.14"
mdns-sd = "0.7.2"
futures = "0.3.28"
ddc-hi = "0.4.1"
coreaudio-rs = "0.11.2"
rust_swift_screencapture = { version = "0.1.1", path = "../../../../demo/rust-swift-screencapture" }
[features]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = [ "custom-protocol" ]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
[dev-dependencies]
test_dir = "0.2.0"

View File

@ -0,0 +1,161 @@
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;
const CONFIG_FILE_NAME: &str = "cc.ivanli.ambient_light/led_strip_config.toml";
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq)]
pub enum Border {
Top,
Bottom,
Left,
Right,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct LedStripConfig {
pub index: usize,
pub border: Border,
pub display_id: u32,
pub start_pos: usize,
pub len: usize,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct ColorCalibration {
r: f32,
g: f32,
b: f32,
}
impl ColorCalibration {
pub fn to_bytes(&self) -> [u8; 3] {
[
(self.r * 255.0) as u8,
(self.g * 255.0) as u8,
(self.b * 255.0) as u8,
]
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct LedStripConfigGroup {
pub strips: Vec<LedStripConfig>,
pub mappers: Vec<SamplePointMapper>,
pub color_calibration: ColorCalibration,
}
impl LedStripConfigGroup {
pub async fn read_config() -> anyhow::Result<Self> {
let displays = DisplayInfo::all()?;
// config path
let path = config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
let exists = tokio::fs::try_exists(path.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to check config file exists: {}", e))?;
if exists {
let config = tokio::fs::read_to_string(path).await?;
let mut config: LedStripConfigGroup = toml::from_str(&config)
.map_err(|e| anyhow::anyhow!("Failed to parse config file: {}", e))?;
for strip in config.strips.iter_mut() {
strip.display_id = displays[strip.index / 4].id;
}
// log::info!("config loaded: {:?}", config);
Ok(config)
} else {
info!("config file not exist, fallback to default config");
Ok(Self::get_default_config().await?)
}
}
pub async fn write_config(configs: &Self) -> anyhow::Result<()> {
let path = config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
tokio::fs::create_dir_all(path.parent().unwrap()).await?;
let config_text = toml::to_string(&configs).map_err(|e| {
anyhow::anyhow!("Failed to parse config file: {}. configs: {:?}", e, configs)
})?;
tokio::fs::write(&path, config_text).await.map_err(|e| {
anyhow::anyhow!("Failed to write config file: {}. path: {:?}", e, &path)
})?;
Ok(())
}
pub async fn get_default_config() -> anyhow::Result<Self> {
let displays = display_info::DisplayInfo::all().map_err(|e| {
error!("can not list display info: {}", e);
anyhow::anyhow!("can not list display info: {}", e)
})?;
let mut strips = Vec::new();
let mut mappers = Vec::new();
for (i, display) in displays.iter().enumerate() {
let mut configs = Vec::new();
for j in 0..4 {
let item = LedStripConfig {
index: j + i * 4,
display_id: display.id,
border: match j {
0 => Border::Top,
1 => Border::Bottom,
2 => Border::Left,
3 => Border::Right,
_ => unreachable!(),
},
start_pos: j + i * 4 * 30,
len: 30,
};
configs.push(item);
strips.push(item);
mappers.push(SamplePointMapper {
start: (j + i * 4) * 30,
end: (j + i * 4 + 1) * 30,
pos: (j + i * 4) * 30,
})
}
}
let color_calibration = ColorCalibration {
r: 1.0,
g: 1.0,
b: 1.0,
};
Ok(Self {
strips,
mappers,
color_calibration,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamplePointMapper {
pub start: usize,
pub end: usize,
pub pos: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamplePointConfig {
pub display_id: u32,
pub points: Vec<LedSamplePoints>,
}

View File

@ -0,0 +1,239 @@
use std::{borrow::BorrowMut, sync::Arc};
use tauri::async_runtime::RwLock;
use tokio::{sync::OnceCell, task::yield_now};
use crate::ambient_light::{config, LedStripConfigGroup};
use super::{Border, SamplePointMapper, ColorCalibration};
pub struct ConfigManager {
config: Arc<RwLock<LedStripConfigGroup>>,
config_update_sender: tokio::sync::watch::Sender<LedStripConfigGroup>,
}
impl ConfigManager {
pub async fn global() -> &'static Self {
static CONFIG_MANAGER_GLOBAL: OnceCell<ConfigManager> = OnceCell::const_new();
CONFIG_MANAGER_GLOBAL
.get_or_init(|| async {
let configs = LedStripConfigGroup::read_config().await.unwrap();
let (config_update_sender, config_update_receiver) =
tokio::sync::watch::channel(configs.clone());
if let Err(err) = config_update_sender.send(configs.clone()) {
log::error!("Failed to send config update when read config first time: {}", err);
}
drop(config_update_receiver);
ConfigManager {
config: Arc::new(RwLock::new(configs)),
config_update_sender,
}
})
.await
}
pub async fn reload(&self) -> anyhow::Result<()> {
let mut configs = self.config.write().await;
*configs = LedStripConfigGroup::read_config().await?;
Ok(())
}
pub async fn update(&self, configs: &LedStripConfigGroup) -> anyhow::Result<()> {
LedStripConfigGroup::write_config(configs).await?;
self.reload().await?;
self.config_update_sender
.send(configs.clone())
.map_err(|e| anyhow::anyhow!("Failed to send config update: {}", e))?;
yield_now().await;
log::debug!("config updated: {:?}", configs);
Ok(())
}
pub async fn configs(&self) -> LedStripConfigGroup {
self.config.read().await.clone()
}
pub async fn patch_led_strip_len(
&self,
display_id: u32,
border: Border,
delta_len: i8,
) -> 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 {
let target = strip.len as i64 + delta_len as i64;
if target < 0 || target > 1000 {
return Err(anyhow::anyhow!(
"Overflow. range: 0-1000, current: {}",
target
));
}
strip.len = target as usize;
}
}
Self::rebuild_mappers(&mut config);
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,
border: Border,
target_start: usize,
) -> anyhow::Result<()> {
let mut config = self.config.write().await;
for (index, strip) in config.clone().strips.iter().enumerate() {
if strip.display_id == display_id && strip.border == border {
let mut mapper = config.mappers[index].borrow_mut();
if target_start == mapper.start {
return Ok(());
}
let target_end = mapper.end + target_start - mapper.start;
if target_start > 1000 || target_end > 1000 {
return Err(anyhow::anyhow!(
"Overflow. range: 0-1000, current: {}-{}",
target_start,
target_end
));
}
mapper.start = target_start as usize;
mapper.end = target_end as usize;
log::info!("mapper: {:?}", mapper);
}
}
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 reverse_led_strip_part(
&self,
display_id: u32,
border: Border,
) -> anyhow::Result<()> {
let mut config = self.config.write().await;
for (index, strip) in config.clone().strips.iter().enumerate() {
if strip.display_id == display_id && strip.border == border {
let mut mapper = config.mappers[index].borrow_mut();
let start = mapper.start;
mapper.start = mapper.end;
mapper.end = start;
}
}
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(())
}
fn rebuild_mappers(config: &mut LedStripConfigGroup) {
let mut prev_pos_end = 0;
let mappers: Vec<SamplePointMapper> = config
.strips
.iter()
.enumerate()
.map(|(index, strip)| {
let mapper = &config.mappers[index];
if mapper.start < mapper.end {
let mapper = SamplePointMapper {
start: mapper.start,
end: mapper.start + strip.len,
pos: prev_pos_end,
};
prev_pos_end = prev_pos_end + strip.len;
mapper
} else {
let mapper = SamplePointMapper {
end: mapper.end,
start: mapper.end + strip.len,
pos: prev_pos_end,
};
prev_pos_end = prev_pos_end + strip.len;
mapper
}
})
.collect();
config.mappers = mappers;
}
pub async fn set_items(&self, items: Vec<config::LedStripConfig>) -> anyhow::Result<()> {
let mut config = self.config.write().await;
config.strips = items;
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 fn clone_config_update_receiver(
&self,
) -> tokio::sync::watch::Receiver<LedStripConfigGroup> {
self.config_update_sender.subscribe()
}
pub async fn set_color_calibration(&self, color_calibration: ColorCalibration) -> anyhow::Result<()> {
let config = self.config.write().await;
let mut cloned_config = config.clone();
cloned_config.color_calibration = color_calibration;
drop(config);
self.update(&cloned_config).await
}
}

View File

@ -0,0 +1,7 @@
mod config;
mod config_manager;
mod publisher;
pub use config::*;
pub use config_manager::*;
pub use publisher::*;

View File

@ -0,0 +1,443 @@
use std::{borrow::Borrow, collections::HashMap, sync::Arc, time::Duration};
use paris::warn;
use tauri::async_runtime::RwLock;
use tokio::{
net::UdpSocket,
sync::{broadcast, watch},
time::sleep,
};
use crate::{
ambient_light::{config, ConfigManager},
led_color::LedColor,
rpc::UdpRpc,
screenshot::{self, LedSamplePoints},
screenshot_manager::{self, ScreenshotManager},
};
use itertools::Itertools;
use super::{LedStripConfigGroup, SamplePointMapper};
pub struct LedColorsPublisher {
sorted_colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
sorted_colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
inner_tasks_version: Arc<RwLock<usize>>,
}
impl LedColorsPublisher {
pub async fn global() -> &'static Self {
static LED_COLORS_PUBLISHER_GLOBAL: tokio::sync::OnceCell<LedColorsPublisher> =
tokio::sync::OnceCell::const_new();
let (sorted_tx, sorted_rx) = watch::channel(Vec::new());
let (tx, rx) = watch::channel(Vec::new());
LED_COLORS_PUBLISHER_GLOBAL
.get_or_init(|| async {
LedColorsPublisher {
sorted_colors_rx: Arc::new(RwLock::new(sorted_rx)),
sorted_colors_tx: Arc::new(RwLock::new(sorted_tx)),
colors_rx: Arc::new(RwLock::new(rx)),
colors_tx: Arc::new(RwLock::new(tx)),
inner_tasks_version: Arc::new(RwLock::new(0)),
}
})
.await
}
async fn start_one_display_colors_fetcher(
&self,
display_id: u32,
sample_points: Vec<LedSamplePoints>,
bound_scale_factor: f32,
mappers: Vec<SamplePointMapper>,
display_colors_tx: broadcast::Sender<(u32, Vec<u8>)>,
) {
let internal_tasks_version = self.inner_tasks_version.clone();
let screenshot_manager = ScreenshotManager::global().await;
let screenshot_rx = screenshot_manager.subscribe_by_display_id(display_id).await;
if let Err(err) = screenshot_rx {
log::error!("{}", err);
return;
}
let mut screenshot_rx = screenshot_rx.unwrap();
tokio::spawn(async move {
let init_version = internal_tasks_version.read().await.clone();
while screenshot_rx.changed().await.is_ok() {
let screenshot = screenshot_rx.borrow().clone();
let colors = screenshot.get_colors_by_sample_points(&sample_points).await;
let colors_copy = colors.clone();
let mappers = mappers.clone();
match Self::send_colors_by_display(colors, mappers).await {
Ok(_) => {
// log::info!("sent colors: #{: >15}", display_id);
}
Err(err) => {
warn!("Failed to send colors: #{: >15}\t{}", display_id, err);
}
}
// 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();
if version != init_version {
log::info!(
"inner task version changed, stop. {} != {}",
internal_tasks_version.read().await.clone(),
init_version
);
break;
}
}
});
}
fn start_all_colors_worker(
&self,
display_ids: Vec<u32>,
mappers: Vec<SamplePointMapper>,
mut display_colors_rx: broadcast::Receiver<(u32, Vec<u8>)>,
) {
let sorted_colors_tx = self.sorted_colors_tx.clone();
let colors_tx = self.colors_tx.clone();
log::debug!("start all_colors_worker");
tokio::spawn(async move {
for _ in 0..10 {
let sorted_colors_tx = sorted_colors_tx.write().await;
let colors_tx = colors_tx.write().await;
let mut all_colors: Vec<Option<Vec<u8>>> = vec![None; display_ids.len()];
let mut start: tokio::time::Instant = tokio::time::Instant::now();
log::debug!("start all_colors_worker task");
loop {
let color_info = display_colors_rx.recv().await;
if let Err(err) = color_info {
match err {
broadcast::error::RecvError::Closed => {
return;
}
broadcast::error::RecvError::Lagged(_) => {
warn!("display_colors_rx lagged");
continue;
}
}
}
let (display_id, colors) = color_info.unwrap();
let index = display_ids.iter().position(|id| *id == display_id);
if index.is_none() {
warn!("display id not found");
continue;
}
all_colors[index.unwrap()] = Some(colors);
if all_colors.iter().all(|color| color.is_some()) {
let flatten_colors = all_colors
.clone()
.into_iter()
.flat_map(|c| c.unwrap())
.collect::<Vec<_>>();
match colors_tx.send(flatten_colors.clone()) {
Ok(_) => {}
Err(err) => {
warn!("Failed to send colors: {}", err);
}
};
let sorted_colors =
ScreenshotManager::get_sorted_colors(&flatten_colors, &mappers);
match sorted_colors_tx.send(sorted_colors) {
Ok(_) => {}
Err(err) => {
warn!("Failed to send sorted colors: {}", err);
}
};
log::debug!("tick: {}ms", start.elapsed().as_millis());
start = tokio::time::Instant::now();
}
}
}
});
}
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();
let configs = config_receiver.borrow().clone();
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) {
let inner_tasks_version = self.inner_tasks_version.clone();
let configs = Self::get_colors_configs(&configs).await;
if let Err(err) = configs {
warn!("Failed to get configs: {}", err);
sleep(Duration::from_millis(100)).await;
return;
}
let configs = configs.unwrap();
let mut inner_tasks_version = inner_tasks_version.write().await;
*inner_tasks_version = inner_tasks_version.overflowing_add(1).0;
drop(inner_tasks_version);
let (display_colors_tx, display_colors_rx) = broadcast::channel::<(u32, Vec<u8>)>(8);
for sample_point_group in configs.sample_point_groups.clone() {
let display_id = sample_point_group.display_id;
let sample_points = sample_point_group.points;
let bound_scale_factor = sample_point_group.bound_scale_factor;
self.start_one_display_colors_fetcher(
display_id,
sample_points,
bound_scale_factor,
sample_point_group.mappers,
display_colors_tx.clone(),
)
.await;
}
let display_ids = configs.sample_point_groups;
self.start_all_colors_worker(
display_ids.iter().map(|c| c.display_id).collect(),
configs.mappers,
display_colors_rx,
);
}
pub async fn send_colors(offset: u16, mut payload: Vec<u8>) -> anyhow::Result<()> {
// let mqtt = MqttRpc::global().await;
// mqtt.publish_led_sub_pixels(payload).await;
let socket = UdpSocket::bind("0.0.0.0:8000").await?;
let mut buffer = vec![2];
buffer.push((offset >> 8) as u8);
buffer.push((offset & 0xff) as u8);
buffer.append(&mut payload);
socket.send_to(&buffer, "192.168.31.206:23042").await?;
Ok(())
}
pub async fn send_colors_by_display(
colors: Vec<LedColor>,
mappers: Vec<SamplePointMapper>,
) -> anyhow::Result<()> {
// let color_len = colors.len();
let display_led_offset = mappers
.clone()
.iter()
.flat_map(|mapper| [mapper.start, mapper.end])
.min()
.unwrap();
let udp_rpc = UdpRpc::global().await;
if let Err(err) = udp_rpc {
warn!("udp_rpc can not be initialized: {}", err);
}
let udp_rpc = udp_rpc.as_ref().unwrap();
// let socket = UdpSocket::bind("0.0.0.0:0").await?;
for group in mappers.clone() {
if (group.start.abs_diff(group.end)) > colors.len() {
return Err(anyhow::anyhow!(
"get_sorted_colors: color_index out of range. color_index: {}, strip len: {}, colors.len(): {}",
group.pos,
group.start.abs_diff(group.end),
colors.len()
));
}
let group_size = group.start.abs_diff(group.end);
let mut buffer = Vec::<u8>::with_capacity(group_size * 3);
if group.end > group.start {
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());
}
} 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());
}
}
let offset = group.start.min(group.end);
let mut tx_buffer = vec![2];
tx_buffer.push((offset >> 8) as u8);
tx_buffer.push((offset & 0xff) as u8);
tx_buffer.append(&mut buffer);
udp_rpc.send_to_all(&tx_buffer).await?;
}
Ok(())
}
pub async fn clone_sorted_colors_receiver(&self) -> watch::Receiver<Vec<u8>> {
self.sorted_colors_rx.read().await.clone()
}
pub async fn get_colors_configs(
configs: &LedStripConfigGroup,
) -> anyhow::Result<AllColorConfig> {
let screenshot_manager = ScreenshotManager::global().await;
let display_ids = configs
.strips
.iter()
.map(|c| c.display_id)
.unique()
.collect::<Vec<_>>();
let mappers = configs.mappers.clone();
let mut colors_configs = Vec::new();
let mut merged_screenshot_receiver = screenshot_manager.clone_merged_screenshot_rx().await;
merged_screenshot_receiver.resubscribe();
let mut screenshots = HashMap::new();
loop {
log::info!("waiting merged screenshot...");
let screenshot = merged_screenshot_receiver.recv().await;
if let Err(err) = screenshot {
match err {
tokio::sync::broadcast::error::RecvError::Closed => {
warn!("closed");
continue;
}
tokio::sync::broadcast::error::RecvError::Lagged(_) => {
warn!("lagged");
continue;
}
}
}
let screenshot = screenshot.unwrap();
// log::info!("got screenshot: {:?}", screenshot.display_id);
screenshots.insert(screenshot.display_id, screenshot);
if screenshots.len() == display_ids.len() {
let mut led_start = 0;
for display_id in display_ids {
let led_strip_configs = configs
.strips
.iter()
.enumerate()
.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()
.map(|(_, config)| screenshot.get_sample_points(&config))
.flatten()
.collect();
if points.len() == 0 {
warn!("no led strip config for display_id: {}", display_id);
continue;
}
let bound_scale_factor = screenshot.bound_scale_factor;
let led_end = led_start + points.iter().map(|p| p.len()).sum::<usize>();
let mappers = led_strip_configs.map(|(i, _)| mappers[i].clone()).collect();
let colors_config = DisplaySamplePointGroup {
display_id,
points,
bound_scale_factor,
mappers,
};
colors_configs.push(colors_config);
led_start = led_end;
}
log::debug!("got all colors configs: {:?}", colors_configs.len());
return Ok(AllColorConfig {
sample_point_groups: colors_configs,
mappers,
});
}
}
}
pub async fn clone_colors_receiver(&self) -> watch::Receiver<Vec<u8>> {
self.colors_rx.read().await.clone()
}
}
#[derive(Debug)]
pub struct AllColorConfig {
pub sample_point_groups: Vec<DisplaySamplePointGroup>,
pub mappers: Vec<config::SamplePointMapper>,
// pub screenshot_receivers: Vec<watch::Receiver<Screenshot>>,
}
#[derive(Debug, Clone)]
pub struct DisplaySamplePointGroup {
pub display_id: u32,
pub points: Vec<LedSamplePoints>,
pub bound_scale_factor: f32,
pub mappers: Vec<config::SamplePointMapper>,
}

View File

@ -1,238 +0,0 @@
use futures::future::join_all;
use once_cell::sync::OnceCell;
use paris::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use tauri::async_runtime::RwLock;
use tokio::{
sync::mpsc,
time::{sleep, Instant},
};
use crate::{
picker::{
self, config::DisplayConfig, display_picker::DisplayPicker, led_color::LedColor,
screenshot::Screenshot,
},
rpc,
};
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
pub enum AmbientLightMode {
None,
Follow,
Flowing,
}
pub struct CoreManager {
ambient_light_mode: Arc<RwLock<AmbientLightMode>>,
}
impl CoreManager {
pub fn global() -> &'static CoreManager {
static CORE_MANAGER: OnceCell<CoreManager> = OnceCell::new();
let core = CORE_MANAGER.get_or_init(|| CoreManager {
ambient_light_mode: Arc::new(RwLock::new(AmbientLightMode::None)),
});
core
}
pub async fn set_ambient_light(&self, target_mode: AmbientLightMode) {
let mut mode = self.ambient_light_mode.write().await;
*mode = target_mode;
drop(mode);
match target_mode {
AmbientLightMode::Flowing => self.play_flowing_light().await,
AmbientLightMode::None => {}
AmbientLightMode::Follow => match self.play_follow().await {
Ok(_) => {}
Err(error) => {
warn!("Can not following displays. {}", error);
}
},
};
}
pub async fn play_flowing_light(&self) {
let mut hue = 0f64;
let step_length = 2.0;
loop {
let lock = self.ambient_light_mode.read().await;
if let AmbientLightMode::Flowing = *lock {
let mut colors = Vec::<LedColor>::new();
for i in 0..60 {
let color =
LedColor::from_hsv((hue + i as f64 * step_length) % 360.0, 1.0, 0.5);
colors.push(color);
}
hue = (hue + 1.0) % 360.0;
match rpc::manager::Manager::global()
.publish_led_colors(&colors)
.await
{
Ok(_) => {}
Err(error) => {
warn!("publish led colors failed. {}", error);
}
}
} else {
break;
}
sleep(Duration::from_millis(50)).await;
}
}
pub async fn play_follow(&self) -> anyhow::Result<()> {
macos_app_nap::prevent();
let mut futs = vec![];
let configs = picker::config::Manager::global().reload_config().await;
let configs = match configs {
Ok(c) => c.display_configs,
Err(err) => anyhow::bail!("can not get display configs. {:?}", err),
};
info!("piker display configs: {:?}", configs);
let (tx, mut rx) = mpsc::channel(10);
for config in configs.clone() {
let tx = tx.clone();
let fut = tokio::spawn(async move {
match Self::follow_display_by_config(config, tx).await {
Ok(_) => {}
Err(error) => {
warn!("following failed. {}", error);
}
}
});
futs.push(fut);
}
let total_colors_count = configs
.iter()
.flat_map(|c| {
vec![
c.led_strip_of_borders.top,
c.led_strip_of_borders.bottom,
c.led_strip_of_borders.left,
c.led_strip_of_borders.right,
]
})
.flat_map(|l| match l {
Some(l) => (l.global_start_position.min(l.global_end_position)
..l.global_start_position.max(l.global_end_position))
.collect(),
None => {
vec![]
}
})
.collect::<HashSet<_>>()
.len();
tokio::spawn(async move {
let mut global_sub_pixels = HashMap::new();
while let Some(screenshot) = rx.recv().await {
let start_at = Instant::now();
let colors = screenshot.get_colors();
let config = screenshot.get_config();
for (colors, config) in vec![
(colors.top, config.led_strip_of_borders.top),
(colors.right, config.led_strip_of_borders.right),
(colors.bottom, config.led_strip_of_borders.bottom),
(colors.left, config.led_strip_of_borders.left),
] {
match config {
Some(config) => {
let (sign, start) =
if config.global_start_position <= config.global_end_position {
(1, config.global_start_position as isize * 3)
} else {
(-1, (config.global_start_position as isize + 1) * 3 - 1)
};
for (index, color) in colors.into_iter().enumerate() {
let pixel_index = index / 3;
let sub_pixel_index = index % 3;
let offset = if sign < 0 {
2 - sub_pixel_index
} else {
sub_pixel_index
};
let global_sub_pixel_index =
(sign * (pixel_index as isize * 3 + offset as isize) + start)
as usize;
global_sub_pixels.insert(global_sub_pixel_index, color);
}
}
None => {}
}
}
// info!(
// "led count: {}, spend: {:?}",
// global_sub_pixels.len(),
// start_at.elapsed()
// );
if global_sub_pixels.len() >= total_colors_count * 3 {
let mut colors = vec![];
for index in 0..global_sub_pixels.len() {
colors.push(*global_sub_pixels.get(&index).unwrap());
}
// info!("{:?}", colors);
global_sub_pixels = HashMap::new();
match rpc::manager::Manager::global()
.publish_led_sub_pixels(colors)
.await
{
Ok(_) => {
// info!("publish successful",);
}
Err(error) => {
warn!("publish led colors failed. {}", error);
}
}
}
}
});
join_all(futs).await;
Ok(())
}
async fn follow_display_by_config(
config: DisplayConfig,
tx: mpsc::Sender<Screenshot>,
) -> anyhow::Result<()> {
let mut picker = DisplayPicker::from_config(config)?;
info!("width: {}", picker.config.display_width);
loop {
let start = Instant::now();
let next_tick = start + Duration::from_millis(16);
let lock = Self::global().ambient_light_mode.read().await;
if let AmbientLightMode::Follow = *lock {
drop(lock);
let screenshot = picker.take_screenshot()?;
// info!("Take Screenshot Spend: {:?}", start.elapsed());
match tx.send(screenshot).await {
Ok(_) => {}
Err(err) => {
error!("send screenshot to main thread was failed. {:?}", err);
}
};
} else {
break;
}
tokio::time::sleep_until(next_tick).await;
}
Ok(())
}
}

View File

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

View File

@ -0,0 +1,13 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub enum Brightness {
Relative(i16),
Absolute(u16),
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct DisplayBrightness {
pub brightness: Brightness,
pub display_index: usize,
}

View File

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

View File

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

View File

@ -0,0 +1,282 @@
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 tokio::{
sync::{broadcast, watch, OnceCell, RwLock},
task::yield_now,
};
use crate::{
display::DisplayStateWrapper,
rpc::{BoardMessageChannels, DisplaySetting},
};
use super::{display_handler::DisplayHandler, display_state::DisplayState};
const CONFIG_FILE_NAME: &str = "cc.ivanli.ambient_light/displays.toml";
pub struct DisplayManager {
displays: Arc<RwLock<Vec<Arc<RwLock<DisplayHandler>>>>>,
setting_request_handler: Option<tokio::task::JoinHandle<()>>,
displays_changed_sender: Arc<watch::Sender<Vec<DisplayState>>>,
auto_save_state_handler: Option<tokio::task::JoinHandle<()>>,
}
impl DisplayManager {
pub async fn global() -> &'static Self {
static DISPLAY_MANAGER: OnceCell<DisplayManager> = OnceCell::const_new();
DISPLAY_MANAGER.get_or_init(|| Self::create()).await
}
pub async fn create() -> Self {
let (displays_changed_sender, _) = watch::channel(Vec::new());
let displays_changed_sender = Arc::new(displays_changed_sender);
let mut instance = Self {
displays: Arc::new(RwLock::new(Vec::new())),
setting_request_handler: None,
displays_changed_sender,
auto_save_state_handler: None,
};
instance.fetch_displays().await;
instance.restore_states().await;
instance.fetch_state_of_displays().await;
instance.subscribe_setting_request();
instance.auto_save_state_of_displays();
instance
}
fn auto_save_state_of_displays(&mut self) {
let displays = self.displays.clone();
let handler = tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(10)).await;
Self::save_states(displays.clone()).await;
Self::send_displays_changed(displays.clone()).await;
}
});
self.auto_save_state_handler = Some(handler);
}
async fn send_displays_changed(displays: Arc<RwLock<Vec<Arc<RwLock<DisplayHandler>>>>>) {
let mut states = Vec::new();
for display in displays.read().await.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
let channel = BoardMessageChannels::global().await;
let tx = channel.displays_changed_sender.clone();
if let Err(err) = tx.send(states) {
error!("Failed to send displays changed: {}", err);
}
}
async fn fetch_displays(&self) {
let mut displays = self.displays.write().await;
displays.clear();
let controllers = Display::enumerate();
for display in controllers {
let controller = Arc::new(RwLock::new(display));
let state = Arc::new(RwLock::new(DisplayState::default()));
let handler = DisplayHandler {
state: state.clone(),
controller: controller.clone(),
};
displays.push(Arc::new(RwLock::new(handler)));
}
}
async fn fetch_state_of_displays(&self) {
let displays = self.displays.read().await;
for display in displays.iter() {
let display = display.read().await;
display.fetch_state().await;
}
}
pub async fn get_displays(&self) -> Vec<DisplayState> {
let displays = self.displays.read().await;
let mut states = Vec::new();
for display in displays.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
states
}
fn subscribe_setting_request(&mut self) {
let displays = self.displays.clone();
let displays_changed_sender = self.displays_changed_sender.clone();
let handler = tokio::spawn(async move {
let channels = BoardMessageChannels::global().await;
let mut request_rx = channels.display_setting_request_sender.subscribe();
loop {
if let Err(err) = request_rx.recv().await {
match err {
broadcast::error::RecvError::Closed => {
info!("display setting request channel closed");
break;
}
broadcast::error::RecvError::Lagged(_) => {
warn!("display setting request channel lagged");
continue;
}
}
}
let message = request_rx.recv().await.unwrap();
let displays = displays.write().await;
let display = displays.get(message.display_index);
if display.is_none() {
warn!("display#{} not found", message.display_index);
continue;
}
let display = display.unwrap().write().await;
let result = match message.setting {
DisplaySetting::Brightness(value) => display.set_brightness(value as u16).await,
DisplaySetting::Contrast(value) => display.set_contrast(value as u16).await,
DisplaySetting::Mode(value) => display.set_mode(value as u16).await,
};
if let Err(err) = result {
error!("failed to set display setting: {}", err);
continue;
}
drop(display);
let mut states = Vec::new();
for display in displays.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
if let Err(err) = displays_changed_sender.send(states) {
error!("failed to send displays changed event: {}", err);
}
yield_now().await;
}
});
self.setting_request_handler = Some(handler);
}
async fn restore_states(&self) {
let path = config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
if !path.exists() {
log::info!("config file not found: {}. skip read.", path.display());
return;
}
let text = std::fs::read_to_string(path);
if let Err(err) = text {
log::error!("failed to read config file: {}", err);
return;
}
let text = text.unwrap();
let wrapper = toml::from_str::<DisplayStateWrapper>(&text);
if let Err(err) = wrapper {
log::error!("failed to parse display states file: {}", err);
return;
}
let states = wrapper.unwrap().states;
let displays = self.displays.read().await;
for (index, display) in displays.iter().enumerate() {
let display = display.read().await;
let mut state = display.state.write().await;
let saved = states.get(index);
if let Some(saved) = saved {
state.brightness = saved.brightness;
state.contrast = saved.contrast;
state.mode = saved.mode;
log::info!("restore display config. display#{}: {:?}", index, state);
}
}
log::info!(
"restore display config. store displays: {}, online displays: {}",
states.len(),
displays.len()
);
}
async fn save_states(displays: Arc<RwLock<Vec<Arc<RwLock<DisplayHandler>>>>>) {
let path = config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
let displays = displays.read().await;
let mut states = Vec::new();
for display in displays.iter() {
let state = display.read().await.state.read().await.clone();
states.push(state);
}
let wrapper = DisplayStateWrapper::new(states);
let text = toml::to_string(&wrapper);
if let Err(err) = text {
log::error!("failed to serialize display states: {}", err);
log::error!("display states: {:?}", &wrapper);
return;
}
let text = text.unwrap();
if path.exists() {
if let Err(err) = std::fs::remove_file(&path) {
log::error!("failed to remove old config file: {}", err);
return;
}
}
if let Err(err) = std::fs::write(&path, text) {
log::error!("failed to write config file: {}", err);
return;
}
log::debug!(
"save display config. store displays: {}, online displays: {}",
wrapper.states.len(),
displays.len()
);
}
pub fn subscribe_displays_changed(&self) -> watch::Receiver<Vec<DisplayState>> {
self.displays_changed_sender.subscribe()
}
}
impl Drop for DisplayManager {
fn drop(&mut self) {
log::info!("dropping display manager=============");
if let Some(handler) = self.setting_request_handler.take() {
handler.abort();
}
if let Some(handler) = self.auto_save_state_handler.take() {
handler.abort();
}
}
}

View File

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

View File

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

0
src-tauri/src/logger.rs Normal file
View File

View File

@ -1,103 +1,488 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#![feature(bool_to_option)]
// Prevents additional console window on WiOk(ndows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod core;
mod picker;
mod ambient_light;
mod display;
mod led_color;
mod rpc;
mod screenshot;
mod screenshot_manager;
mod volume;
use crate::core::AmbientLightMode;
use crate::core::CoreManager;
use paris::*;
use picker::config::DisplayConfig;
use picker::manager::Picker;
use picker::screenshot::ScreenshotDto;
use tauri::async_runtime::Mutex;
use once_cell::sync::OnceCell;
use ambient_light::{Border, ColorCalibration, LedStripConfig, LedStripConfigGroup};
use display::{DisplayManager, DisplayState};
use display_info::DisplayInfo;
use paris::{error, info, warn};
use rpc::{BoardInfo, UdpRpc};
use screenshot::Screenshot;
use screenshot_manager::ScreenshotManager;
use serde::{Deserialize, Serialize};
use serde_json::to_string;
use tauri::{http::ResponseBuilder, regex, Manager};
use volume::VolumeManager;
static GET_SCREENSHOT_LOCK: OnceCell<Mutex<bool>> = OnceCell::new();
#[derive(Serialize, Deserialize)]
#[serde(remote = "DisplayInfo")]
struct DisplayInfoDef {
pub id: u32,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub rotation: f32,
pub scale_factor: f32,
pub is_primary: bool,
}
#[derive(Serialize)]
struct DisplayInfoWrapper<'a>(#[serde(with = "DisplayInfoDef")] &'a DisplayInfo);
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
async fn take_snapshot() -> Vec<ScreenshotDto> {
info!("Hi?");
let _lock = GET_SCREENSHOT_LOCK.get_or_init(|| Mutex::new(false)).lock().await;
info!("Hi!");
let manager = Picker::global().await;
let start = time::Instant::now();
let base64_bitmap_list = match manager.list_displays().await {
Ok(base64_bitmap_list) => {
info!("screenshots len: {}", base64_bitmap_list.len());
base64_bitmap_list
}
Err(error) => {
error!("can not take screenshots for all. {}", error);
vec![]
}
};
info!("截图耗时 {} s", start.elapsed().as_seconds_f32());
base64_bitmap_list
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[tauri::command]
async fn get_screenshot_by_config(config: DisplayConfig) -> Result<ScreenshotDto, String> {
info!("Hi?");
// let _lock = GET_SCREENSHOT_LOCK.get_or_init(|| Mutex::new(false)).lock().await;
// info!("Hi!");
let start = time::Instant::now();
let screenshot_dto = Picker::preview_display_by_config(config).await;
info!("截图耗时 {} s", start.elapsed().as_seconds_f32());
match screenshot_dto {
Ok(screenshot_dto) => Ok(screenshot_dto),
Err(error) => {
error!("preview_display_by_config failed. {}", error);
Err(format!("preview_display_by_config failed. {}", error))
fn list_display_info() -> Result<String, String> {
let displays = display_info::DisplayInfo::all().map_err(|e| {
error!("can not list display info: {}", e);
e.to_string()
})?;
let displays: Vec<DisplayInfoWrapper> =
displays.iter().map(|v| DisplayInfoWrapper(v)).collect();
let json_str = to_string(&displays).map_err(|e| {
error!("can not list display info: {}", e);
e.to_string()
})?;
Ok(json_str)
}
#[tauri::command]
async fn read_led_strip_configs() -> Result<LedStripConfigGroup, String> {
let config = ambient_light::LedStripConfigGroup::read_config()
.await
.map_err(|e| {
error!("can not read led strip configs: {}", e);
e.to_string()
})?;
Ok(config)
}
#[tauri::command]
async fn write_led_strip_configs(
configs: Vec<ambient_light::LedStripConfig>,
) -> Result<(), String> {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager.set_items(configs).await.map_err(|e| {
error!("can not write led strip configs: {}", e);
e.to_string()
})
}
#[tauri::command]
async fn get_led_strips_sample_points(
config: LedStripConfig,
) -> Result<Vec<screenshot::LedSamplePoints>, String> {
let screenshot_manager = ScreenshotManager::global().await;
let channels = screenshot_manager.channels.read().await;
if let Some(rx) = channels.get(&config.display_id) {
let rx = rx.read().await;
let screenshot = rx.borrow().clone();
let sample_points = screenshot.get_sample_points(&config);
Ok(sample_points)
} else {
return Err(format!("display not found: {}", config.display_id));
}
}
#[tauri::command]
async fn get_picker_config() -> picker::config::Configuration {
let configuration = picker::config::Manager::global().get_config().await;
info!("configuration: {:?}", configuration);
configuration
}
#[tauri::command]
async fn write_picker_config(config: picker::config::Configuration) -> Result<(), String> {
let manager = picker::config::Manager::global();
let path = picker::config::Manager::get_config_file_path();
info!("log save in {:?}", path.to_str());
manager.set_config(&config).await;
match picker::config::Manager::write_config_to_disk(path, &config) {
Ok(_) => Ok(()),
Err(err) => {
error!("can not write picker config. {:?}", err);
Err(format!("can not write picker config. {:?}", err))
}
async fn get_one_edge_colors(
display_id: u32,
sample_points: Vec<screenshot::LedSamplePoints>,
) -> Result<Vec<led_color::LedColor>, String> {
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();
let colors =
Screenshot::get_one_edge_colors(&sample_points, &bytes, screenshot.bytes_per_row);
Ok(colors)
} else {
Err(format!("display not found: {}", display_id))
}
}
#[tauri::command]
async fn play_mode(target_mode: AmbientLightMode) {
info!("target mode: {:?}", target_mode);
async fn patch_led_strip_len(display_id: u32, border: Border, delta_len: i8) -> Result<(), String> {
info!(
"patch_led_strip_len: {} {:?} {}",
display_id, border, delta_len
);
let config_manager = ambient_light::ConfigManager::global().await;
config_manager
.patch_led_strip_len(display_id, border, delta_len)
.await
.map_err(|e| {
error!("can not patch led strip len: {}", e);
e.to_string()
})?;
tokio::spawn(async move { CoreManager::global().set_ambient_light(target_mode).await });
info!("patch_led_strip_len: ok");
Ok(())
}
#[tauri::command]
async fn send_colors(offset: u16, buffer: Vec<u8>) -> Result<(), String> {
ambient_light::LedColorsPublisher::send_colors(offset, buffer)
.await
.map_err(|e| {
error!("can not send colors: {}", e);
e.to_string()
})
}
#[tauri::command]
async fn move_strip_part(
display_id: u32,
border: Border,
target_start: usize,
) -> Result<(), String> {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager
.move_strip_part(display_id, border, target_start)
.await
.map_err(|e| {
error!("can not move strip part: {}", e);
e.to_string()
})
}
#[tauri::command]
async fn reverse_led_strip_part(display_id: u32, border: Border) -> Result<(), String> {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager
.reverse_led_strip_part(display_id, border)
.await
.map_err(|e| {
error!("can not reverse led strip part: {}", e);
e.to_string()
})
}
#[tauri::command]
async fn set_color_calibration(calibration: ColorCalibration) -> Result<(), String> {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager
.set_color_calibration(calibration)
.await
.map_err(|e| {
error!("can not set color calibration: {}", e);
e.to_string()
})
}
#[tauri::command]
async fn read_config() -> ambient_light::LedStripConfigGroup {
let config_manager = ambient_light::ConfigManager::global().await;
config_manager.configs().await
}
#[tauri::command]
async fn get_boards() -> Result<Vec<BoardInfo>, String> {
let udp_rpc = UdpRpc::global().await;
if let Err(e) = udp_rpc {
return Err(format!("can not ping: {}", e));
}
let udp_rpc = udp_rpc.as_ref().unwrap();
let boards = udp_rpc.get_boards().await;
let boards = boards.into_iter().collect::<Vec<_>>();
Ok(boards)
}
#[tauri::command]
async fn get_displays() -> Vec<DisplayState> {
let display_manager = DisplayManager::global().await;
display_manager.get_displays().await
}
#[tokio::main]
async fn main() {
rpc::manager::Manager::global();
env_logger::init();
tokio::spawn(async move {
let screenshot_manager = ScreenshotManager::global().await;
screenshot_manager.start().await.unwrap_or_else(|e| {
error!("can not start screenshot manager: {}", e);
})
});
tokio::spawn(async move {
let led_color_publisher = ambient_light::LedColorsPublisher::global().await;
led_color_publisher.start().await;
});
let _volume = VolumeManager::global().await;
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
take_snapshot,
play_mode,
get_picker_config,
get_screenshot_by_config,
write_picker_config,
greet,
list_display_info,
read_led_strip_configs,
write_led_strip_configs,
get_led_strips_sample_points,
get_one_edge_colors,
patch_led_strip_len,
send_colors,
move_strip_part,
reverse_led_strip_part,
set_color_calibration,
read_config,
get_boards,
get_displays
])
.register_uri_scheme_protocol("ambient-light", move |_app, request| {
let response = ResponseBuilder::new().header("Access-Control-Allow-Origin", "*");
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 {
let config_manager = ambient_light::ConfigManager::global().await;
let mut config_update_receiver = config_manager.clone_config_update_receiver();
loop {
if let Err(err) = config_update_receiver.changed().await {
error!("config update receiver changed error: {}", err);
return;
}
log::info!("config changed. emit config_changed event.");
let config = config_update_receiver.borrow().clone();
app_handle.emit_all("config_changed", config).unwrap();
}
});
let app_handle = app.handle().clone();
tokio::spawn(async move {
let publisher = ambient_light::LedColorsPublisher::global().await;
let mut publisher_update_receiver = publisher.clone_sorted_colors_receiver().await;
loop {
if let Err(err) = publisher_update_receiver.changed().await {
error!("publisher update receiver changed error: {}", err);
return;
}
let publisher = publisher_update_receiver.borrow().clone();
app_handle
.emit_all("led_sorted_colors_changed", publisher)
.unwrap();
}
});
let app_handle = app.handle().clone();
tokio::spawn(async move {
let publisher = ambient_light::LedColorsPublisher::global().await;
let mut publisher_update_receiver = publisher.clone_colors_receiver().await;
loop {
if let Err(err) = publisher_update_receiver.changed().await {
error!("publisher update receiver changed error: {}", err);
return;
}
let publisher = publisher_update_receiver.borrow().clone();
app_handle
.emit_all("led_colors_changed", publisher)
.unwrap();
}
});
let app_handle = app.handle().clone();
tokio::spawn(async move {
loop {
match UdpRpc::global().await {
Ok(udp_rpc) => {
let mut receiver = udp_rpc.subscribe_boards_change();
loop {
if let Err(err) = receiver.changed().await {
error!("boards change receiver changed error: {}", err);
return;
}
let boards = receiver.borrow().clone();
let boards = boards.into_iter().collect::<Vec<_>>();
app_handle.emit_all("boards_changed", boards).unwrap();
}
}
Err(err) => {
error!("udp rpc error: {}", err);
return;
}
}
}
});
let app_handle = app.handle().clone();
tokio::spawn(async move {
let display_manager = DisplayManager::global().await;
let mut rx = display_manager.subscribe_displays_changed();
while rx.changed().await.is_ok() {
let displays = rx.borrow().clone();
log::info!("displays changed. emit displays_changed event.");
app_handle.emit_all("displays_changed", displays).unwrap();
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -1,53 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct LedStripConfigOfBorders {
pub top: Option<LedStripConfig>,
pub bottom: Option<LedStripConfig>,
pub left: Option<LedStripConfig>,
pub right: Option<LedStripConfig>,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct LedStripConfig {
pub index: usize,
pub global_start_position: usize,
pub global_end_position: usize,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct DisplayConfig {
pub id: usize,
pub index_of_display: usize,
pub display_width: usize,
pub display_height: usize,
pub led_strip_of_borders: LedStripConfigOfBorders,
}
impl LedStripConfigOfBorders {
pub fn default() -> Self {
Self {
top: None,
bottom: None,
left: None,
right: None,
}
}
}
impl DisplayConfig {
pub fn default(
id: usize,
index_of_display: usize,
display_width: usize,
display_height: usize,
) -> Self {
Self {
id,
index_of_display,
display_width,
display_height,
led_strip_of_borders: LedStripConfigOfBorders::default(),
}
}
}

View File

@ -1,147 +0,0 @@
use std::{
env::current_dir,
fs::{self, File},
io::Read,
path::PathBuf,
sync::Arc,
};
use once_cell::sync::OnceCell;
use paris::info;
use serde::{Deserialize, Serialize};
use tauri::{api::path::config_dir, async_runtime::Mutex};
use super::DisplayConfig;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Configuration {
pub config_version: u8,
pub display_configs: Vec<DisplayConfig>,
}
impl Configuration {
pub fn default() -> Self {
Self {
config_version: 1,
display_configs: vec![],
}
}
}
pub struct Manager {
config: Arc<Mutex<Configuration>>,
}
impl Manager {
pub fn global() -> &'static Manager {
static DISPLAY_CONFIG_MANAGE: OnceCell<Manager> = OnceCell::new();
DISPLAY_CONFIG_MANAGE.get_or_init(|| Self::init_from_disk())
}
pub fn default() -> Self {
Self::new(Configuration::default())
}
pub fn new(config: Configuration) -> Self {
Self {
config: Arc::new(Mutex::new(config)),
}
}
pub fn get_config_file_path() -> PathBuf {
config_dir()
.unwrap_or(current_dir().unwrap())
.join("display_config.json")
}
pub fn init_from_disk() -> Self {
let config_file_path = Self::get_config_file_path();
match Self::read_config_from_disk(config_file_path) {
Ok(config) => Self::new(config),
Err(_) => Self::default(),
}
}
pub fn read_config_from_disk(config_file_path: PathBuf) -> anyhow::Result<Configuration> {
let mut file = File::open(config_file_path)
.map_err(|error| anyhow::anyhow!("config file is not existed. {}", error))?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|error| anyhow::anyhow!("can not read config file. {}", error))?;
serde_json::from_str(&contents)
.map_err(|error| anyhow::anyhow!("can not parse config file contents. {}", error))
}
pub fn write_config_to_disk(
config_file_path: PathBuf,
config: &Configuration,
) -> anyhow::Result<()> {
let contents = serde_json::to_string(config)
.map_err(|error| anyhow::anyhow!("can not serialize config. {}", error))?;
info!("contents: {}", contents);
fs::write(config_file_path, contents.as_bytes())
.map_err(|error| anyhow::anyhow!("can not write config file. {}", error))?;
Ok(())
}
pub async fn get_config(&self) -> Configuration {
self.config.lock().await.clone()
}
pub async fn set_config(&self, new_config: &Configuration) {
let mut config = self.config.lock().await;
*config = new_config.clone();
}
pub async fn reload_config(&self) -> anyhow::Result<Configuration> {
let mut config = self.config.lock().await;
let new_config = Self::read_config_from_disk(Self::get_config_file_path())
.map_err(|err| anyhow::anyhow!("can not reload config. {:?}", err))?;
*config = new_config.clone();
return anyhow::Ok(new_config);
}
}
#[cfg(test)]
mod tests {
use std::fs;
use serde_json::json;
use test_dir::{DirBuilder, TestDir};
use crate::picker::config::Configuration;
#[tokio::test]
async fn write_config_to_disk_should_be_successful() {
let temp = TestDir::temp().create("config_dir", test_dir::FileType::Dir);
let config_file_path = temp.path("config_dir").join("picker.config.json");
let manager = crate::picker::config::manger::Manager::default();
crate::picker::config::manger::Manager::write_config_to_disk(
config_file_path.clone(),
&Configuration::default(),
)
.unwrap();
let contents = fs::read_to_string(config_file_path.clone()).unwrap();
let _config: Configuration = serde_json::from_str(contents.as_str()).unwrap();
}
#[test]
fn read_config_to_disk_should_be_successful() {
let temp = TestDir::temp().create("config_dir", test_dir::FileType::Dir);
let config_file_path = temp.path("config_dir").join("picker.config.json");
fs::write(
config_file_path.clone(),
json!({
"config_version": 1,
"display_configs": []
})
.to_string()
.as_bytes(),
)
.unwrap();
let _manager =
crate::picker::config::manger::Manager::read_config_from_disk(config_file_path.clone())
.unwrap();
}
}

View File

@ -1,5 +0,0 @@
mod display_config;
mod manger;
pub use display_config::*;
pub use manger::*;

View File

@ -1,51 +0,0 @@
use paris::info;
use scrap::{Capturer, Display};
use super::{config::DisplayConfig, screen::Screen, screenshot::Screenshot};
pub struct DisplayPicker {
pub screen: Screen,
pub config: DisplayConfig,
}
impl DisplayPicker {
pub fn new(screen: Screen, config: DisplayConfig) -> Self {
Self { screen, config }
}
pub fn from_config(config: DisplayConfig) -> anyhow::Result<Self> {
let displays = Display::all()
.map_err(|error| anyhow::anyhow!("Can not get all of displays. {}", error))?;
let display = displays
.into_iter()
.skip(config.index_of_display)
.next();
match display {
Some(display) => {
let height = display.height();
let width = display.width();
info!("dw: {}, cw: {}", width, config.display_height);
assert_eq!(width, config.display_width);
let capturer = Capturer::new(display)?;
let screen = Screen::new(capturer, width, height);
Ok(Self { screen, config })
}
None => {
anyhow::bail!("Index out of displays range.")
}
}
}
pub fn take_screenshot(&mut self) -> anyhow::Result<Screenshot> {
let bitmap = self
.screen
.take()
.map_err(|error| anyhow::anyhow!("take screenshot for display failed. {}", error))?;
// info!("bitmap size {}", bitmap.len());
let screenshot = Screenshot::new(bitmap, self.config);
Ok(screenshot)
}
}

View File

@ -1,87 +0,0 @@
use futures::{stream::FuturesUnordered, StreamExt};
use paris::info;
use scrap::Display;
use std::sync::Arc;
use tokio::{
sync::{Mutex, OnceCell},
task,
};
use crate::picker::{config, screen::Screen};
use super::{
config::DisplayConfig,
display_picker::DisplayPicker,
screenshot::{Screenshot, ScreenshotDto},
};
pub struct Picker {
pub screens: Arc<Mutex<Vec<Screen>>>,
pub screenshots: Arc<Mutex<Vec<Screenshot>>>,
pub display_configs: Arc<Mutex<Vec<DisplayConfig>>>,
}
impl Picker {
pub async fn global() -> &'static Picker {
static SCREEN_COLOR_PICKER: OnceCell<Picker> = OnceCell::const_new();
SCREEN_COLOR_PICKER
.get_or_init(|| async {
let configs = config::Manager::global().get_config().await.display_configs;
info!("Global Picker use configs. {:?}", configs);
Picker {
screens: Arc::new(Mutex::new(vec![])),
screenshots: Arc::new(Mutex::new(vec![])),
display_configs: Arc::new(Mutex::new(
configs,
)),
}
})
.await
}
pub async fn list_displays(&self) -> anyhow::Result<Vec<ScreenshotDto>> {
let mut configs = vec![];
let displays = Display::all()
.map_err(|error| anyhow::anyhow!("Can not get all of displays. {}", error))?;
// configs.clear();
let mut futs = FuturesUnordered::new();
for (index, display) in displays.iter().enumerate() {
let height = display.height();
let width = display.width();
let config = DisplayConfig::default(index, index, width, height);
configs.push(config);
}
for config in configs.iter() {
futs.push(async move {
let join = task::spawn(Self::preview_display_by_config(config.clone()));
join.await?
});
}
let mut bitmap_string_list = vec![];
while let Some(bitmap_string) = futs.next().await {
match bitmap_string {
Ok(bitmap_string) => {
bitmap_string_list.push(bitmap_string);
}
Err(error) => {
anyhow::bail!("can not convert to base64 image. {}", error);
}
}
}
Ok(bitmap_string_list)
}
pub async fn preview_display_by_config(config: DisplayConfig) -> anyhow::Result<ScreenshotDto> {
let start = time::Instant::now();
let mut picker = DisplayPicker::from_config(config)?;
let screenshot = picker.take_screenshot()?;
info!("Take Screenshot Spend: {}", start.elapsed());
anyhow::Ok(screenshot.to_dto().await)
}
}

View File

@ -1,6 +0,0 @@
pub mod led_color;
pub mod screen;
pub mod manager;
pub mod screenshot;
pub mod display_picker;
pub mod config;

View File

@ -1,83 +0,0 @@
use futures::{stream::FuturesUnordered, StreamExt};
use once_cell::sync::OnceCell;
use paris::{info, warn};
use scrap::Display;
use std::{borrow::Borrow, sync::Arc};
use tokio::{sync::Mutex, task};
use crate::picker::{config, screen::Screen};
use super::{
config::DisplayConfig,
display_picker::DisplayPicker,
manager::Picker,
screenshot::{Screenshot, ScreenshotDto},
};
pub struct PreviewPicker {
pub pickers: Arc<Mutex<Vec<Arc<Mutex<DisplayPicker>>>>>,
pub screenshots: Arc<Mutex<Vec<Screenshot>>>,
}
impl PreviewPicker {
pub fn global() -> &'static PreviewPicker {
static SCREEN_COLOR_PREVIEW_PICKER: OnceCell<PreviewPicker> = OnceCell::new();
SCREEN_COLOR_PREVIEW_PICKER.get_or_init(|| PreviewPicker {
pickers: Arc::new(Mutex::new(vec![])),
screenshots: Arc::new(Mutex::new(vec![])),
})
}
pub async fn list_displays(&self) {
let mut pickers = self.pickers.lock().await;
let displays = Display::all()
.map_err(|error| anyhow::anyhow!("Can not get all of displays. {}", error))?;
let mut configs = vec![];
let mut futs = FuturesUnordered::new();
for (index, display) in displays.iter().enumerate() {
let height = display.height();
let width = display.width();
let config = DisplayConfig::default(index, width, height);
configs.push(config);
}
for config in configs.iter() {
let picker = DisplayPicker::from_config(*config);
match picker {
Ok(picker) => {
pickers.push(Arc::new(Mutex::new(picker)));
}
Err(_) => {
warn!(
"can not create DisplayPicker from config. config: {:?}",
config
);
}
}
}
}
pub async fn get_screenshot_by_config(
&self,
config: DisplayConfig,
) -> anyhow::Result<ScreenshotDto> {
let start = time::Instant::now();
let mut picker = DisplayPicker::from_config(config)?;
let screenshot = picker.take_screenshot()?;
info!("Take Screenshot Spend: {}", start.elapsed());
anyhow::Ok(screenshot.to_dto().await)
}
pub async fn preview_display_by_config(config: DisplayConfig) -> anyhow::Result<ScreenshotDto> {
let start = time::Instant::now();
let mut picker = DisplayPicker::from_config(config)?;
let screenshot = picker.take_screenshot()?;
info!("Take Screenshot Spend: {}", start.elapsed());
anyhow::Ok(screenshot.to_dto().await)
}
}

View File

@ -1,52 +0,0 @@
use scrap::Capturer;
use std::{io::ErrorKind::WouldBlock, time::Duration, thread};
pub struct Screen {
capturer: Option<Capturer>,
init_error: Option<anyhow::Error>,
pub width: usize,
pub height: usize,
}
impl Screen {
pub fn new(capturer: Capturer, width: usize, height: usize) -> Self {
Self {
capturer: Some(capturer),
init_error: None,
width,
height,
}
}
pub fn new_failed(init_error: anyhow::Error, width: usize, height: usize) -> Self {
Self {
capturer: None,
init_error: Some(init_error),
width,
height,
}
}
pub fn take(&mut self) -> anyhow::Result<Vec<u8>> {
match self.capturer.as_mut() {
Some(capturer) => loop {
match capturer.frame() {
Ok(buffer) => {
return anyhow::Ok(buffer.to_vec());
}
Err(error) => {
if error.kind() == WouldBlock {
thread::sleep(Duration::from_millis(16));
continue;
} else {
anyhow::bail!("failed to frame of display. {}", error);
}
}
}
},
None => anyhow::bail!("Do not initialized"),
}
}
}
unsafe impl Send for Screen {}

View File

@ -1,286 +0,0 @@
use image::ImageBuffer;
use image::{ImageOutputFormat, Rgb};
use paris::{error, info};
use serde::{Deserialize, Serialize};
use std::iter;
use std::time::SystemTime;
use super::{config::DisplayConfig, led_color::LedColor};
type Point = (usize, usize);
type LedSamplePoints = Vec<Point>;
#[derive(Clone, Serialize, Deserialize, Debug)]
struct ScreenSamplePoints {
pub top: Vec<LedSamplePoints>,
pub bottom: Vec<LedSamplePoints>,
pub left: Vec<LedSamplePoints>,
pub right: Vec<LedSamplePoints>,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Screenshot {
bitmap: Vec<u8>,
config: DisplayConfig,
sample_points: ScreenSamplePoints,
}
impl Screenshot {
pub fn new(bitmap: Vec<u8>, config: DisplayConfig) -> Self {
Self {
bitmap,
config,
sample_points: Self::get_sample_points(config),
}
}
fn get_sample_points(config: DisplayConfig) -> ScreenSamplePoints {
let top = match config.led_strip_of_borders.top {
Some(led_strip_config) => Self::get_one_edge_sample_points(
config.display_height / 8,
config.display_width,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
),
None => {
vec![]
}
};
let bottom: Vec<LedSamplePoints> = match config.led_strip_of_borders.bottom {
Some(led_strip_config) => {
let points = Self::get_one_edge_sample_points(
config.display_height / 9,
config.display_width,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups
.into_iter()
.map(|(x, y)| (x, config.display_height - y))
.collect()
})
.collect()
}
None => {
vec![]
}
};
let left: Vec<LedSamplePoints> = match config.led_strip_of_borders.left {
Some(led_strip_config) => {
let points = Self::get_one_edge_sample_points(
config.display_width / 16,
config.display_height,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups.into_iter().map(|(x, y)| (y, x)).collect()
})
.collect()
}
None => {
vec![]
}
};
let right: Vec<LedSamplePoints> = match config.led_strip_of_borders.right {
Some(led_strip_config) => {
let points = Self::get_one_edge_sample_points(
config.display_width / 16,
config.display_height,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups
.into_iter()
.map(|(x, y)| (config.display_width - y, x))
.collect()
})
.collect()
}
None => {
vec![]
}
};
ScreenSamplePoints {
top,
bottom,
left,
right,
}
}
fn get_one_edge_sample_points(
width: usize,
length: usize,
leds: usize,
single_axis_points: usize,
) -> Vec<LedSamplePoints> {
let cell_size_x = length as f64 / single_axis_points as f64 / leds as f64;
let cell_size_y = width / single_axis_points;
let point_start_y = cell_size_y / 2;
let point_start_x = cell_size_x / 2.0;
let point_y_list: Vec<usize> = (point_start_y..width).step_by(cell_size_y).collect();
let point_x_list: Vec<usize> = iter::successors(Some(point_start_x), |i| {
let next = i + cell_size_x;
(next < (length as f64)).then_some(next)
})
.map(|i| i as usize)
.collect();
let points: Vec<Point> = point_x_list
.iter()
.map(|&x| point_y_list.iter().map(move |&y| (x, y)))
.flatten()
.collect();
points
.chunks(single_axis_points * single_axis_points)
.into_iter()
.map(|points| Vec::from(points))
.collect()
}
pub fn get_colors(&self) -> DisplayColorsOfLedStrips {
let top = self
.get_one_edge_colors(&self.sample_points.top)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
let bottom = self
.get_one_edge_colors(&self.sample_points.bottom)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
let left = self
.get_one_edge_colors(&self.sample_points.left)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
let right = self
.get_one_edge_colors(&self.sample_points.right)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
DisplayColorsOfLedStrips {
top,
bottom,
left,
right,
}
}
pub fn get_one_edge_colors(
&self,
sample_points_of_leds: &Vec<LedSamplePoints>,
) -> Vec<LedColor> {
let mut colors = vec![];
for led_points in sample_points_of_leds {
let mut r = 0.0;
let mut g = 0.0;
let mut b = 0.0;
let len = led_points.len() as f64;
for (x, y) in led_points {
let position = (x + y * self.config.display_width) * 4;
r += self.bitmap[position + 2] as f64;
g += self.bitmap[position + 1] as f64;
b += self.bitmap[position] as f64;
}
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);
// paris::info!("color: {:?}", color.get_rgb());
colors.push(color);
}
colors
}
pub async fn to_webp_base64(&self) -> String {
let bitmap = &self.bitmap;
let stride = bitmap.len() / self.config.display_height;
let mut image_buffer = ImageBuffer::new(
self.config.display_width as u32 / 3,
self.config.display_height as u32 / 3,
);
for y in 0..self.config.display_height / 3 {
for x in 0..self.config.display_width / 3 {
let i = stride * y * 3 + 4 * x * 3;
image_buffer.put_pixel(
x as u32,
y as u32,
Rgb::<u8>([bitmap[i + 2], bitmap[i + 1], bitmap[i]]),
);
}
}
// let webp_memory =
// webp::Encoder::from_rgb(bitflipped.as_slice(), size_x, size_y).encode(50.0);
// return base64::encode(&*webp_memory);
let mut cursor = std::io::Cursor::new(vec![]);
match image_buffer.write_to(&mut cursor, ImageOutputFormat::Tiff) {
Ok(_) => {
return base64::encode(cursor.into_inner());
}
Err(err) => {
error!("can not encode image. {:?}", err);
return String::from("");
}
}
}
pub async fn to_dto(&self) -> ScreenshotDto {
let rk = SystemTime::now();
info!("[{:?} {:p}] to_dto", rk.elapsed(), &self);
let encode_image = self.to_webp_base64().await;
info!("[{:?} {:p}] image", rk.elapsed(), &self);
let config = self.config.clone();
info!("[{:?} {:p}] cloned", rk.elapsed(), &self);
let colors = self.get_colors();
info!("[{:?} {:p}] colors", rk.elapsed(), &self);
ScreenshotDto {
encode_image,
config,
colors,
}
}
pub fn get_config(&self) -> DisplayConfig {
self.config
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ScreenshotDto {
pub config: DisplayConfig,
pub encode_image: String,
pub colors: DisplayColorsOfLedStrips,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct DisplayColorsOfLedStrips {
pub top: Vec<u8>,
pub bottom: Vec<u8>,
pub left: Vec<u8>,
pub right: Vec<u8>,
}

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

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

View File

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

View File

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

View File

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

View File

@ -1,55 +0,0 @@
use crate::picker::led_color::LedColor;
use super::mqtt::MqttConnection;
use once_cell::sync::OnceCell;
pub struct Manager {
mqtt: MqttConnection,
}
impl Manager {
pub fn global() -> &'static Self {
static RPC_MANAGER: OnceCell<Manager> = OnceCell::new();
RPC_MANAGER.get_or_init(|| Manager::new())
}
pub fn new() -> Self {
let mut mqtt = MqttConnection::new();
mqtt.initialize();
Self { mqtt }
}
pub async fn publish_led_colors(&self, colors: &Vec<LedColor>) -> anyhow::Result<()> {
let payload = colors
.iter()
.map(|c| c.get_rgb().clone())
.flatten()
.collect::<Vec<u8>>();
self.mqtt
.client
.publish(
"display-ambient-light/desktop/colors",
rumqttc::QoS::AtLeastOnce,
false,
payload,
)
.await
.map_err(|error| anyhow::anyhow!("mqtt publish failed. {}", error))
}
pub async fn publish_led_sub_pixels(&self, payload: Vec<u8>) -> anyhow::Result<()> {
self.mqtt
.client
.publish(
"display-ambient-light/desktop/colors",
rumqttc::QoS::AtLeastOnce,
false,
payload,
)
.await
.map_err(|error| anyhow::anyhow!("mqtt publish failed. {}", error))
}
}

View File

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

View File

@ -1,72 +0,0 @@
use rumqttc::{AsyncClient, MqttOptions, QoS};
use std::time::Duration;
use time::{format_description, OffsetDateTime};
use tokio::task;
use tracing::warn;
pub struct MqttConnection {
pub client: AsyncClient,
}
impl MqttConnection {
pub fn new() -> Self {
let mut options = MqttOptions::new("rumqtt-async", "192.168.31.11", 1883);
options.set_keep_alive(Duration::from_secs(5));
let (client, mut eventloop) = AsyncClient::new(options, 10);
task::spawn(async move {
loop {
match eventloop.poll().await {
Ok(_) => {}
Err(err) => {
println!("MQTT Error Event = {:?}", err);
}
}
}
});
Self { client }
}
pub fn initialize(&mut self) {
self.subscribe_board();
self.broadcast_desktop_online();
}
async fn subscribe_board(&self) {
self.client
.subscribe("display-ambient-light/board/#", QoS::AtMostOnce)
.await;
}
fn broadcast_desktop_online(&mut self) {
let client = self.client.to_owned();
task::spawn(async move {
loop {
match OffsetDateTime::now_utc()
.format(&format_description::well_known::Iso8601::DEFAULT)
{
Ok(now_str) => {
match client
.publish(
"display-ambient-light/desktop/online",
QoS::AtLeastOnce,
false,
now_str.as_bytes(),
)
.await
{
Ok(_) => {}
Err(error) => {
warn!("can not publish last online time. {}", error)
}
}
}
Err(error) => {
warn!("can not get time for now. {}", error);
}
}
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}
}

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

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

214
src-tauri/src/screenshot.rs Normal file
View File

@ -0,0 +1,214 @@
use std::fmt::Formatter;
use std::{iter, fmt::Debug};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tauri::async_runtime::RwLock;
use crate::{ambient_light::LedStripConfig, led_color::LedColor};
#[derive(Clone)]
pub struct Screenshot {
pub display_id: u32,
pub height: u32,
pub width: u32,
pub bytes_per_row: usize,
pub bytes: Arc<RwLock<Arc<Vec<u8>>>>,
pub scale_factor: f32,
pub bound_scale_factor: f32,
}
impl Debug for Screenshot {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Screenshot")
.field("display_id", &self.display_id)
.field("height", &self.height)
.field("width", &self.width)
.field("bytes_per_row", &self.bytes_per_row)
.field("scale_factor", &self.scale_factor)
.field("bound_scale_factor", &self.bound_scale_factor)
.finish()
}
}
static SINGLE_AXIS_POINTS: usize = 5;
impl Screenshot {
pub fn new(
display_id: u32,
height: u32,
width: u32,
bytes_per_row: usize,
bytes: Arc<Vec<u8>>,
scale_factor: f32,
bound_scale_factor: f32,
) -> Self {
Self {
display_id,
height,
width,
bytes_per_row,
bytes: Arc::new(RwLock::new(bytes)),
scale_factor,
bound_scale_factor,
}
}
pub fn get_sample_points(&self, config: &LedStripConfig) -> Vec<LedSamplePoints> {
let height = self.height as usize;
let width = self.width as usize;
// let height = CGDisplay::new(self.display_id).bounds().size.height as usize;
// let width = CGDisplay::new(self.display_id).bounds().size.width as usize;
match config.border {
crate::ambient_light::Border::Top => {
Self::get_one_edge_sample_points(height / 18, width, config.len, SINGLE_AXIS_POINTS)
}
crate::ambient_light::Border::Bottom => {
let points = Self::get_one_edge_sample_points(height / 18, width, config.len, SINGLE_AXIS_POINTS);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups.into_iter().map(|(x, y)| (x, height - y)).collect()
})
.collect()
}
crate::ambient_light::Border::Left => {
let points = Self::get_one_edge_sample_points(width / 32, height, config.len, SINGLE_AXIS_POINTS);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups.into_iter().map(|(x, y)| (y, x)).collect()
})
.collect()
}
crate::ambient_light::Border::Right => {
let points = Self::get_one_edge_sample_points(width / 32, height, config.len, SINGLE_AXIS_POINTS);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups.into_iter().map(|(x, y)| (width - y, x)).collect()
})
.collect()
}
}
}
fn get_one_edge_sample_points(
width: usize,
length: usize,
leds: usize,
single_axis_points: usize,
) -> Vec<LedSamplePoints> {
if leds == 0 {
return vec![];
}
let cell_size_x = length as f64 / single_axis_points as f64 / leds as f64;
let cell_size_y = width / single_axis_points;
let point_start_y = cell_size_y / 2;
let point_start_x = cell_size_x / 2.0;
let point_y_list: Vec<usize> = (point_start_y..width).step_by(cell_size_y).collect();
let point_x_list: Vec<usize> = iter::successors(Some(point_start_x), |i| {
let next = i + cell_size_x;
(next < (length as f64)).then_some(next)
})
.map(|i| i as usize)
.collect();
let points: Vec<Point> = point_x_list
.iter()
.map(|&x| point_y_list.iter().map(move |&y| (x, y)))
.flatten()
.collect();
points
.chunks(single_axis_points * single_axis_points)
.into_iter()
.map(|points| Vec::from(points))
.collect()
}
pub fn get_one_edge_colors(
sample_points_of_leds: &Vec<LedSamplePoints>,
bitmap: &Vec<u8>,
bytes_per_row: usize,
) -> Vec<LedColor> {
let mut colors = vec![];
for led_points in sample_points_of_leds {
let mut r = 0.0;
let mut g = 0.0;
let mut b = 0.0;
let len = led_points.len() as f64;
for (x, y) in led_points {
// log::info!("x: {}, y: {}, bytes_per_row: {}", x, y, bytes_per_row);
let position = x * 4 + y * bytes_per_row;
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
}
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);
colors.push(color);
}
colors
}
pub fn get_one_edge_colors_by_cg_image(
sample_points_of_leds: &Vec<LedSamplePoints>,
bitmap: core_foundation::data::CFData,
bytes_per_row: usize,
) -> Vec<LedColor> {
let mut colors = vec![];
for led_points in sample_points_of_leds {
let mut r = 0.0;
let mut g = 0.0;
let mut b = 0.0;
let len = led_points.len() as f64;
for (x, y) in led_points {
// log::info!("x: {}, y: {}, bytes_per_row: {}", x, y, bytes_per_row);
let position = x * 4 + y * bytes_per_row;
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
// log::info!("position: {}, total: {}", position, bitmap.len());
}
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);
colors.push(color);
}
colors
}
pub async fn get_colors_by_sample_points(
&self,
points: &Vec<LedSamplePoints>,
) -> Vec<LedColor> {
let bytes = self.bytes.read().await;
Self::get_one_edge_colors(points, &bytes, self.bytes_per_row)
}
}
type Point = (usize, usize);
pub type LedSamplePoints = Vec<Point>;
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ScreenSamplePoints {
pub top: Vec<LedSamplePoints>,
pub bottom: Vec<LedSamplePoints>,
pub left: Vec<LedSamplePoints>,
pub right: Vec<LedSamplePoints>,
}
pub struct DisplayColorsOfLedStrips {
pub top: Vec<u8>,
pub bottom: Vec<u8>,
pub left: Vec<u8>,
pub right: Vec<u8>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScreenshotPayload {
pub display_id: u32,
pub height: u32,
pub width: u32,
}

View File

@ -0,0 +1,244 @@
use std::time::Duration;
use std::{collections::HashMap, sync::Arc};
use core_graphics::display::{
kCGNullWindowID, kCGWindowImageDefault, kCGWindowListOptionOnScreenOnly, CGDisplay,
};
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use paris::{info, warn};
use rust_swift_screencapture::display::CGDisplayId;
use tauri::async_runtime::RwLock;
use tokio::sync::{broadcast, watch, Mutex, OnceCell};
use tokio::task::yield_now;
use tokio::time::sleep;
use crate::screenshot::LedSamplePoints;
use crate::{ambient_light::SamplePointMapper, led_color::LedColor, screenshot::Screenshot};
pub fn get_display_colors(
display_id: u32,
sample_points: &Vec<Vec<LedSamplePoints>>,
bound_scale_factor: f32,
) -> anyhow::Result<Vec<LedColor>> {
log::debug!("take_screenshot");
let cg_display = CGDisplay::new(display_id);
let mut colors = vec![];
for points in sample_points {
if points.len() == 0 {
continue;
}
let start_x = points[0][0].0;
let start_y = points[0][0].1;
let end_x = points.last().unwrap().last().unwrap().0;
let end_y = points.last().unwrap().last().unwrap().1;
let (start_x, end_x) = (usize::min(start_x, end_x), usize::max(start_x, end_x));
let (start_y, end_y) = (usize::min(start_y, end_y), usize::max(start_y, end_y));
let origin = CGPoint {
x: start_x as f64 * bound_scale_factor as f64 + cg_display.bounds().origin.x,
y: start_y as f64 * bound_scale_factor as f64 + cg_display.bounds().origin.y,
};
let size = CGSize {
width: (end_x - start_x + 1) as f64,
height: (end_y - start_y + 1) as f64,
};
// log::info!(
// "origin: {:?}, size: {:?}, start_x: {}, start_y: {}, bounds: {:?}",
// origin,
// size,
// start_x,
// start_y,
// cg_display.bounds().size
// );
let cg_image = CGDisplay::screenshot(
CGRect::new(&origin, &size),
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowImageDefault,
)
.ok_or_else(|| anyhow::anyhow!("Display#{}: take screenshot failed", display_id))?;
let bitmap = cg_image.data();
let points = points
.iter()
.map(|points| {
points
.iter()
.map(|(x, y)| (*x - start_x, *y - start_y))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut part_colors =
Screenshot::get_one_edge_colors_by_cg_image(&points, bitmap, cg_image.bytes_per_row());
colors.append(&mut part_colors);
}
Ok(colors)
}
pub struct ScreenshotManager {
pub channels: Arc<RwLock<HashMap<u32, Arc<RwLock<watch::Sender<Screenshot>>>>>>,
merged_screenshot_tx: Arc<RwLock<broadcast::Sender<Screenshot>>>,
}
impl ScreenshotManager {
pub async fn global() -> &'static Self {
static SCREENSHOT_MANAGER: OnceCell<ScreenshotManager> = OnceCell::const_new();
SCREENSHOT_MANAGER
.get_or_init(|| async {
let channels = Arc::new(RwLock::new(HashMap::new()));
let (merged_screenshot_tx, _) = broadcast::channel::<Screenshot>(2);
Self {
channels,
merged_screenshot_tx: Arc::new(RwLock::new(merged_screenshot_tx)),
}
})
.await
}
pub async fn start(&self) -> anyhow::Result<()> {
let displays = display_info::DisplayInfo::all()?;
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;
Ok(())
}
async fn start_one(&self, display_id: u32, scale_factor: f32) -> anyhow::Result<()> {
let merged_screenshot_tx = self.merged_screenshot_tx.clone();
let (tx, _) = watch::channel(Screenshot::new(
display_id,
0,
0,
0,
Arc::new(vec![]),
scale_factor,
scale_factor,
));
let tx = Arc::new(RwLock::new(tx));
let mut channels = self.channels.write().await;
channels.insert(display_id, tx.clone());
drop(channels);
loop {
let display = rust_swift_screencapture::display::Display::new(display_id);
let mut frame_rx = display.subscribe_frame().await;
display.start_capture(30).await;
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);
}
yield_now().await;
}
sleep(Duration::from_secs(5)).await;
info!(
"display {} frame_rx.changed() failed, try to restart",
display_id
);
}
}
pub fn get_sorted_colors(colors: &Vec<u8>, mappers: &Vec<SamplePointMapper>) -> Vec<u8> {
let total_leds = mappers
.iter()
.map(|mapper| usize::max(mapper.start, mapper.end))
.max()
.unwrap_or(0) as usize;
let mut global_colors = vec![0u8; total_leds * 3];
let mut color_index = 0;
mappers.iter().for_each(|group| {
if group.end > global_colors.len() || group.start > global_colors.len() {
warn!(
"get_sorted_colors: group out of range. start: {}, end: {}, global_colors.len(): {}",
group.start,
group.end,
global_colors.len()
);
return;
}
if color_index + group.start.abs_diff(group.end) * 3 > colors.len(){
warn!(
"get_sorted_colors: color_index out of range. color_index: {}, strip len: {}, colors.len(): {}",
color_index / 3,
group.start.abs_diff(group.end),
colors.len() / 3
);
return;
}
if group.end > group.start {
for i in group.start..group.end {
global_colors[i * 3] = colors[color_index +0];
global_colors[i * 3 + 1] = colors[color_index +1];
global_colors[i * 3 + 2] = colors[color_index +2];
color_index += 3;
}
} else {
for i in (group.end..group.start).rev() {
global_colors[i * 3] = colors[color_index +0];
global_colors[i * 3 + 1] = colors[color_index +1];
global_colors[i * 3 + 2] = colors[color_index +2];
color_index += 3;
}
}
});
global_colors
}
pub async fn clone_merged_screenshot_rx(&self) -> broadcast::Receiver<Screenshot> {
self.merged_screenshot_tx.read().await.subscribe()
}
pub async fn subscribe_by_display_id(
&self,
display_id: CGDisplayId,
) -> anyhow::Result<watch::Receiver<Screenshot>> {
let channels = self.channels.read().await;
if let Some(tx) = channels.get(&display_id) {
Ok(tx.read().await.subscribe())
} else {
Err(anyhow::anyhow!("display_id: {} not found", display_id))
}
}
}

View File

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

View File

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

View File

@ -3,24 +3,23 @@
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:1420",
"distDir": "../dist"
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "display-ambient-light-desktop",
"version": "0.0.0"
"productName": "test-demo",
"version": "0.0.1"
},
"tauri": {
"allowlist": {
"all": true
"all": false,
"shell": {
"all": false,
"open": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@ -28,22 +27,10 @@
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "cc.ivanli.ambient",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"identifier": "cc.ivanli.ambient-light.desktop",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"macOS": {
"minimumSystemVersion": "13"
}
},
"security": {
@ -55,10 +42,10 @@
"windows": [
{
"fullscreen": false,
"height": 600,
"resizable": true,
"title": "display-ambient-light-desktop",
"width": 800
"title": "test-demo",
"width": 800,
"height": 600
}
]
}

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,11 +0,0 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
img {
pointer-event: none !important;
}

View File

@ -1,73 +1,39 @@
import { useCallback, useState } from 'react';
import tw from 'twin.macro';
import { invoke } from '@tauri-apps/api/tauri';
import './App.css';
import { Configurator } from './configurator/configurator';
import { ButtonSwitch } from './commons/components/button';
import { fillParentCss } from './styles/fill-parent';
type Mode = 'Flowing' | 'Follow' | null;
localStorage.setItem('debug', '*');
import { Routes, Route } from '@solidjs/router';
import { LedStripConfiguration } from './components/led-strip-configuration/led-strip-configuration';
import { WhiteBalance } from './components/white-balance/white-balance';
import { createEffect } from 'solid-js';
import { invoke } from '@tauri-apps/api';
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 [screenshots, setScreenshots] = useState<string[]>([]);
const [ledStripColors, setLedStripColors] = useState<string[]>([]);
const [currentMode, setCurrentMode] = useState<Mode>(null);
async function takeSnapshot() {
const base64TextList: string[] = await invoke('take_snapshot');
setScreenshots(base64TextList.map((text) => `data:image/webp;base64,${text}`));
}
const getLedStripColors = useCallback(async () => {
setLedStripColors(await invoke('get_led_strip_colors'));
}, []);
const switchCurrentMode = useCallback(
async (targetMode: Mode) => {
console.log(targetMode, currentMode, currentMode === targetMode);
if (currentMode === targetMode) {
await invoke('play_mode', { targetMode: 'None' });
setCurrentMode(null);
} else {
await invoke('play_mode', { targetMode });
setCurrentMode(targetMode);
}
console.log(targetMode, currentMode, currentMode === targetMode);
},
[currentMode, setCurrentMode],
);
createEffect(() => {
invoke<LedStripConfigContainer>('read_config').then((config) => {
console.log('read config', config);
setLedStripStore({
strips: config.strips,
mappers: config.mappers,
colorCalibration: config.color_calibration,
});
});
});
return (
<div css={[fillParentCss]} tw="box-border flex flex-col">
<div tw="flex justify-between">
{ledStripColors.map((it) => (
<span tw="h-8 flex-auto" style={{ backgroundColor: it }}></span>
))}
</div>
<div tw="flex gap-1 justify-center w-screen overflow-hidden">
{screenshots.map((screenshot) => (
<div tw="flex-auto">
<img src={screenshot} />
</div>
))}
</div>
<div tw="flex gap-5 justify-center">
<ButtonSwitch onClick={() => takeSnapshot()}>Take Snapshot</ButtonSwitch>
<ButtonSwitch onClick={() => getLedStripColors()}>Get Colors</ButtonSwitch>
<ButtonSwitch onClick={() => switchCurrentMode('Flowing')}>
Flowing Light
</ButtonSwitch>
<ButtonSwitch onClick={() => switchCurrentMode('Follow')}>Follow</ButtonSwitch>
</div>
<div css={[fillParentCss]}>
<Configurator />
<div>
<div>
<a href="/info"></a>
<a href="/displays"></a>
<a href="/led-strips-configuration"></a>
<a href="/white-balance"></a>
</div>
<Routes>
<Route path="/info" component={InfoIndex} />
<Route path="/displays" component={DisplayStateIndex} />
<Route path="/led-strips-configuration" component={LedStripConfiguration} />
<Route path="/white-balance" component={WhiteBalance} />
</Routes>
</div>
);
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,16 @@
<!-- http://mike.eire.ca/2010/02/25/easy-svg-grid/ -->
<!-- "I needed a grid in the background while I was debugging an SVG image I was creating, something
like Photoshops transparency grid. Heres what I did." -->
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="200" height="400">
<defs>
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
<rect fill="black" x="0" y="0" width="5" height="5" opacity="0.3" />
<rect fill="white" x="5" y="0" width="5" height="5" />
<rect fill="black" x="5" y="5" width="5" height="5" opacity="0.3" />
<rect fill="white" x="0" y="5" width="5" height="5" />
</pattern>
</defs>
<rect fill="url(#grid)" x="0" y="0" width="100%" height="100%" />
</svg>

After

Width:  |  Height:  |  Size: 759 B

View File

@ -1,21 +0,0 @@
import { FC } from 'react';
import styled from '@emotion/styled';
import tw, { theme } from 'twin.macro';
import { css } from '@emotion/react';
interface ButtonProps {
value?: boolean;
isSmall?: boolean;
}
export const ButtonSwitch = styled.button(({ value, isSmall }: ButtonProps) => [
// The common button styles
tw`px-8 py-2 rounded-xl transform duration-75 dark:bg-black m-2 shadow-lg text-opacity-95 dark:shadow-gray-800`,
tw`hover:(scale-105)`,
tw`focus:(scale-100)`,
value && 'bg-gradient-to-r from-purple-500 to-blue-500',
isSmall ? tw`text-sm` : tw`text-lg`,
]);

View File

@ -0,0 +1,36 @@
import { Component, ParentComponent } from 'solid-js';
import { DisplayState } from '../../models/display-state.model';
type DisplayStateCardProps = {
state: DisplayState;
};
type ItemProps = {
label: string;
};
const Item: ParentComponent<ItemProps> = (props) => {
return (
<dl class="flex">
<dt class="w-20">{props.label}</dt>
<dd class="flex-auto">{props.children}</dd>
</dl>
);
};
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>
);
};

View File

@ -0,0 +1,52 @@
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 { DisplayState, RawDisplayState } from '../../models/display-state.model';
import { DisplayStateCard } from './display-state-card';
const logger = debug('app:components:displays:display-state-index');
export const DisplayStateIndex: Component = () => {
const [states, setStates] = createSignal<DisplayState[]>([]);
createEffect(() => {
const unlisten = listen<RawDisplayState[]>('displays_changed', (ev) => {
logger('displays_changed', ev);
setStates(
ev.payload.map((it) => ({
...it,
last_modified_at: new Date(it.last_modified_at.secs_since_epoch * 1000),
})),
);
});
invoke<RawDisplayState[]>('get_displays').then((states) => {
logger('get_displays', states);
setStates(
states.map((it) => ({
...it,
last_modified_at: new Date(it.last_modified_at.secs_since_epoch * 1000),
})),
);
});
return () => {
unlisten.then((unlisten) => unlisten());
};
});
return (
<ol class="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 p-2 gap-2">
<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">
<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>
)}
</For>
</ol>
);
};

View File

@ -0,0 +1,42 @@
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 { BoardInfoPanel } from './board-info-panel';
const logger = debug('app:components:info:board-index');
export const BoardIndex: Component = () => {
const [boards, setBoards] = createSignal<BoardInfo[]>([]);
createEffect(() => {
const unlisten = listen<BoardInfo[]>('boards_changed', (ev) => {
logger('boards_changed', ev);
setBoards(ev.payload);
});
invoke<BoardInfo[]>('get_boards').then((boards) => {
logger('get_boards', boards);
setBoards(boards);
});
return () => {
unlisten.then((unlisten) => unlisten());
};
});
return (
<ol class="grid sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 p-2 gap-2">
<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">
<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>
)}
</For>
</ol>
);
};

View File

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

View File

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

View File

@ -0,0 +1,42 @@
import { Component, JSX, ParentComponent, splitProps } from 'solid-js';
import { DisplayInfo } from '../../models/display-info.model';
type DisplayInfoItemProps = {
label: string;
};
export const DisplayInfoItem: ParentComponent<DisplayInfoItemProps> = (props) => {
return (
<dl class="px-3 py-1 flex hover:bg-slate-900/50 gap-2 text-white drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)] rounded">
<dt class="uppercase w-1/2 select-all whitespace-nowrap">{props.label}</dt>
<dd class="select-all w-1/2 whitespace-nowrap">{props.children}</dd>
</dl>
);
};
type DisplayInfoPanelProps = {
display: DisplayInfo;
} & JSX.HTMLAttributes<HTMLElement>;
export const DisplayInfoPanel: Component<DisplayInfoPanelProps> = (props) => {
const [localProps, rootProps] = splitProps(props, ['display']);
return (
<section {...rootProps} class={'m-2 flex flex-col gap-1 py-2 ' + rootProps.class}>
<DisplayInfoItem label="ID">
<code>{localProps.display.id}</code>
</DisplayInfoItem>
<DisplayInfoItem label="Position">
({localProps.display.x}, {localProps.display.y})
</DisplayInfoItem>
<DisplayInfoItem label="Size">
{localProps.display.width} x {localProps.display.height}
</DisplayInfoItem>
<DisplayInfoItem label="Scale Factor">
{localProps.display.scale_factor}
</DisplayInfoItem>
<DisplayInfoItem label="is Primary">
{localProps.display.is_primary ? 'True' : 'False'}
</DisplayInfoItem>
</section>
);
};

View File

@ -0,0 +1,84 @@
import {
createEffect,
createSignal,
JSX,
onCleanup,
onMount,
ParentComponent,
} from 'solid-js';
import { displayStore, setDisplayStore } from '../../stores/display.store';
import background from '../../assets/transparent-grid-background.svg?url';
export const DisplayListContainer: ParentComponent = (props) => {
let root: HTMLElement;
const [olStyle, setOlStyle] = createSignal({
top: '0px',
left: '0px',
});
const [rootStyle, setRootStyle] = createSignal<JSX.CSSProperties>({
height: '100%',
});
const [bound, setBound] = createSignal({
left: 0,
top: 0,
right: 100,
bottom: 100,
});
const resetSize = () => {
const _bound = bound();
setDisplayStore({
viewScale: root.clientWidth / (_bound.right - _bound.left),
});
setOlStyle({
top: `${-_bound.top * displayStore.viewScale}px`,
left: `${-_bound.left * displayStore.viewScale}px`,
});
setRootStyle({
height: `${(_bound.bottom - _bound.top) * displayStore.viewScale}px`,
background: `url(${background})`,
});
};
createEffect(() => {
const boundLeft = Math.min(0, ...displayStore.displays.map((display) => display.x));
const boundTop = Math.min(0, ...displayStore.displays.map((display) => display.y));
const boundRight = Math.max(
0,
...displayStore.displays.map((display) => display.x + display.width),
);
const boundBottom = Math.max(
0,
...displayStore.displays.map((display) => display.y + display.height),
);
setBound({
left: boundLeft,
top: boundTop,
right: boundRight,
bottom: boundBottom,
});
let observer: ResizeObserver;
onMount(() => {
observer = new ResizeObserver(resetSize);
observer.observe(root);
});
onCleanup(() => {
observer?.unobserve(root);
});
});
createEffect(() => {});
return (
<section ref={root!} class="relative bg-gray-400/30" style={rootStyle()}>
<ol class="absolute" style={olStyle()}>
{props.children}
</ol>
</section>
);
};

View File

@ -0,0 +1,65 @@
import { Component, createMemo } from 'solid-js';
import { DisplayInfo } from '../../models/display-info.model';
import { displayStore } from '../../stores/display.store';
import { ledStripStore } from '../../stores/led-strip.store';
import { DisplayInfoPanel } from './display-info-panel';
import { LedStripPart } from './led-strip-part';
import { ScreenView } from './screen-view';
type DisplayViewProps = {
display: DisplayInfo;
};
export const DisplayView: Component<DisplayViewProps> = (props) => {
const size = createMemo(() => ({
width: props.display.width * displayStore.viewScale,
height: props.display.height * displayStore.viewScale,
}));
const style = createMemo(() => ({
top: `${props.display.y * displayStore.viewScale}px`,
left: `${props.display.x * displayStore.viewScale}px`,
height: `${size().height}px`,
width: `${size().width}px`,
}));
const ledStripConfigs = createMemo(() => {
console.log('ledStripConfigs', ledStripStore.strips);
return ledStripStore.strips.filter((c) => c.display_id === props.display.id);
});
return (
<section
class="absolute grid grid-cols-[16px,auto,16px] grid-rows-[16px,auto,16px] overflow-hidden"
style={style()}
>
<ScreenView
class="row-start-2 col-start-2 group"
displayId={props.display.id}
style={{
'object-fit': 'contain',
}}
>
<DisplayInfoPanel
display={props.display}
class="absolute bg-slate-700/20 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded backdrop-blur w-1/3 min-w-[300px] text-black group-hover:opacity-100 opacity-0 transition-opacity"
/>
</ScreenView>
<LedStripPart
class="row-start-1 col-start-2 flex-row overflow-hidden"
config={ledStripConfigs().find((c) => c.border === 'Top')}
/>
<LedStripPart
class="row-start-2 col-start-1 flex-col overflow-hidden"
config={ledStripConfigs().find((c) => c.border === 'Left')}
/>
<LedStripPart
class="row-start-2 col-start-3 flex-col overflow-hidden"
config={ledStripConfigs().find((c) => c.border === 'Right')}
/>
<LedStripPart
class="row-start-3 col-start-2 flex-row overflow-hidden"
config={ledStripConfigs().find((c) => c.border === 'Bottom')}
/>
</section>
);
};

View File

@ -0,0 +1,106 @@
import { createEffect, onCleanup } from 'solid-js';
import { invoke } from '@tauri-apps/api/tauri';
import { DisplayView } from './display-view';
import { DisplayListContainer } from './display-list-container';
import { displayStore, setDisplayStore } from '../../stores/display.store';
import { LedStripConfigContainer } from '../../models/led-strip-config';
import { setLedStripStore } from '../../stores/led-strip.store';
import { listen } from '@tauri-apps/api/event';
import { LedStripPartsSorter } from './led-strip-parts-sorter';
import { 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) => {
setDisplayStore({
displays: JSON.parse(displays),
});
});
invoke<LedStripConfigContainer>('read_led_strip_configs').then((configs) => {
console.log(configs);
setLedStripStore(configs);
});
});
// listen to config_changed event
createEffect(() => {
const unlisten = listen('config_changed', (event) => {
const { strips, mappers } = event.payload as LedStripConfigContainer;
console.log(event.payload);
setLedStripStore({
strips,
mappers,
});
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
// listen to led_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_colors_changed', (event) => {
if (!window.document.hidden) {
const colors = event.payload;
setLedStripStore({
colors,
});
}
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
// listen to led_sorted_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_sorted_colors_changed', (event) => {
if (!window.document.hidden) {
const sortedColors = event.payload;
setLedStripStore({
sortedColors,
});
}
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
const [ledStripConfiguration, setLedStripConfiguration] = createStore<
LedStripConfigurationContextType[0]
>({
selectedStripPart: null,
});
const ledStripConfigurationContextValue: LedStripConfigurationContextType = [
ledStripConfiguration,
{
setSelectedStripPart: (v) => {
setLedStripConfiguration({
selectedStripPart: v,
});
},
},
];
return (
<div>
<LedStripConfigurationContext.Provider value={ledStripConfigurationContextValue}>
<LedStripPartsSorter />
<DisplayListContainer>
{displayStore.displays.map((display) => {
return <DisplayView display={display} />;
})}
</DisplayListContainer>
</LedStripConfigurationContext.Provider>
</div>
);
};

View File

@ -0,0 +1,137 @@
import { invoke } from '@tauri-apps/api';
import {
Component,
createEffect,
createMemo,
createRoot,
createSignal,
For,
JSX,
splitProps,
useContext,
} from 'solid-js';
import { useTippy } from 'solid-tippy';
import { followCursor } from 'tippy.js';
import { LedStripConfig } from '../../models/led-strip-config';
import { LedStripConfigurationContext } from '../../contexts/led-strip-configuration.context';
import { ledStripStore } from '../../stores/led-strip.store';
type LedStripPartProps = {
config?: LedStripConfig | null;
} & JSX.HTMLAttributes<HTMLElement>;
type PixelProps = {
color: string;
};
export const Pixel: Component<PixelProps> = (props) => {
const style = createMemo(() => ({
background: props.color,
}));
return (
<div
class="flex-auto flex h-full w-full justify-center items-center relative"
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"
style={style()}
/>
</div>
);
};
export const LedStripPart: Component<LedStripPartProps> = (props) => {
const [localProps, rootProps] = splitProps(props, ['config']);
const [stripConfiguration] = useContext(LedStripConfigurationContext);
const [colors, setColors] = createSignal<string[]>([]);
// update led strip colors from global store
createEffect(() => {
if (!localProps.config) {
return;
}
const index = ledStripStore.strips.findIndex(
(s) =>
s.display_id === localProps.config!.display_id &&
s.border === localProps.config!.border,
);
if (index === -1) {
return;
}
const mapper = ledStripStore.mappers[index];
if (!mapper) {
return;
}
const offset = mapper.pos * 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]
})`;
});
setColors(colors);
});
const [anchor, setAnchor] = createSignal<HTMLElement>();
useTippy(anchor, {
hidden: true,
props: {
trigger: 'mouseenter focus',
followCursor: true,
plugins: [followCursor],
content: () =>
createRoot(() => {
return (
<span class="rounded-lg bg-slate-400/50 backdrop-blur text-white p-2 drop-shadow">
Count: {localProps.config?.len ?? '--'}
</span>
);
}) as Element,
},
});
const onWheel = (e: WheelEvent) => {
if (localProps.config) {
invoke('patch_led_strip_len', {
displayId: localProps.config.display_id,
border: localProps.config.border,
deltaLen: e.deltaY > 0 ? 1 : -1,
})
.then(() => {})
.catch((e) => {
console.error(e);
});
}
};
return (
<section
{...rootProps}
ref={setAnchor}
class={
'flex rounded-full flex-nowrap justify-around items-center overflow-hidden ' +
rootProps.class
}
classList={{
'ring ring-inset bg-yellow-400/50 ring-orange-400 animate-pulse':
stripConfiguration.selectedStripPart?.border === localProps.config?.border &&
stripConfiguration.selectedStripPart?.displayId ===
localProps.config?.display_id,
}}
onWheel={onWheel}
>
<For each={colors()}>{(item) => <Pixel color={item} />}</For>
</section>
);
};

View File

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

View File

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

View File

@ -0,0 +1,22 @@
import { Component, JSX } from 'solid-js';
type Props = {
value?: number;
} & JSX.HTMLAttributes<HTMLInputElement>;
export const ColorSlider: Component<Props> = (props) => {
return (
<input
type="range"
{...props}
max={1}
min={0}
step={0.01}
value={props.value}
class={
'w-full h-2 bg-gradient-to-r rounded-lg appearance-none cursor-pointer dark:bg-gray-700 drop-shadow ' +
props.class
}
/>
);
};

View File

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

View File

@ -0,0 +1,131 @@
import { listen } from '@tauri-apps/api/event';
import { Component, createEffect, onCleanup } 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 { VsClose } from 'solid-icons/vs';
import { BiRegularReset } from 'solid-icons/bi';
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>
);
};
export const WhiteBalance = () => {
// listen to config_changed event
createEffect(() => {
const unlisten = listen('config_changed', (event) => {
const { strips, mappers, color_calibration } =
event.payload as LedStripConfigContainer;
console.log(event.payload);
setLedStripStore({
strips,
mappers,
colorCalibration: color_calibration,
});
});
onCleanup(() => {
unlisten.then((unlisten) => 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 exit = () => {
window.history.back();
};
const reset = () => {
invoke('set_color_calibration', {
calibration: new ColorCalibration(),
}).catch((error) => console.log(error));
};
return (
<section class="select-none text-stone-800">
<div
class="absolute top-0 left-0 right-0 bottom-0"
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>
<ColorSlider
class="from-cyan-500 to-red-500"
value={ledStripStore.colorCalibration.r}
onInput={(ev) =>
updateColorCalibration(
'r',
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
)
}
/>
<Value value={ledStripStore.colorCalibration.r} />
</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}
onInput={(ev) =>
updateColorCalibration(
'g',
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
)
}
/>
<Value value={ledStripStore.colorCalibration.g} />
</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}
onInput={(ev) =>
updateColorCalibration(
'b',
(ev.target as HTMLInputElement).valueAsNumber ?? 1,
)
}
/>
<Value value={ledStripStore.colorCalibration.b} />
</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"
>
<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} />
</button>
</div>
</section>
);
};

View File

@ -1,62 +0,0 @@
import { isNil } from 'ramda';
import { FC, Fragment, useMemo } from 'react';
import tw, { css, styled } from 'twin.macro';
import { useLedCount } from '../contents/led-count';
import { PixelRgb } from '../models/pixel-rgb';
import { StyledPixel } from './styled-pixel';
import { BorderLedStrip } from './completed-led-strip';
const StyledCompletedContainerBorder = styled.section(
tw`dark:shadow-xl border-gray-600 bg-black border rounded-full flex flex-nowrap justify-around items-center p-px h-3 mb-1`,
css`
grid-column: 1 / -1;
grid-row: 1 / span 1;
justify-self: stretch;
`,
);
interface StyledCompletedContainerProps {
borderLedStrips: BorderLedStrip[];
overrideBorderLedStrips?: BorderLedStrip[];
}
export const CompletedContainer: FC<StyledCompletedContainerProps> = ({
borderLedStrips,
overrideBorderLedStrips,
}) => {
const { ledCount } = useLedCount();
const completedPixels = useMemo(() => {
const completed: PixelRgb[] = new Array(ledCount).fill([0, 0, 0]);
(overrideBorderLedStrips ?? borderLedStrips).forEach(({ pixels, config }) => {
if (isNil(config)) {
return;
}
if (config.global_start_position <= config.global_end_position) {
pixels.forEach((color, i) => {
completed[config.global_start_position + i] = color;
});
} else {
pixels.forEach((color, i) => {
completed[config.global_start_position - i] = color;
});
}
});
return completed.map((color, i) => (
<StyledPixel
rgb={color}
key={i}
css={css`
grid-row-start: 1;
grid-column-start: ${i + 1};
align-self: flex-start;
`}
/>
));
}, [ledCount, borderLedStrips, overrideBorderLedStrips]);
return (
<Fragment>
<StyledCompletedContainerBorder />
{completedPixels}
</Fragment>
);
};

View File

@ -1,119 +0,0 @@
import debug from 'debug';
import { lensPath, set, splitEvery, update } from 'ramda';
import { FC, useEffect, useMemo, useState } from 'react';
import tw, { css, styled } from 'twin.macro';
import { Borders, borders } from '../../constants/border';
import { useLedCount } from '../contents/led-count';
import { DisplayConfig, LedStripConfigOfBorders } from '../models/display-config';
import { LedStripConfig } from '../models/led-strip-config';
import { PixelRgb } from '../models/pixel-rgb';
import { ScreenshotDto } from '../models/screenshot.dto';
import { CompletedContainer } from './completed-container';
import { DraggableStrip } from './draggable-strip';
export const logger = debug('app:completed-led-strip');
interface CompletedLedStripProps {
screenshots: ScreenshotDto[];
onDisplayConfigChange?: (value: DisplayConfig) => void;
}
export type BorderLedStrip = {
pixels: PixelRgb[];
config: LedStripConfig | null;
};
const StyledContainer = styled.section(
({ rows, columns }: { rows: number; columns: number }) => [
tw`grid m-4 pb-2 items-center justify-items-center select-none`,
tw`overflow-x-auto overflow-y-hidden flex-none`,
css`
grid-template-columns: repeat(${columns}, 0.5em);
grid-template-rows: auto repeat(${rows}, 0.5em);
`,
],
);
export const CompletedLedStrip: FC<CompletedLedStripProps> = ({
screenshots,
onDisplayConfigChange,
}) => {
const borderLedStrips: BorderLedStrip[] = useMemo(() => {
return screenshots.flatMap((ss) =>
borders.map((b) => ({
pixels: splitEvery(3, Array.from(ss.colors[b])) as PixelRgb[],
config: ss.config.led_strip_of_borders[b],
})),
);
}, [screenshots]);
const ledCount = useMemo(
() => borderLedStrips.reduce((prev, curr) => prev + curr.pixels.length, 0),
[borderLedStrips],
);
const maxIndex = useMemo(
() =>
Math.max(
...borderLedStrips
.map((s) => [
s.config?.global_end_position ?? 0,
s.config?.global_start_position ?? 0,
])
.flat(),
),
[borderLedStrips],
);
const { setLedCount } = useLedCount();
// setLedCount for context
useEffect(() => {
setLedCount(ledCount);
}, [ledCount, setLedCount]);
const [overrideBorderLedStrips, setOverrideBorderLedStrips] =
useState<BorderLedStrip[]>();
const strips = useMemo(() => {
return borderLedStrips.map(({ config, pixels }, index) =>
config ? (
<DraggableStrip
key={index}
{...{ config, pixels, index: index + 1 }}
onConfigChange={(c) => {
setOverrideBorderLedStrips(
update(index, { config: c, pixels }, borderLedStrips),
);
}}
onConfigFinish={(c) => {
const indexOfDisplay = Math.floor(index / borders.length);
const xLens = lensPath<LedStripConfigOfBorders, Borders>([
borders[index % borders.length],
]);
const displayConfig: DisplayConfig = {
...screenshots[indexOfDisplay].config,
led_strip_of_borders: set(
xLens,
c,
screenshots[indexOfDisplay].config.led_strip_of_borders,
),
};
onDisplayConfigChange?.(displayConfig);
}}
/>
) : (
<div key={index} />
),
);
}, [borderLedStrips, screenshots]);
useEffect(() => {
setOverrideBorderLedStrips(undefined);
}, [borderLedStrips]);
return (
<StyledContainer rows={screenshots.length * borders.length} columns={maxIndex + 1}>
<CompletedContainer
borderLedStrips={borderLedStrips}
overrideBorderLedStrips={overrideBorderLedStrips}
/>
{strips}
</StyledContainer>
);
};

View File

@ -1,100 +0,0 @@
import { HTMLAttributes, useCallback, useMemo } from 'react';
import { FC } from 'react';
import { DisplayConfig, LedStripConfigOfBorders } from '../models/display-config';
import { LedStrip } from './led-strip';
import tw, { css, styled, theme } from 'twin.macro';
import { ScreenshotDto } from '../models/screenshot.dto';
import { LedStripEditor } from './led-strip-editor';
import { LedStripConfig } from '../models/led-strip-config';
import debug from 'debug';
import { lensPath, lensProp, set, view } from 'ramda';
const logger = debug('app:display-with-led-strips');
export interface DisplayWithLedStripsProps
extends Omit<HTMLAttributes<HTMLElement>, 'onChange'> {
config: DisplayConfig;
screenshot: ScreenshotDto;
onChange?: (config: DisplayConfig) => void;
}
const StyledContainer = styled.section(
tw`m-4 grid gap-1`,
css`
grid-template-columns: ${theme`width.5`} ${theme`width.3`} auto ${theme`width.3`} ${theme`width.5`};
`,
css`
grid-template-rows: ${theme`width.5`} ${theme`width.3`} auto ${theme`width.3`} ${theme`width.5`};
`,
);
export const DisplayWithLedStrips: FC<DisplayWithLedStripsProps> = ({
config,
screenshot,
onChange,
...htmlAttrs
}) => {
const screenshotUrl = useMemo(
() => `data:image/ico;base64,${screenshot.encode_image}`,
[screenshot.encode_image],
);
const onLedStripConfigChange = useCallback(
(position: keyof LedStripConfigOfBorders, value: LedStripConfig | null) => {
const xLens = lensPath<
DisplayConfig,
'led_strip_of_borders',
keyof LedStripConfigOfBorders
>(['led_strip_of_borders', position]);
const c = set(xLens, value, config);
logger('on change. prev: %o, curr: %o', view(xLens, config), value);
onChange?.(c);
},
[config],
);
return (
<StyledContainer {...htmlAttrs}>
<img src={screenshotUrl} tw="row-start-3 col-start-3 w-full" />
<LedStrip
config={config.led_strip_of_borders.top}
colors={screenshot.colors.top}
tw="row-start-2 col-start-3"
/>
<LedStrip
config={config.led_strip_of_borders.left}
colors={screenshot.colors.left}
tw="row-start-3 col-start-2"
/>
<LedStrip
config={config.led_strip_of_borders.right}
colors={screenshot.colors.right}
tw="row-start-3 col-start-4"
/>
<LedStrip
config={config.led_strip_of_borders.bottom}
colors={screenshot.colors.bottom}
tw="row-start-4 col-start-3"
/>
<LedStripEditor
config={config.led_strip_of_borders.top}
tw="row-start-1 col-start-3"
onChange={(value) => onLedStripConfigChange('top', value)}
/>
<LedStripEditor
config={config.led_strip_of_borders.left}
tw="row-start-3 col-start-1"
onChange={(value) => onLedStripConfigChange('left', value)}
/>
<LedStripEditor
config={config.led_strip_of_borders.right}
tw="row-start-3 col-start-5"
onChange={(value) => onLedStripConfigChange('right', value)}
/>
<LedStripEditor
config={config.led_strip_of_borders.bottom}
tw="row-start-5 col-start-3"
onChange={(value) => onLedStripConfigChange('bottom', value)}
/>
</StyledContainer>
);
};

View File

@ -1,199 +0,0 @@
import {
createRef,
FC,
Fragment,
MouseEventHandler,
ReactNode,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { css } from 'twin.macro';
import { useLedCount } from '../contents/led-count';
import { LedStripConfig } from '../models/led-strip-config';
import { PixelRgb } from '../models/pixel-rgb';
import { StyledPixel } from './styled-pixel';
import { logger } from './completed-led-strip';
interface DraggableStripProp {
config: LedStripConfig;
pixels: PixelRgb[];
index: number;
onConfigChange?: (config: LedStripConfig) => void;
onConfigFinish?: (config: LedStripConfig) => void;
}
export const DraggableStrip: FC<DraggableStripProp> = ({
config,
pixels,
index,
onConfigChange,
onConfigFinish,
}) => {
const { ledCount } = useLedCount();
const startXRef = useRef(0);
const currentXRef = useRef(0);
const configRef = useRef<LedStripConfig>();
const [availableConfig, setAvailableConfig] = useState<LedStripConfig>(config);
// const currentDiffRef = useRef(0);
const isDragRef = useRef(false);
const handleMouseMoveRef = useRef<(ev: MouseEvent) => void>();
const [boxTranslateX, setBoxTranslateX] = useState(0);
const ledItems = useMemo(() => {
const step = config.global_start_position - config.global_end_position < 0 ? 1 : -1;
return pixels.map((rgb, i) => (
<StyledPixel
key={i}
rgb={rgb}
css={css`
grid-column: ${(availableConfig.global_start_position ?? 0) + i * step + 1} /
span 1;
grid-row-start: ${index + 1};
pointer-events: none;
`}
/>
));
}, [pixels, availableConfig]);
const [placeholders, placeholderRefs]: [ReactNode[], RefObject<HTMLSpanElement>[]] =
useMemo(
() =>
new Array(ledCount)
.fill(undefined)
.map((_, i) => {
const ref = createRef<HTMLSpanElement>();
const n = (
<span
ref={ref}
key={i}
tw=" h-full w-full"
css={css`
grid-column-start: ${i + 1};
grid-row-start: ${index + 1};
`}
/>
);
return [n, ref] as [ReactNode, RefObject<HTMLSpanElement>];
})
.reduce(
([nList, refList], [n, ref]) => [
[...nList, n],
[...refList, ref],
],
[[], []] as [ReactNode[], RefObject<HTMLSpanElement>[]],
),
[ledCount],
);
// start and moving
const handleMouseDown: MouseEventHandler<HTMLDivElement> = useCallback(
(ev) => {
startXRef.current = ev.pageX;
ev.currentTarget.requestPointerLock();
isDragRef.current = true;
logger('handleMouseDown, config: %o', config);
const placeholderPositions = placeholderRefs.map((it) => {
if (!it.current) {
return [0, 0];
}
const viewportOffset = it.current.getBoundingClientRect();
return [viewportOffset.left, viewportOffset.right] as [number, number];
});
logger('placeholderPositions: %o', placeholderPositions);
// set init position
const initPos = placeholderPositions.findIndex(
([l, r]) => l <= ev.pageX && r >= ev.pageX,
);
let prevMatch = 0;
if (handleMouseMoveRef.current) {
document.body.removeEventListener('mousemove', handleMouseMoveRef.current);
}
handleMouseMoveRef.current = (ev) => {
if (!isDragRef.current) {
return;
}
currentXRef.current = ev.pageX;
setBoxTranslateX(currentXRef.current - startXRef.current);
const match = placeholderPositions.findIndex(
([l, r]) => l <= currentXRef.current && r >= currentXRef.current,
);
if (match === -1) {
return;
}
if (match === prevMatch) {
return;
}
prevMatch = match;
const diff = match - initPos;
const newValue: LedStripConfig = {
...config,
global_start_position: config.global_start_position + diff,
global_end_position: config.global_end_position + diff,
};
configRef.current = newValue;
setAvailableConfig(newValue);
logger('change config. old: %o, new: %o', config, newValue);
onConfigChange?.(newValue);
};
document.body.addEventListener('mousemove', handleMouseMoveRef.current);
},
[placeholderRefs, availableConfig, setAvailableConfig, config],
);
// move event.
useEffect(() => {
const handleMouseUp = (ev: MouseEvent) => {
if (configRef.current && isDragRef.current) {
onConfigFinish?.(configRef.current);
}
startXRef.current = 0;
isDragRef.current = false;
document.exitPointerLock();
if (handleMouseMoveRef.current) {
document.body.removeEventListener('mousemove', handleMouseMoveRef.current);
}
};
document.body.addEventListener('mouseup', handleMouseUp);
return () => {
document.body.removeEventListener('mouseup', handleMouseUp);
};
}, [onConfigFinish]);
// reset translateX when config updated.
useEffect(() => {
startXRef.current = currentXRef.current;
setAvailableConfig(config);
setBoxTranslateX(0);
logger('useEffect, config: %o', config);
}, [config]);
return (
<Fragment>
{placeholders}
{ledItems}
<div
tw="border border-gray-700 h-3 w-full rounded-full"
css={css`
grid-column: ${Math.min(
config?.global_start_position ?? 0,
config?.global_end_position ?? 0,
) + 1} / span
${Math.abs(config?.global_start_position - config?.global_end_position) + 1};
grid-row-start: ${index + 1};
cursor: ew-resize;
transform: translateX(${boxTranslateX}px);
`}
onMouseDown={handleMouseDown}
></div>
</Fragment>
);
};

View File

@ -1,104 +0,0 @@
import { HTMLAttributes, MouseEventHandler, useCallback } from 'react';
import { FC } from 'react';
import { LedStripConfig } from '../models/led-strip-config';
import tw, { styled } from 'twin.macro';
import { faLeftRight, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export interface LedStripEditorProps
extends Omit<HTMLAttributes<HTMLElement>, 'onChange'> {
config: LedStripConfig | null;
onChange?: (config: LedStripConfig | null) => void;
}
const StyledContainer = styled.section(
tw`flex flex-wrap gap-2 self-start justify-self-start`,
);
const StyledButton = styled.button(
tw`
bg-yellow-500 dark:bg-amber-600 rounded-full h-4 w-4 text-xs shadow select-none`,
tw`hocus:scale-105 hocus:active:scale-95 active:bg-amber-600 active:dark:bg-amber-500`,
);
export const LedStripEditor: FC<LedStripEditorProps> = ({
config,
onChange,
...htmlAttrs
}) => {
const addLed: MouseEventHandler = useCallback(
(ev) => {
ev.preventDefault();
const delta = ev.button === 2 ? 10 : 1;
if (config) {
if (config.global_start_position <= config.global_end_position) {
onChange?.({
...config,
global_end_position: config.global_end_position + delta,
});
} else {
onChange?.({
...config,
global_start_position: config.global_start_position + delta,
});
}
} else {
onChange?.(new LedStripConfig(0, 0, 0));
}
},
[config, onChange],
);
const removeLed: MouseEventHandler = useCallback(
(ev) => {
ev.preventDefault();
const delta = ev.button === 2 ? 10 : 1;
if (!config) {
onChange?.(null);
} else if (
Math.abs(config.global_start_position - config.global_end_position) <= delta
) {
onChange?.(null);
} else {
if (config.global_start_position <= config.global_end_position) {
onChange?.({
...config,
global_end_position: config.global_end_position - delta,
});
} else {
onChange?.({
...config,
global_start_position: config.global_start_position - delta,
});
}
}
},
[config, onChange],
);
const reverse = useCallback(() => {
if (!config) {
return;
}
onChange?.({
...config,
global_start_position: config.global_end_position,
global_end_position: config.global_start_position,
});
}, [config, onChange]);
return (
<StyledContainer {...htmlAttrs}>
<StyledButton title="Add LED" onClick={addLed} onContextMenu={addLed}>
<FontAwesomeIcon icon={faPlus} />
</StyledButton>
<StyledButton title="Remove LED" onClick={removeLed} onContextMenu={removeLed}>
<FontAwesomeIcon icon={faMinus} />
</StyledButton>
<StyledButton title="Reverse" onClick={reverse}>
<FontAwesomeIcon icon={faLeftRight} />
</StyledButton>
{`s: ${config?.global_start_position ?? 'x'}, e: ${
config?.global_end_position ?? 'x'
}`}
</StyledContainer>
);
};

View File

@ -1,28 +0,0 @@
import { HTMLAttributes, useMemo } from 'react';
import { FC } from 'react';
import { LedStripConfig } from '../models/led-strip-config';
import tw, { css, styled } from 'twin.macro';
import { splitEvery } from 'ramda';
import { StyledPixel } from './styled-pixel';
export interface LedStripProps extends HTMLAttributes<HTMLElement> {
config: LedStripConfig | null;
colors: Uint8Array;
}
const StyledContainer = styled.section(
tw`dark:bg-transparent shadow-xl border-gray-500 border rounded-full flex flex-wrap justify-around items-center -mx-px -mt-px`,
css``,
);
export const LedStrip: FC<LedStripProps> = ({ config, colors, ...htmlAttrs }) => {
const pixels = useMemo(() => {
const pixels = splitEvery(3, Array.from(colors)) as Array<[number, number, number]>;
return pixels.map((rgb, index) => <StyledPixel key={index} rgb={rgb}></StyledPixel>);
}, [colors]);
return (
<StyledContainer {...htmlAttrs} css={[!config && tw`bg-gray-200`]}>
{pixels}
</StyledContainer>
);
};

View File

@ -1,10 +0,0 @@
import tw, { css, styled } from 'twin.macro';
export const StyledPixel = styled.span(
({ rgb: [r, g, b] }: { rgb: [number, number, number] }) => [
tw`rounded-full h-3 w-3 bg-current block border border-gray-700`,
css`
color: rgb(${r}, ${g}, ${b});
`,
],
);

View File

@ -1,146 +0,0 @@
import { invoke } from '@tauri-apps/api';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import tw, { styled } from 'twin.macro';
import { useAsync, useAsyncCallback } from 'react-async-hook';
import { DisplayWithLedStrips } from './components/display-with-led-strips';
import { PickerConfiguration } from './models/picker-configuration';
import { DisplayConfig } from './models/display-config';
import { ScreenshotDto } from './models/screenshot.dto';
import { Alert, Fab, Snackbar } from '@mui/material';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faRotateBack, faSpinner } from '@fortawesome/free-solid-svg-icons';
import { update } from 'ramda';
import { CompletedLedStrip } from './components/completed-led-strip';
import { LedCountProvider } from './contents/led-count';
import debug from 'debug';
import { useSnackbar } from 'notistack';
import { fillParentCss } from '../styles/fill-parent';
const logger = debug('app:configurator');
const getPickerConfig = () => invoke<PickerConfiguration>('get_picker_config');
const getScreenshotOfDisplays = () =>
invoke<ScreenshotDto[]>('take_snapshot').then((items) => {
return items;
});
const getScreenshotByConfig = async (config: DisplayConfig) => {
return await invoke<ScreenshotDto>('get_screenshot_by_config', {
config,
});
};
const writePickerConfig = async (config: PickerConfiguration) => {
return await invoke<void>('write_picker_config', {
config,
});
};
const StyledConfiguratorContainer = styled.section(
tw`flex flex-col items-stretch relative overflow-hidden`,
fillParentCss,
);
const StyledDisplayContainer = styled.section(tw`overflow-auto`);
const ConfiguratorInner: FC = () => {
const { enqueueSnackbar } = useSnackbar();
const { loading: pendingPickerConfig, result: savedPickerConfig } = useAsync(
getPickerConfig,
[],
);
const { loading: pendingScreenshotOfDisplays, result: defaultScreenshotOfDisplays } =
useAsync(getScreenshotOfDisplays, []);
const [screenshotOfDisplays, setScreenshotOfDisplays] = useState<ScreenshotDto[]>([]);
const { loading: pendingGetLedColorsByConfig, execute: onDisplayConfigChange } =
useAsyncCallback(async (value: DisplayConfig) => {
const screenshot = await getScreenshotByConfig(value);
setScreenshotOfDisplays((old) => {
const index = old.findIndex((it) => it.config.id === screenshot.config.id);
const newValue = update(index, screenshot, old);
savedPickerConfig &&
writePickerConfig({
...savedPickerConfig,
display_configs: newValue.map((it) => it.config),
});
return newValue;
});
});
const [displayConfigs, setDisplayConfigs] = useState<DisplayConfig[]>([]);
useEffect(() => {
const displayConfigs = savedPickerConfig?.display_configs;
if (displayConfigs && defaultScreenshotOfDisplays) {
setDisplayConfigs(displayConfigs);
setScreenshotOfDisplays(defaultScreenshotOfDisplays);
(async () => {
for (const config of displayConfigs) {
await onDisplayConfigChange(config);
}
})().then();
}
}, [savedPickerConfig, onDisplayConfigChange, defaultScreenshotOfDisplays]);
const resetBackToDefaultConfig = useCallback(() => {
if (defaultScreenshotOfDisplays) {
setScreenshotOfDisplays(defaultScreenshotOfDisplays);
} else {
enqueueSnackbar('Default Config was not found. please try again later.', {
variant: 'error',
});
}
}, [setScreenshotOfDisplays, defaultScreenshotOfDisplays]);
const displays = useMemo(() => {
if (screenshotOfDisplays) {
return screenshotOfDisplays.map((screenshot, index) => (
<DisplayWithLedStrips
key={index}
config={screenshot.config}
screenshot={screenshot}
onChange={(value) => onDisplayConfigChange(value)}
/>
));
}
}, [displayConfigs, screenshotOfDisplays]);
if (pendingPickerConfig || pendingScreenshotOfDisplays) {
return (
<section>
{JSON.stringify({ pendingPickerConfig, pendingScreenshotOfDisplays })}
</section>
);
}
return (
<StyledConfiguratorContainer>
<CompletedLedStrip
screenshots={screenshotOfDisplays}
onDisplayConfigChange={onDisplayConfigChange}
/>
<StyledDisplayContainer tw="overflow-y-auto">{displays}</StyledDisplayContainer>
<Fab
aria-label="reset"
size="small"
tw="top-2 right-2 absolute"
onClick={resetBackToDefaultConfig}
>
<FontAwesomeIcon icon={faRotateBack} />
</Fab>
<Snackbar open={pendingGetLedColorsByConfig} autoHideDuration={3000}>
<Alert icon={<FontAwesomeIcon icon={faSpinner} />} sx={{ width: '100%' }}>
This is a success message!
</Alert>
</Snackbar>
</StyledConfiguratorContainer>
);
};
export const Configurator = () => {
return (
<LedCountProvider>
<ConfiguratorInner />
</LedCountProvider>
);
};

View File

@ -1,25 +0,0 @@
import {
createContext,
Dispatch,
FC,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react';
interface LedCountContext {
ledCount: number;
setLedCount: Dispatch<SetStateAction<number>>;
}
const Context = createContext<LedCountContext>(undefined as any);
export const LedCountProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [ledCount, setLedCount] = useState(0);
return (
<Context.Provider value={{ ledCount, setLedCount }}>{children}</Context.Provider>
);
};
export const useLedCount = () => useContext(Context);

View File

@ -1,21 +0,0 @@
import { Borders } from '../../constants/border';
import { LedStripConfig } from "./led-strip-config";
export class LedStripConfigOfBorders implements Record<Borders, LedStripConfig | null> {
constructor(
public top: LedStripConfig | null = null,
public bottom: LedStripConfig | null = null,
public left: LedStripConfig | null = null,
public right: LedStripConfig | null = null,
) {}
}
export class DisplayConfig {
led_strip_of_borders = new LedStripConfigOfBorders();
constructor(
public id: number,
public index_of_display: number,
public display_width: number,
public display_height: number,
) {}
}

View File

@ -1,7 +0,0 @@
export class LedStripConfig {
constructor(
public index: number,
public global_start_position: number,
public global_end_position: number,
) {}
}

View File

@ -1,8 +0,0 @@
import { DisplayConfig } from './display-config';
export class PickerConfiguration {
constructor(
public display_configs: DisplayConfig[] = [],
public config_version: number = 1,
) {}
}

View File

@ -1,12 +0,0 @@
import { DisplayConfig } from './display-config';
export class ScreenshotDto {
encode_image!: string;
config!: DisplayConfig;
colors!: {
top: Uint8Array;
bottom: Uint8Array;
left: Uint8Array;
right: Uint8Array;
};
}

View File

@ -1,2 +1,2 @@
export const borders = ['top', 'right', 'bottom', 'left'] as const;
export const borders = ['Top', 'Right', 'Bottom', 'Left'] as const;
export type Borders = typeof borders[number];

View File

@ -0,0 +1,24 @@
import { createContext } from 'solid-js';
import { Borders } from '../constants/border';
export type LedStripConfigurationContextType = [
{
selectedStripPart: {
displayId: number;
border: Borders;
} | null;
},
{
setSelectedStripPart: (v: { displayId: number; border: Borders } | null) => void;
},
];
export const LedStripConfigurationContext =
createContext<LedStripConfigurationContextType>([
{
selectedStripPart: null,
},
{
setSelectedStripPart: () => {},
},
]);

15
src/index.tsx Normal file
View File

@ -0,0 +1,15 @@
/* @refresh reload */
import { render } from "solid-js/web";
import "./styles.css";
import App from "./App";
import { Router } from '@solidjs/router';
render(
() => (
<Router>
<App />
</Router>
),
document.getElementById('root') as HTMLElement,
);

View File

@ -1,14 +0,0 @@
import { SnackbarProvider } from 'notistack';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import GlobalStyles from './styles/global-styles';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<GlobalStyles />
<SnackbarProvider maxSnack={3}>
<App />
</SnackbarProvider>
</React.StrictMode>,
);

View File

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

View File

@ -0,0 +1,11 @@
export class DisplayInfo {
constructor(
public id: number,
public x: number,
public y: number,
public width: number,
public height: number,
public scale_factor: number,
public is_primary: boolean,
) {}
}

View File

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

View File

@ -0,0 +1,27 @@
import { Borders } from '../constants/border';
export type LedStripPixelMapper = {
start: number;
end: number;
pos: number;
};
export class ColorCalibration {
r: number = 1;
g: number = 1;
b: number = 1;
}
export type LedStripConfigContainer = {
strips: LedStripConfig[];
mappers: LedStripPixelMapper[];
color_calibration: ColorCalibration;
};
export class LedStripConfig {
constructor(
public readonly display_id: number,
public readonly border: Borders,
public len: number,
) {}
}

View File

@ -0,0 +1,7 @@
import { createStore } from 'solid-js/store';
import { DisplayInfo } from '../models/display-info.model';
export const [displayStore, setDisplayStore] = createStore({
displays: new Array<DisplayInfo>(),
viewScale: 0.2,
});

View File

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

View File

@ -1,106 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
}

3
src/styles.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,3 +0,0 @@
import tw from 'twin.macro';
export const fillParentCss = tw`w-full h-full overflow-hidden`;

View File

@ -1,35 +0,0 @@
import React from 'react';
import { Global, css } from '@emotion/react';
import tw, { theme, GlobalStyles as BaseStyles } from 'twin.macro';
import { fillParentCss } from './fill-parent';
const customStyles = css({
body: {
WebkitTapHighlightColor: theme`colors.purple.500`,
...tw`antialiased`,
...tw`dark:bg-dark-800 bg-dark-100 dark:text-gray-100 text-gray-800`,
...fillParentCss,
},
html: {
...fillParentCss,
},
'#root': {
...fillParentCss,
},
'::-webkit-scrollbar': tw`w-1.5 h-1.5`,
'::-webkit-scrollbar-button': tw`hidden`,
'::-webkit-scrollbar-track': tw`bg-transparent`,
'::-webkit-scrollbar-track-piece': tw`bg-transparent`,
'::-webkit-scrollbar-thumb': tw`bg-gray-300 dark:(bg-gray-700/50 hover:bg-gray-700/90 active:bg-gray-700/100) transition-colors rounded-full`,
'::-webkit-scrollbar-corner': tw`bg-gray-600`,
'::-webkit-resizer': tw`hidden`,
});
const GlobalStyles = () => (
<>
<BaseStyles />
<Global styles={customStyles} />
</>
);
export default GlobalStyles;

1
src/vite-env.d.ts vendored
View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

Some files were not shown because too many files have changed in this diff Show More