Compare commits

..

No commits in common. "master" and "boardcast-screenshot" have entirely different histories.

87 changed files with 4329 additions and 5939 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'],
},
],
},
};

24
.gitignore vendored
View File

@ -1,24 +1,2 @@
# 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?
dist

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

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

@ -0,0 +1,7 @@
{
"files.autoSave": "onWindowChange",
"cSpell.words": [
"Itertools",
"Leds"
]
}

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

@ -0,0 +1,29 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "ui:dev",
"type": "shell",
// `dev` keeps running in the background
// ideally you should also configure a `problemMatcher`
// see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson
"isBackground": true,
// change this to your `beforeDevCommand`:
"command": "yarn",
"args": [
"dev"
]
},
{
"label": "ui:build",
"type": "shell",
// change this to your `beforeBuildCommand`:
"command": "yarn",
"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,29 @@
{
"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",
"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-js": "^1.4.7",
"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",
"@types/node": "^18.7.10",
"autoprefixer": "^10.4.14",
"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"
"tailwindcss": "^3.2.7",
"typescript": "^4.7.4",
"vite": "^4.0.0",
"vite-plugin-solid": "^2.3.0"
}
}

2385
pnpm-lock.yaml generated

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: {},
},
}

2125
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +1,39 @@
[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"
[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
custom-protocol = [ "tauri/custom-protocol" ]
[dev-dependencies]
test_dir = "0.2.0"
# this feature is used for production builds or when `devPath` points to the filesystem
# DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

View File

@ -0,0 +1,133 @@
use std::env::current_dir;
use display_info::DisplayInfo;
use paris::{error, info};
use serde::{Deserialize, Serialize};
use tauri::api::path::config_dir;
use crate::screenshot::{self, LedSamplePoints};
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, Serialize, Deserialize, Debug)]
pub struct LedStripConfigGroup {
pub strips: Vec<LedStripConfig>,
pub mappers: Vec<SamplePointMapper>,
}
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,
})
}
}
Ok(Self { strips, mappers })
}
}
#[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,226 @@
use std::{borrow::BorrowMut, sync::Arc};
use tauri::async_runtime::RwLock;
use tokio::sync::OnceCell;
use crate::ambient_light::{config, LedStripConfigGroup};
use super::{Border, SamplePointMapper};
pub struct ConfigManager {
config: Arc<RwLock<LedStripConfigGroup>>,
config_update_receiver: tokio::sync::watch::Receiver<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());
config_update_sender.send(configs.clone()).unwrap();
ConfigManager {
config: Arc::new(RwLock::new(configs)),
config_update_receiver,
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))?;
// log::info!("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_receiver.clone()
}
}

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,459 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use paris::warn;
use tauri::async_runtime::RwLock;
use tokio::{
sync::{broadcast, watch},
time::sleep,
};
use crate::{
ambient_light::{config, ConfigManager},
rpc::MqttRpc,
screenshot::LedSamplePoints,
screenshot_manager::{self, ScreenshotManager},
};
use itertools::Itertools;
use super::{LedStripConfigGroup, SamplePointConfig, SamplePointMapper};
pub struct LedColorsPublisher {
sorted_colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
sorted_colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
colors_rx: Arc<RwLock<watch::Receiver<Vec<u8>>>>,
colors_tx: Arc<RwLock<watch::Sender<Vec<u8>>>>,
display_colors_rx: Arc<RwLock<broadcast::Receiver<(u32, Vec<u8>)>>>,
display_colors_tx: Arc<RwLock<broadcast::Sender<(u32, Vec<u8>)>>>,
inner_tasks_version: Arc<RwLock<usize>>,
}
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());
let (display_colors_tx, display_colors_rx) = broadcast::channel(8);
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)),
display_colors_rx: Arc::new(RwLock::new(display_colors_rx)),
display_colors_tx: Arc::new(RwLock::new(display_colors_tx)),
inner_tasks_version: Arc::new(RwLock::new(0)),
}
})
.await
}
fn start_one_display_colors_fetcher(
&self,
display_id: u32,
sample_points: Vec<Vec<LedSamplePoints>>,
) {
let display_colors_tx = self.display_colors_tx.clone();
let internal_tasks_version = self.inner_tasks_version.clone();
tokio::spawn(async move {
let display_colors_tx = display_colors_tx.read().await.clone();
let colors = screenshot_manager::get_display_colors(display_id, &sample_points);
if let Err(err) = colors {
warn!("Failed to get colors: {}", err);
return;
}
let mut start: tokio::time::Instant = tokio::time::Instant::now();
let mut interval = tokio::time::interval(Duration::from_millis(66));
let init_version = internal_tasks_version.read().await.clone();
loop {
interval.tick().await;
tokio::time::sleep(Duration::from_millis(1)).await;
if internal_tasks_version.read().await.clone() != init_version {
log::info!(
"inner task version changed, stop. {} != {}",
internal_tasks_version.read().await.clone(),
init_version
);
break;
}
// log::info!("tick: {}ms", start.elapsed().as_millis());
start = tokio::time::Instant::now();
let colors = screenshot_manager::get_display_colors(display_id, &sample_points);
if let Err(err) = colors {
warn!("Failed to get colors: {}", err);
sleep(Duration::from_millis(100)).await;
continue;
}
let colors = colors.unwrap();
let color_len = colors.len();
match display_colors_tx.send((
display_id,
colors
.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);
}
};
}
});
}
fn start_all_colors_worker(&self, display_ids: Vec<u32>, mappers: Vec<SamplePointMapper>) {
let sorted_colors_tx = self.sorted_colors_tx.clone();
let colors_tx = self.colors_tx.clone();
let display_colors_rx = self.display_colors_rx.clone();
tokio::spawn(async move {
for _ in 0..10 {
let mut rx = display_colors_rx.read().await.resubscribe();
let sorted_colors_tx = sorted_colors_tx.write().await;
let colors_tx = colors_tx.write().await;
let mut all_colors: Vec<Option<Vec<u8>>> = vec![None; display_ids.len()];
let mut start: tokio::time::Instant = tokio::time::Instant::now();
log::info!("start all_colors_worker");
loop {
// log::info!("display_colors_rx changed");
let color_info = rx.recv().await;
if let Err(err) = color_info {
match err {
broadcast::error::RecvError::Closed => {
break;
}
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::info!("tick: {}ms", start.elapsed().as_millis());
start = tokio::time::Instant::now();
}
}
}
});
}
pub fn start(&self) {
let inner_tasks_version = self.inner_tasks_version.clone();
tokio::spawn(async move {
let publisher = Self::global().await;
let mut inner_tasks_version = inner_tasks_version.write().await;
*inner_tasks_version = inner_tasks_version.overflowing_add(1).0;
let config_manager = ConfigManager::global().await;
let mut config_receiver = config_manager.clone_config_update_receiver();
log::info!("waiting for config update...");
while config_receiver.changed().await.is_ok() {
log::info!("config updated, restart inner tasks...");
let configs = config_receiver.borrow().clone();
let configs = Self::get_colors_configs(&configs).await;
if let Err(err) = configs {
warn!("Failed to get configs: {}", err);
sleep(Duration::from_millis(100)).await;
continue;
}
let configs = configs.unwrap();
for sample_point_group in configs.sample_point_groups.clone() {
let display_id = sample_point_group.display_id;
let sample_points = sample_point_group.points;
publisher.start_one_display_colors_fetcher(display_id, sample_points);
}
let display_ids = configs.sample_point_groups;
publisher.start_all_colors_worker(
display_ids.iter().map(|c| c.display_id).collect(),
configs.mappers,
);
break;
}
});
// tokio::spawn(async move {
// loop {
// let sorted_colors_tx = sorted_colors_tx.write().await;
// let colors_tx = colors_tx.write().await;
// let screenshot_manager = ScreenshotManager::global().await;
// let config_manager = ConfigManager::global().await;
// let config_receiver = config_manager.clone_config_update_receiver();
// let configs = config_receiver.borrow().clone();
// let configs = Self::get_colors_configs(&configs).await;
// if let Err(err) = configs {
// warn!("Failed to get configs: {}", err);
// sleep(Duration::from_millis(100)).await;
// continue;
// }
// let configs = configs.unwrap();
// let mut merged_screenshot_receiver =
// screenshot_manager.clone_merged_screenshot_rx().await;
// let mut screenshots = HashMap::new();
// // let mut start = tokio::time::Instant::now();
// loop {
// let screenshot = merged_screenshot_receiver.recv().await;
// if let Err(err) = screenshot {
// match err {
// tokio::sync::broadcast::error::RecvError::Closed => {
// warn!("closed");
// continue;
// }
// tokio::sync::broadcast::error::RecvError::Lagged(_) => {
// warn!("lagged");
// continue;
// }
// }
// }
// let screenshot = screenshot.unwrap();
// // log::info!("got screenshot: {:?}", screenshot.display_id);
// screenshots.insert(screenshot.display_id, screenshot);
// if screenshots.len() == configs.sample_point_groups.len() {
// // log::info!("{}", start.elapsed().as_millis().to_string());
// {
// let screenshots = configs
// .sample_point_groups
// .iter()
// .map(|strip| screenshots.get(&strip.display_id).unwrap())
// .collect::<Vec<_>>();
// let colors = screenshot_manager
// .get_all_colors(&configs.sample_point_groups, &screenshots)
// .await;
// let sorted_colors =
// ScreenshotManager::get_sorted_colors(&colors, &configs.mappers)
// .await;
// match colors_tx.send(colors) {
// Ok(_) => {
// // log::info!("colors updated");
// }
// Err(_) => {
// warn!("colors update failed");
// }
// }
// match sorted_colors_tx.send(sorted_colors) {
// Ok(_) => {
// // log::info!("colors updated");
// }
// Err(_) => {
// warn!("colors update failed");
// }
// }
// }
// // screenshots.clear();
// // start = tokio::time::Instant::now();
// }
// }
// }
// });
let rx = self.sorted_colors_rx.clone();
tokio::spawn(async move {
let mut rx = rx.read().await.clone();
loop {
if let Err(err) = rx.changed().await {
warn!("rx changed error: {}", err);
sleep(Duration::from_millis(1000)).await;
continue;
}
let colors = rx.borrow().clone();
let len = colors.len();
match Self::send_colors(colors).await {
Ok(_) => {
// log::info!("colors sent. len: {}", len);
}
Err(err) => {
warn!("colors send failed: {}", err);
}
}
}
});
}
pub async fn send_colors(payload: Vec<u8>) -> anyhow::Result<()> {
let mqtt = MqttRpc::global().await;
mqtt.publish_led_sub_pixels(payload).await
}
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;
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() {
for display_id in display_ids {
let led_strip_configs: Vec<_> = configs
.strips
.iter()
.filter(|c| c.display_id == display_id)
.collect();
if led_strip_configs.len() == 0 {
warn!("no led strip config for display_id: {}", display_id);
continue;
}
let screenshot = screenshots.get(&display_id).unwrap();
log::debug!("screenshot updated: {:?}", display_id);
let points: Vec<_> = led_strip_configs
.iter()
.map(|config| screenshot.get_sample_points(&config))
.collect();
let colors_config = DisplaySamplePointGroup { display_id, points };
colors_configs.push(colors_config);
}
return Ok(AllColorConfig {
sample_point_groups: colors_configs,
mappers,
// screenshot_receivers: local_rx_list,
});
}
}
}
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<Vec<LedSamplePoints>>,
}

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,36 @@
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct DisplayConfig {
pub id: usize,
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,
}
impl DisplayConfig {
pub fn default(index: usize) -> Self {
Self {
id: index,
brightness: 30,
contrast: 50,
mode: 0,
last_modified_at: SystemTime::now(),
max_brightness: 100,
min_brightness: 0,
max_contrast: 100,
min_contrast: 0,
max_mode: 15,
min_mode: 0,
}
}
}

View File

@ -0,0 +1,187 @@
use std::{
borrow::Borrow,
collections::HashMap,
ops::Sub,
sync::Arc,
time::{Duration, SystemTime},
};
use base64::Config;
use ddc_hi::Display;
use paris::{error, info, warn};
use tauri::async_runtime::Mutex;
use tokio::sync::{broadcast, OwnedMutexGuard};
use tracing::warn;
use crate::{display::Brightness, models, rpc};
use super::{display_config::DisplayConfig, DisplayBrightness};
use ddc_hi::Ddc;
pub struct Manager {
displays: Arc<Mutex<HashMap<usize, Arc<Mutex<DisplayConfig>>>>>,
}
impl Manager {
pub fn global() -> &'static Self {
static DISPLAY_MANAGER: once_cell::sync::OnceCell<Manager> =
once_cell::sync::OnceCell::new();
DISPLAY_MANAGER.get_or_init(|| Self::create())
}
pub fn create() -> Self {
let instance = Self {
displays: Arc::new(Mutex::new(HashMap::new())),
};
instance
}
pub async fn subscribe_display_brightness(&self) {
let rpc = rpc::Manager::global().await;
let mut rx = rpc.client().subscribe_change_display_brightness_rx();
loop {
if let Ok(display_brightness) = rx.recv().await {
if let Err(err) = self.set_display_brightness(display_brightness).await {
error!("set_display_brightness failed. {:?}", err);
}
}
}
}
fn read_display_config_by_ddc(index: usize) -> anyhow::Result<DisplayConfig> {
let mut displays = Display::enumerate();
match displays.get_mut(index) {
Some(display) => {
let mut config = DisplayConfig::default(index);
match display.handle.get_vcp_feature(0x10) {
Ok(value) => {
config.max_brightness = value.maximum();
config.min_brightness = 0;
config.brightness = value.value();
}
Err(_) => {}
};
match display.handle.get_vcp_feature(0x12) {
Ok(value) => {
config.max_contrast = value.maximum();
config.min_contrast = 0;
config.contrast = value.value();
}
Err(_) => {}
};
match display.handle.get_vcp_feature(0xdc) {
Ok(value) => {
config.max_mode = value.maximum();
config.min_mode = 0;
config.mode = value.value();
}
Err(_) => {}
};
Ok(config)
}
None => anyhow::bail!("display#{} is missed.", index),
}
}
async fn get_display(&self, index: usize) -> anyhow::Result<OwnedMutexGuard<DisplayConfig>> {
let mut displays = self.displays.lock().await;
match displays.get_mut(&index) {
Some(config) => {
let mut config = config.to_owned().lock_owned().await;
if config.last_modified_at > SystemTime::now().sub(Duration::from_secs(10)) {
info!("cached");
return Ok(config);
}
return match Self::read_display_config_by_ddc(index) {
Ok(config) => {
let id = config.id;
let value = Arc::new(Mutex::new(config));
let valueGuard = value.clone().lock_owned().await;
displays.insert(id, value);
info!("read form ddc");
Ok(valueGuard)
}
Err(err) => {
warn!(
"can not read config from display by ddc, use CACHED value. {:?}",
err
);
config.last_modified_at = SystemTime::now();
Ok(config)
}
};
}
None => {
let config = Self::read_display_config_by_ddc(index).map_err(|err| {
anyhow::anyhow!(
"can not read config from display by ddc,use DEFAULT value. {:?}",
err
)
})?;
let id = config.id;
let value = Arc::new(Mutex::new(config));
let valueGuard = value.clone().lock_owned().await;
displays.insert(id, value);
Ok(valueGuard)
}
}
}
pub async fn set_display_brightness(
&self,
display_brightness: DisplayBrightness,
) -> anyhow::Result<()> {
match Display::enumerate().get_mut(display_brightness.display_index) {
Some(display) => {
match self.get_display(display_brightness.display_index).await {
Ok(mut config) => {
let curr = config.brightness;
info!("curr_brightness: {:?}", curr);
let mut target = match display_brightness.brightness {
Brightness::Relative(v) => curr.wrapping_add_signed(v),
Brightness::Absolute(v) => v,
};
if target.gt(&config.max_brightness) {
target = config.max_brightness;
} else if target.lt(&config.min_brightness) {
target = config.min_brightness;
}
config.brightness = target;
display
.handle
.set_vcp_feature(0x10, target as u16)
.map_err(|err| anyhow::anyhow!("can not set brightness. {:?}", err))?;
let rpc = rpc::Manager::global().await;
rpc.publish_desktop_cmd(
format!("display{}/brightness", display_brightness.display_index)
.as_str(),
target.to_be_bytes().to_vec(),
)
.await;
}
Err(err) => {
info!(
"can not get display#{} brightness. {:?}",
display_brightness.display_index, err
);
if let Brightness::Absolute(v) = display_brightness.brightness {
display.handle.set_vcp_feature(0x10, v).map_err(|err| {
anyhow::anyhow!("can not set brightness. {:?}", err)
})?;
};
}
};
}
None => {
warn!("display#{} is not found.", display_brightness.display_index);
}
}
Ok(())
}
}

View File

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

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

View File

@ -1,103 +1,384 @@
#![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;
pub mod screenshot;
mod screenshot_manager;
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, LedColorsPublisher, LedStripConfig, LedStripConfigGroup};
use core_graphics::display::{
kCGNullWindowID, kCGWindowImageDefault, kCGWindowListOptionOnScreenOnly, CGDisplay,
};
use display_info::DisplayInfo;
use paris::{error, info, warn};
use screenshot::Screenshot;
use screenshot_manager::ScreenshotManager;
use serde::{Deserialize, Serialize};
use serde_json::to_string;
use tauri::{http::ResponseBuilder, regex, Manager};
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.clone();
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.clone();
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(buffer: Vec<u8>) -> Result<(), String> {
ambient_light::LedColorsPublisher::send_colors(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()
})
}
#[tokio::main]
async fn main() {
rpc::manager::Manager::global();
env_logger::init();
let screenshot_manager = ScreenshotManager::global().await;
screenshot_manager.start().unwrap();
let led_color_publisher = ambient_light::LedColorsPublisher::global().await;
led_color_publisher.start();
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,
])
.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 channels = screenshot_manager.channels.read().await;
if let Some(rx) = channels.get(&display_id) {
let rx = rx.clone();
let screenshot = rx.borrow().clone();
let bytes = screenshot.bytes.read().await;
let (scale_factor_x, scale_factor_y, width, height) = if url.query.is_some()
&& url.query.as_ref().unwrap().contains_key("height")
&& url.query.as_ref().unwrap().contains_key("width")
{
let width = url.query.as_ref().unwrap()["width"]
.parse::<u32>()
.map_err(|err| {
warn!("width parse error: {}", err);
err
})?;
let height = url.query.as_ref().unwrap()["height"]
.parse::<u32>()
.map_err(|err| {
warn!("height parse error: {}", err);
err
})?;
(
screenshot.width as f32 / width as f32,
screenshot.height as f32 / height as f32,
width,
height,
)
} else {
log::debug!("scale by scale_factor");
let scale_factor = screenshot.scale_factor;
(
scale_factor,
scale_factor,
(screenshot.width as f32 / scale_factor) as u32,
(screenshot.height as f32 / scale_factor) as u32,
)
};
log::debug!(
"scale by query. width: {}, height: {}, scale_factor: {}, len: {}",
width,
height,
screenshot.width as f32 / width as f32,
width * height * 4,
);
let bytes_per_row = screenshot.bytes_per_row as f32;
let mut rgba_buffer = vec![0u8; (width * height * 4) as usize];
for y in 0..height {
for x in 0..width {
let offset = ((y as f32) * scale_factor_y).floor() as usize
* bytes_per_row as usize
+ ((x as f32) * scale_factor_x).floor() as usize * 4;
let b = bytes[offset];
let g = bytes[offset + 1];
let r = bytes[offset + 2];
let a = bytes[offset + 3];
let offset_2 = (y * width + x) as usize * 4;
rgba_buffer[offset_2] = r;
rgba_buffer[offset_2 + 1] = g;
rgba_buffer[offset_2 + 2] = b;
rgba_buffer[offset_2 + 3] = a;
}
}
Ok(rgba_buffer.clone())
} else {
anyhow::bail!("Display#{}: not found", display_id);
}
})
});
if let Ok(bytes) = bytes {
return response
.mimetype("octet/stream")
.status(200)
.body(bytes.to_vec());
}
let err = bytes.unwrap_err();
error!("request screenshot bin data failed: {}", err);
return response
.mimetype("text/plain")
.status(500)
.body(err.to_string().into_bytes());
})
.setup(move |app| {
let app_handle = app.handle().clone();
tokio::spawn(async move {
let config_manager = ambient_light::ConfigManager::global().await;
let config_update_receiver = config_manager.clone_config_update_receiver();
let mut config_update_receiver = config_update_receiver;
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();
}
});
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>,
}

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,3 @@
pub mod manager;
pub mod mqtt;
mod mqtt;
pub use mqtt::*;

View File

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

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

@ -0,0 +1,194 @@
use std::cell::RefCell;
use std::{iter, cell::Ref};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tauri::async_runtime::{RwLock, Mutex};
use crate::{ambient_light::LedStripConfig, led_color::LedColor};
#[derive(Debug, Clone)]
pub struct Screenshot {
pub display_id: u32,
pub height: u32,
pub width: u32,
pub bytes_per_row: usize,
pub bytes: Arc<RwLock<Vec<u8>>>,
pub scale_factor: f32,
}
static SINGLE_AXIS_POINTS: usize = 5;
impl Screenshot {
pub fn new(
display_id: u32,
height: u32,
width: u32,
bytes_per_row: usize,
bytes: Vec<u8>,
scale_factor: f32,
) -> Self {
Self {
display_id,
height,
width,
bytes_per_row,
bytes: Arc::new(RwLock::new(bytes)),
scale_factor,
}
}
pub fn get_sample_points(&self, config: &LedStripConfig) -> Vec<LedSamplePoints> {
let height = self.height as usize;
let width = self.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;
}
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,274 @@
use std::cell::{Ref, RefCell};
use std::{collections::HashMap, sync::Arc};
use core_graphics::display::{
kCGNullWindowID, kCGWindowImageDefault, kCGWindowListOptionOnScreenOnly, CGDisplay,
};
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use paris::warn;
use tauri::async_runtime::RwLock;
use tokio::sync::{broadcast, watch, OnceCell};
use tokio::time::{self, Duration};
use crate::screenshot::LedSamplePoints;
use crate::{
ambient_light::{SamplePointConfig, SamplePointMapper},
led_color::LedColor,
screenshot::{ScreenSamplePoints, Screenshot},
};
pub fn take_screenshot(display_id: u32, scale_factor: f32) -> anyhow::Result<Screenshot> {
log::debug!("take_screenshot");
let cg_display = CGDisplay::new(display_id);
let cg_image = CGDisplay::screenshot(
cg_display.bounds(),
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowImageDefault,
)
.ok_or_else(|| anyhow::anyhow!("Display#{}: take screenshot failed", display_id))?;
let buffer = cg_image.data();
let bytes_per_row = cg_image.bytes_per_row();
let height = cg_image.height();
let width = cg_image.width();
let bytes = buffer.bytes().to_owned();
Ok(Screenshot::new(
display_id,
height as u32,
width as u32,
bytes_per_row,
bytes,
scale_factor,
))
}
pub fn get_display_colors(
display_id: u32,
sample_points: &Vec<Vec<LedSamplePoints>>,
) -> anyhow::Result<Vec<LedColor>> {
log::debug!("take_screenshot");
let cg_display = CGDisplay::new(display_id);
let mut colors = vec![];
let start_at = std::time::Instant::now();
for points in sample_points {
if points.len() == 0 {
continue;
}
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 + cg_display.bounds().origin.x,
y: start_y 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,
};
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);
}
// if display_id == 4849664 {
// log::info!(
// "======= get_display_colors {} took {}ms",
// display_id,
// start_at.elapsed().as_millis()
// );
// }
Ok(colors)
}
pub struct ScreenshotManager {
pub channels: Arc<RwLock<HashMap<u32, watch::Receiver<Screenshot>>>>,
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 fn start(&self) -> anyhow::Result<()> {
let displays = display_info::DisplayInfo::all()?;
for display in displays {
self.start_one(display.id, display.scale_factor)?;
}
Ok(())
}
fn start_one(&self, display_id: u32, scale_factor: f32) -> anyhow::Result<()> {
let channels = self.channels.to_owned();
let merged_screenshot_tx = self.merged_screenshot_tx.clone();
tokio::spawn(async move {
let screenshot = take_screenshot(display_id, scale_factor);
if screenshot.is_err() {
warn!("take_screenshot_loop: {}", screenshot.err().unwrap());
return;
}
let mut interval = time::interval(Duration::from_millis(3300));
let mut start = tokio::time::Instant::now();
let screenshot = screenshot.unwrap();
let (screenshot_tx, screenshot_rx) = watch::channel(screenshot);
{
let channels = channels.clone();
let mut channels = channels.write().await;
channels.insert(display_id, screenshot_rx.clone());
}
let merged_screenshot_tx = merged_screenshot_tx.read().await.clone();
loop {
start = tokio::time::Instant::now();
Self::take_screenshot_loop(
display_id,
scale_factor,
&screenshot_tx,
&merged_screenshot_tx,
)
.await;
interval.tick().await;
tokio::time::sleep(Duration::from_millis(1)).await;
}
});
Ok(())
}
async fn take_screenshot_loop(
display_id: u32,
scale_factor: f32,
screenshot_tx: &watch::Sender<Screenshot>,
merged_screenshot_tx: &broadcast::Sender<Screenshot>,
) {
let screenshot = take_screenshot(display_id, scale_factor);
if let Ok(screenshot) = screenshot {
match merged_screenshot_tx.send(screenshot.clone()) {
Ok(_) => {}
Err(err) => {
// warn!("take_screenshot_loop: merged_screenshot_tx.send failed. display#{}. err: {}", display_id, err);
}
}
screenshot_tx.send(screenshot).unwrap();
// log::info!("take_screenshot_loop: send success. display#{}", display_id)
} else {
warn!("take_screenshot_loop: {}", screenshot.err().unwrap());
}
}
pub async fn get_all_colors(
&self,
configs: &Vec<SamplePointConfig>,
screenshots: &Vec<&Screenshot>,
) -> Vec<LedColor> {
let mut all_colors = vec![];
for (index, screenshot) in screenshots.iter().enumerate() {
let config = &configs[index];
let mut colors = screenshot.get_colors_by_sample_points(&config.points).await;
all_colors.append(&mut colors);
}
all_colors
}
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()
}
}

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,23 +27,8 @@
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "cc.ivanli.ambient",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
"identifier": "cc.ivanli.ambient-light.desktop",
"targets": "all"
},
"security": {
"csp": null
@ -55,10 +39,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,104 @@
import { useCallback, useState } from 'react';
import tw from 'twin.macro';
import { createEffect, onCleanup } from 'solid-js';
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 { DisplayView } from './components/display-view';
import { DisplayListContainer } from './components/display-list-container';
import { displayStore, setDisplayStore } from './stores/display.store';
import { LedStripConfigContainer } from './models/led-strip-config';
import { setLedStripStore } from './stores/led-strip.store';
import { listen } from '@tauri-apps/api/event';
import { LedStripPartsSorter } from './components/led-strip-parts-sorter';
import { createStore } from 'solid-js/store';
import {
LedStripConfigurationContext,
LedStripConfigurationContextType,
} from './contexts/led-strip-configuration.context';
function App() {
const [screenshots, setScreenshots] = useState<string[]>([]);
const [ledStripColors, setLedStripColors] = useState<string[]>([]);
const [currentMode, setCurrentMode] = useState<Mode>(null);
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);
});
});
async function takeSnapshot() {
const base64TextList: string[] = await invoke('take_snapshot');
// 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,
});
});
setScreenshots(base64TextList.map((text) => `data:image/webp;base64,${text}`));
}
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
const getLedStripColors = useCallback(async () => {
setLedStripColors(await invoke('get_led_strip_colors'));
}, []);
// listen to led_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_colors_changed', (event) => {
const colors = event.payload;
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);
setLedStripStore({
colors,
});
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
// listen to led_sorted_colors_changed event
createEffect(() => {
const unlisten = listen<Uint8ClampedArray>('led_sorted_colors_changed', (event) => {
const sortedColors = event.payload;
setLedStripStore({
sortedColors,
});
});
onCleanup(() => {
unlisten.then((unlisten) => unlisten());
});
});
const [ledStripConfiguration, setLedStripConfiguration] = createStore<
LedStripConfigurationContextType[0]
>({
selectedStripPart: null,
});
const ledStripConfigurationContextValue: LedStripConfigurationContextType = [
ledStripConfiguration,
{
setSelectedStripPart: (v) => {
setLedStripConfiguration({
selectedStripPart: v,
});
},
},
[currentMode, setCurrentMode],
);
];
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>
<LedStripConfigurationContext.Provider value={ledStripConfigurationContextValue}>
<LedStripPartsSorter />
<DisplayListContainer>
{displayStore.displays.map((display) => {
return <DisplayView display={display} />;
})}
</DisplayListContainer>
</LedStripConfigurationContext.Provider>
</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,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,155 @@
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;
};
async function subscribeScreenshotUpdate(displayId: number) {
await invoke('subscribe_encoded_screenshot_updated', {
displayId,
});
}
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 [ledSamplePoints, setLedSamplePoints] = createSignal();
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);
});
// get led strip sample points
createEffect(() => {
if (localProps.config) {
invoke('get_led_strips_sample_points', {
config: localProps.config,
}).then((points) => {
setLedSamplePoints(points);
});
}
});
const [anchor, setAnchor] = createSignal<HTMLElement>();
useTippy(anchor, {
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,228 @@
import {
batch,
Component,
createContext,
createEffect,
createMemo,
createSignal,
For,
Index,
JSX,
on,
untrack,
useContext,
} from 'solid-js';
import { LedStripConfig, LedStripPixelMapper } from '../models/led-strip-config';
import { ledStripStore } from '../stores/led-strip.store';
import { invoke } from '@tauri-apps/api';
import { LedStripConfigurationContext } from '../contexts/led-strip-configuration.context';
import background from '../assets/transparent-grid-background.svg?url';
const SorterItem: Component<{ strip: LedStripConfig; mapper: LedStripPixelMapper }> = (
props,
) => {
const [fullLeds, setFullLeds] = createSignal<Array<string | null>>([]);
const [dragging, setDragging] = createSignal<boolean>(false);
const [dragStart, setDragStart] = createSignal<{ x: number; y: number } | null>(null);
const [dragCurr, setDragCurr] = createSignal<{ x: number; y: number } | null>(null);
const [dragStartIndex, setDragStartIndex] = createSignal<number>(0);
const [cellWidth, setCellWidth] = createSignal<number>(0);
const [, { setSelectedStripPart }] = useContext(LedStripConfigurationContext);
const move = (targetStart: number) => {
if (targetStart === props.mapper.start) {
return;
}
invoke('move_strip_part', {
displayId: props.strip.display_id,
border: props.strip.border,
targetStart,
}).catch((err) => console.error(err));
};
// reset translateX on config updated
createEffect(() => {
const indexDiff = props.mapper.start - dragStartIndex();
untrack(() => {
if (!dragStart() || !dragCurr()) {
return;
}
const compensation = indexDiff * cellWidth();
batch(() => {
setDragStartIndex(props.mapper.start);
setDragStart({
x: dragStart()!.x + compensation,
y: dragCurr()!.y,
});
});
});
});
const onPointerDown = (ev: PointerEvent) => {
if (ev.button !== 0) {
return;
}
setDragging(true);
setDragStart({ x: ev.clientX, y: ev.clientY });
setDragCurr({ x: ev.clientX, y: ev.clientY });
setDragStartIndex(props.mapper.start);
};
const onPointerUp = () => (ev: PointerEvent) => {
if (ev.button !== 0) {
return;
}
setDragging(false);
};
const onPointerMove = (ev: PointerEvent) => {
setSelectedStripPart({
displayId: props.strip.display_id,
border: props.strip.border,
});
if (!(ev.buttons & 1)) {
return;
}
const draggingInfo = dragging();
if (!draggingInfo) {
return;
}
setDragCurr({ x: ev.clientX, y: ev.clientY });
const cellWidth =
(ev.currentTarget as HTMLDivElement).clientWidth / ledStripStore.totalLedCount;
const diff = ev.clientX - dragStart()!.x;
const moved = Math.round(diff / cellWidth);
if (moved === 0) {
return;
}
setCellWidth(cellWidth);
move(props.mapper.start + moved);
};
const onPointerLeave = () => {
setSelectedStripPart(null);
};
const reverse = () => {
invoke('reverse_led_strip_part', {
displayId: props.strip.display_id,
border: props.strip.border,
}).catch((err) => console.error(err));
};
// update fullLeds
createEffect(() => {
const fullLeds = new Array(ledStripStore.totalLedCount).fill(null);
const colors = ledStripStore.colors;
const { start, end, pos } = props.mapper;
const isForward = start < end;
const step = isForward ? 1 : -1;
for (let i = start, j = pos; i !== end; i += step, j++) {
let c1 = `rgb(${Math.floor(colors[j * 3] * 0.8)}, ${Math.floor(
colors[j * 3 + 1] * 0.8,
)}, ${Math.floor(colors[j * 3 + 2] * 0.8)})`;
let c2 = `rgb(${Math.min(Math.floor(colors[j * 3] * 1.2), 255)}, ${Math.min(
Math.floor(colors[j * 3 + 1] * 1.2),
255,
)}, ${Math.min(Math.floor(colors[j * 3 + 2] * 1.2), 255)})`;
fullLeds[i] = `linear-gradient(70deg, ${c1} 10%, ${c2})`;
}
setFullLeds(fullLeds);
});
const style = createMemo<JSX.CSSProperties>(() => {
return {
transform: `translateX(${(dragCurr()?.x ?? 0) - (dragStart()?.x ?? 0)}px)`,
};
});
return (
<div
class="flex h-2 m-2 select-none cursor-ew-resize focus:cursor-ew-resize"
style={style()}
onPointerMove={onPointerMove}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
ondblclick={reverse}
>
<For each={fullLeds()}>
{(it) => (
<div
class="flex-auto flex h-full w-full justify-center items-center relative"
title={it ?? ''}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-100"
classList={{ 'ring-stone-300/50': !it }}
style={{ background: it ?? 'transparent' }}
/>
</div>
)}
</For>
</div>
);
};
const SorterResult: Component = () => {
const [fullLeds, setFullLeds] = createSignal<string[]>([]);
createEffect(() => {
const colors = ledStripStore.sortedColors;
const fullLeds = new Array(ledStripStore.totalLedCount)
.fill('rgba(255,255,255,0.1)')
.map((_, i) => {
let c1 = `rgb(${Math.floor(colors[i * 3] * 0.8)}, ${Math.floor(
colors[i * 3 + 1] * 0.8,
)}, ${Math.floor(colors[i * 3 + 2] * 0.8)})`;
let c2 = `rgb(${Math.min(Math.floor(colors[i * 3] * 1.2), 255)}, ${Math.min(
Math.floor(colors[i * 3 + 1] * 1.2),
255,
)}, ${Math.min(Math.floor(colors[i * 3 + 2] * 1.2), 255)})`;
return `linear-gradient(70deg, ${c1} 10%, ${c2})`;
});
setFullLeds(fullLeds);
});
return (
<div class="flex h-2 m-2">
<For each={fullLeds()}>
{(it) => (
<div
class="flex-auto flex h-full w-full justify-center items-center relative"
title={it}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-300"
style={{ background: it }}
/>
</div>
)}
</For>
</div>
);
};
export const LedStripPartsSorter: Component = () => {
return (
<div
class="select-none overflow-hidden"
style={{
'background-image': `url(${background})`,
}}
>
<SorterResult />
<Index each={ledStripStore.strips}>
{(strip, index) => (
<SorterItem strip={strip()} mapper={ledStripStore.mappers[index]} />
)}
</Index>
</div>
);
};

View File

@ -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

@ -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 type Borders = typeof borders[number];
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: () => {},
},
]);

7
src/index.tsx Normal file
View File

@ -0,0 +1,7 @@
/* @refresh reload */
import { render } from "solid-js/web";
import "./styles.css";
import App from "./App";
render(() => <App />, 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,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,20 @@
import { Borders } from '../constants/border';
export type LedStripPixelMapper = {
start: number;
end: number;
pos: number;
};
export type LedStripConfigContainer = {
strips: LedStripConfig[];
mappers: LedStripPixelMapper[];
};
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,12 @@
import { createStore } from 'solid-js/store';
import { LedStripConfig, LedStripPixelMapper } from '../models/led-strip-config';
export const [ledStripStore, setLedStripStore] = createStore({
strips: new Array<LedStripConfig>(),
mappers: new Array<LedStripPixelMapper>(),
colors: new Uint8ClampedArray(),
sortedColors: new Uint8ClampedArray(),
get totalLedCount() {
return Math.max(0, ...ledStripStore.mappers.map((m) => 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" />

View File

@ -1,15 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
dark: {
800: '#0f0f0f',
100: '#f6f6f6',
},
},
},
},
plugins: [],
};

9
tailwind.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
mode: 'jit',
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -1,25 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react"
},
"include": [
"src",
"types"
],
"references": [{ "path": "./tsconfig.node.json" }]
"isolatedModules": true
}
}

View File

@ -1,9 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

18
types/twin.d.ts vendored
View File

@ -1,18 +0,0 @@
import 'twin.macro';
import { css as cssImport } from '@emotion/react';
import styledImport from '@emotion/styled';
import { CSSInterpolation } from '@emotion/serialize';
declare module 'twin.macro' {
// The styled and css imports
const styled: typeof styledImport;
const css: typeof cssImport;
}
declare module 'react' {
// The tw and css prop
interface DOMAttributes<T> {
tw?: string;
css?: CSSInterpolation;
}
}

View File

@ -1,31 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import solidPlugin from "vite-plugin-solid";
const mobile =
process.env.TAURI_PLATFORM === "android" ||
process.env.TAURI_PLATFORM === "ios";
// https://vitejs.dev/config/
export default defineConfig({
optimizeDeps: {
esbuildOptions: {
target: 'es2020',
},
},
plugins: [
react({
babel: {
plugins: [
'babel-plugin-macros',
[
'@emotion/babel-plugin-jsx-pragmatic',
{
export: 'jsx',
import: '__cssprop',
module: '@emotion/react',
},
],
['@babel/plugin-transform-react-jsx', { pragma: '__cssprop' }, 'twin.macro'],
],
},
}),
],
export default defineConfig(async () => ({
plugins: [solidPlugin()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
@ -37,13 +19,13 @@ export default defineConfig({
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ['VITE_', 'TAURI_'],
envPrefix: ["VITE_", "TAURI_"],
build: {
// Tauri supports es2021
target: ['es2021', 'chrome100', 'safari13'],
target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari13",
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
});
}));