5 Commits

Author SHA1 Message Date
93ad9ae46c feat: implement real-time LED strip preview
- Add LED strip visualization around display previews
- Show real-time color status for each LED pixel
- Support multi-display LED strip configurations
- Use elegant 16px thin LED strip design
- Real-time LED color sync via WebSocket
- Responsive layout with display scaling support
2025-07-03 02:09:19 +08:00
6c30a824b0 feat: upgrade Tailwind CSS to v4.1.11
- Upgrade Tailwind CSS from 3.x to 4.x for latest features and performance
- Install @tailwindcss/postcss plugin for Tailwind CSS 4.0 compatibility
- Update CSS configuration to use new @import and @config syntax
- Update PostCSS configuration to use new plugin format
- Build working correctly with new Tailwind CSS engine
2025-06-30 18:01:26 +08:00
515b3a4ccb feat: upgrade Vite to v6.3.5
- Upgrade Vite from 4.x to 6.x for better performance and features
- Update @types/node to v24.0.7 for compatibility
- Maintain compatibility with vite-plugin-solid 2.11.7
- Build and development server working correctly
2025-06-30 17:51:53 +08:00
ddf61c861d feat: update dependencies to latest compatible versions
- Update frontend dependencies (SolidJS, Vite, Tailwind, etc.)
- Update backend dependencies (Tauri 1.8.3, Tokio, Serde, etc.)
- Fix thread safety issues with SafeDisplay wrapper for ddc-hi::Display
- Resolve display-info API compatibility issues
- All dependencies updated within major version constraints
2025-06-30 17:35:03 +08:00
b1fd751090 Fix LED color events and improve screenshot capture
- Fix LED color publisher: uncomment display_colors_tx.send() to enable LED color events
- Replace rust_swift_screencapture with screen-capture-kit for better macOS compatibility
- Add bounds checking in LED color processing to prevent array index errors
- Update screenshot manager to use CGDisplay as fallback implementation
- Fix frontend screenshot URL protocol to use ambient-light://
- Add debug logging for LED color events in frontend
- Remove debug logs that were added for troubleshooting
- Update dependencies and remove CMake-dependent paho-mqtt temporarily

This resolves the issue where LED color events were not being sent to the frontend,
enabling real-time LED color visualization in the UI.
2025-06-30 14:35:03 +08:00
28 changed files with 10013 additions and 3174 deletions

View File

@@ -11,23 +11,24 @@
},
"license": "MIT",
"dependencies": {
"@solidjs/router": "^0.8.2",
"@tauri-apps/api": "^1.3.0",
"debug": "^4.3.4",
"solid-icons": "^1.0.8",
"solid-js": "^1.7.6",
"@solidjs/router": "^0.8.4",
"@tauri-apps/api": "^2.6.0",
"debug": "^4.4.1",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.7",
"solid-tippy": "^0.2.1",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",
"@types/debug": "^4.1.8",
"@types/node": "^18.16.17",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.3.2",
"@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2.6.2",
"@types/debug": "^4.1.12",
"@types/node": "^24.0.7",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"typescript": "^4.9.5",
"vite": "^4.3.9",
"vite-plugin-solid": "^2.7.0"
"vite": "^6.3.5",
"vite-plugin-solid": "^2.11.7"
}
}

2752
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

4376
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,14 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "1.2", features = ["shell-open"] }
tauri = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-shell = "2.0"
serde = { version = "1.0", features = ["derive"] }
dirs = "5.0"
regex = "1.0"
serde_json = "1.0"
core-graphics = "0.22.3"
display-info = "0.4.1"
@@ -28,8 +31,8 @@ url-build-parse = "9.0.0"
color_space = "0.5.3"
hex = "0.4.3"
toml = "0.7.3"
paho-mqtt = "0.12.1"
time = {version="0.3.20", features= ["formatting"] }
# paho-mqtt = "0.12.1" # Temporarily disabled due to CMake issues
time = {version="0.3.35", features= ["formatting"] }
itertools = "0.10.5"
core-foundation = "0.9.3"
tokio-stream = "0.1.14"
@@ -37,7 +40,8 @@ mdns-sd = "0.7.2"
futures = "0.3.28"
ddc-hi = "0.4.1"
coreaudio-rs = "0.11.2"
rust_swift_screencapture = { version = "0.1.1", path = "../../../../demo/rust-swift-screencapture" }
screen-capture-kit = "0.3.1"
image = { version = "0.24", features = ["jpeg"] }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Capability for the main application window","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ use std::env::current_dir;
use display_info::DisplayInfo;
use paris::{error, info};
use serde::{Deserialize, Serialize};
use tauri::api::path::config_dir;
use crate::screenshot::LedSamplePoints;
@@ -55,7 +54,7 @@ impl LedStripConfigGroup {
let displays = DisplayInfo::all()?;
// config path
let path = config_dir()
let path = dirs::config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);
@@ -83,7 +82,7 @@ impl LedStripConfigGroup {
}
pub async fn write_config(configs: &Self) -> anyhow::Result<()> {
let path = config_dir()
let path = dirs::config_dir()
.unwrap_or(current_dir().unwrap())
.join(CONFIG_FILE_NAME);

View File

@@ -88,21 +88,21 @@ impl LedColorsPublisher {
}
}
// match display_colors_tx.send((
// display_id,
// colors_copy
// .into_iter()
// .map(|color| color.get_rgb())
// .flatten()
// .collect::<Vec<_>>(),
// )) {
// Ok(_) => {
// // log::info!("sent colors: {:?}", color_len);
// }
// Err(err) => {
// warn!("Failed to send display_colors: {}", err);
// }
// };
match display_colors_tx.send((
display_id,
colors_copy
.into_iter()
.map(|color| color.get_rgb())
.flatten()
.collect::<Vec<_>>(),
)) {
Ok(_) => {
// log::info!("sent colors: {:?}", color_len);
}
Err(err) => {
warn!("Failed to send display_colors: {}", err);
}
};
// Check if the inner task version changed
let version = internal_tasks_version.read().await.clone();
@@ -127,7 +127,7 @@ impl LedColorsPublisher {
) {
let sorted_colors_tx = self.sorted_colors_tx.clone();
let colors_tx = self.colors_tx.clone();
log::debug!("start all_colors_worker");
tokio::spawn(async move {
for _ in 0..10 {
@@ -137,7 +137,7 @@ impl LedColorsPublisher {
let mut all_colors: Vec<Option<Vec<u8>>> = vec![None; display_ids.len()];
let mut start: tokio::time::Instant = tokio::time::Instant::now();
log::debug!("start all_colors_worker task");
loop {
let color_info = display_colors_rx.recv().await;
@@ -186,7 +186,7 @@ impl LedColorsPublisher {
warn!("Failed to send sorted colors: {}", err);
}
};
log::debug!("tick: {}ms", start.elapsed().as_millis());
start = tokio::time::Instant::now();
}
}
@@ -195,7 +195,7 @@ impl LedColorsPublisher {
}
pub async fn start(&self) {
log::info!("start colors worker");
let config_manager = ConfigManager::global().await;
let mut config_receiver = config_manager.clone_config_update_receiver();
@@ -203,9 +203,7 @@ impl LedColorsPublisher {
self.handle_config_change(configs).await;
log::info!("waiting for config update...");
while config_receiver.changed().await.is_ok() {
log::info!("config updated, restart inner tasks...");
let configs = config_receiver.borrow().clone();
self.handle_config_change(configs).await;
}
@@ -300,16 +298,28 @@ impl LedColorsPublisher {
if group.end > group.start {
for i in group.pos - display_led_offset..group_size + group.pos - display_led_offset
{
if i < colors.len() {
let bytes = colors[i].as_bytes();
buffer.append(&mut bytes.to_vec());
} else {
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
// Add black color as fallback
buffer.append(&mut vec![0, 0, 0]);
}
}
} else {
for i in (group.pos - display_led_offset
..group_size + group.pos - display_led_offset)
.rev()
{
if i < colors.len() {
let bytes = colors[i].as_bytes();
buffer.append(&mut bytes.to_vec());
} else {
log::warn!("Index {} out of bounds for colors array of length {}", i, colors.len());
// Add black color as fallback
buffer.append(&mut vec![0, 0, 0]);
}
}
}
@@ -350,7 +360,7 @@ impl LedColorsPublisher {
let mut screenshots = HashMap::new();
loop {
log::info!("waiting merged screenshot...");
let screenshot = merged_screenshot_receiver.recv().await;
if let Err(err) = screenshot {
@@ -382,7 +392,7 @@ impl LedColorsPublisher {
.filter(|(_, c)| c.display_id == display_id);
let screenshot = screenshots.get(&display_id).unwrap();
log::debug!("screenshot updated: {:?}", display_id);
let points: Vec<_> = led_strip_configs
.clone()
@@ -412,7 +422,7 @@ impl LedColorsPublisher {
led_start = led_end;
}
log::debug!("got all colors configs: {:?}", colors_configs.len());
return Ok(AllColorConfig {
sample_point_groups: colors_configs,

View File

@@ -5,9 +5,27 @@ use tokio::sync::RwLock;
use super::DisplayState;
// Safe wrapper for Display that implements Send + Sync
pub struct SafeDisplay {
display: Display,
}
unsafe impl Send for SafeDisplay {}
unsafe impl Sync for SafeDisplay {}
impl SafeDisplay {
pub fn new(display: Display) -> Self {
Self { display }
}
pub fn get_mut(&mut self) -> &mut Display {
&mut self.display
}
}
pub struct DisplayHandler {
pub state: Arc<RwLock<DisplayState>>,
pub controller: Arc<RwLock<Display>>,
pub controller: Arc<RwLock<SafeDisplay>>,
}
impl DisplayHandler {
@@ -16,7 +34,7 @@ impl DisplayHandler {
let mut temp_state = self.state.read().await.clone();
match controller.handle.get_vcp_feature(0x10) {
match controller.get_mut().handle.get_vcp_feature(0x10) {
Ok(value) => {
temp_state.max_brightness = value.maximum();
temp_state.min_brightness = 0;
@@ -24,7 +42,7 @@ impl DisplayHandler {
}
Err(_) => {}
};
match controller.handle.get_vcp_feature(0x12) {
match controller.get_mut().handle.get_vcp_feature(0x12) {
Ok(value) => {
temp_state.max_contrast = value.maximum();
temp_state.min_contrast = 0;
@@ -32,7 +50,7 @@ impl DisplayHandler {
}
Err(_) => {}
};
match controller.handle.get_vcp_feature(0xdc) {
match controller.get_mut().handle.get_vcp_feature(0xdc) {
Ok(value) => {
temp_state.max_mode = value.maximum();
temp_state.min_mode = 0;
@@ -52,6 +70,7 @@ impl DisplayHandler {
let mut state = self.state.write().await;
controller
.get_mut()
.handle
.set_vcp_feature(0x10, brightness)
.map_err(|err| anyhow::anyhow!("can not set brightness. {:?}", err))?;
@@ -69,6 +88,7 @@ impl DisplayHandler {
let mut state = self.state.write().await;
controller
.get_mut()
.handle
.set_vcp_feature(0x12, contrast)
.map_err(|err| anyhow::anyhow!("can not set contrast. {:?}", err))?;
@@ -84,6 +104,7 @@ impl DisplayHandler {
let mut state = self.state.write().await;
controller
.get_mut()
.handle
.set_vcp_feature(0xdc, mode)
.map_err(|err| anyhow::anyhow!("can not set mode. {:?}", err))?;

View File

@@ -2,7 +2,7 @@ use std::{env::current_dir, sync::Arc, time::Duration};
use ddc_hi::Display;
use paris::{error, info, warn};
use tauri::api::path::config_dir;
use dirs::config_dir;
use tokio::{
sync::{broadcast, watch, OnceCell, RwLock},
task::yield_now,
@@ -13,7 +13,7 @@ use crate::{
rpc::{BoardMessageChannels, DisplaySetting},
};
use super::{display_handler::DisplayHandler, display_state::DisplayState};
use super::{display_handler::{DisplayHandler, SafeDisplay}, display_state::DisplayState};
const CONFIG_FILE_NAME: &str = "cc.ivanli.ambient_light/displays.toml";
@@ -85,7 +85,8 @@ impl DisplayManager {
let controllers = Display::enumerate();
for display in controllers {
let controller = Arc::new(RwLock::new(display));
let safe_display = SafeDisplay::new(display);
let controller = Arc::new(RwLock::new(safe_display));
let state = Arc::new(RwLock::new(DisplayState::default()));
let handler = DisplayHandler {
state: state.clone(),

View File

@@ -18,9 +18,10 @@ use screenshot::Screenshot;
use screenshot_manager::ScreenshotManager;
use serde::{Deserialize, Serialize};
use serde_json::to_string;
use tauri::{http::ResponseBuilder, regex, Manager};
use tauri::{Manager, Emitter, Runtime};
use regex;
use tauri::http::{Request, Response};
use volume::VolumeManager;
#[derive(Serialize, Deserialize)]
#[serde(remote = "DisplayInfo")]
struct DisplayInfoDef {
@@ -213,6 +214,123 @@ async fn get_displays() -> Vec<DisplayState> {
display_manager.get_displays().await
}
// Protocol handler for ambient-light://
fn handle_ambient_light_protocol<R: Runtime>(
_ctx: tauri::UriSchemeContext<R>,
request: Request<Vec<u8>>
) -> Response<Vec<u8>> {
let url = request.uri();
// info!("Handling ambient-light protocol request: {}", url);
// Parse the URL to extract parameters
let url_str = url.to_string();
let re = regex::Regex::new(r"ambient-light://displays/(\d+)\?width=(\d+)&height=(\d+)").unwrap();
if let Some(captures) = re.captures(&url_str) {
let display_id: u32 = captures[1].parse().unwrap_or(0);
let width: u32 = captures[2].parse().unwrap_or(400);
let height: u32 = captures[3].parse().unwrap_or(300);
// info!("Efficient screenshot request for display {}, {}x{}", display_id, width, height);
// Optimized screenshot processing with much smaller intermediate size
// info!("Screenshot request received: display_id={}, width={}, height={}", display_id, width, height);
let screenshot_data = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let screenshot_manager = ScreenshotManager::global().await;
let channels = screenshot_manager.channels.read().await;
if let Some(rx) = channels.get(&display_id) {
let rx = rx.read().await;
let screenshot = rx.borrow().clone();
let bytes = screenshot.bytes.read().await.to_owned();
// Use much smaller intermediate resolution for performance
let intermediate_width = 800; // Much smaller than original 5120
let intermediate_height = 450; // Much smaller than original 2880
// Convert BGRA to RGBA format
let mut rgba_bytes = bytes.as_ref().clone();
for chunk in rgba_bytes.chunks_exact_mut(4) {
chunk.swap(0, 2); // Swap B and R channels
}
let image_result = image::RgbaImage::from_raw(
screenshot.width as u32,
screenshot.height as u32,
rgba_bytes,
);
if let Some(img) = image_result {
// Step 1: Fast downscale to intermediate size
let intermediate_image = image::imageops::resize(
&img,
intermediate_width,
intermediate_height,
image::imageops::FilterType::Nearest, // Fastest possible
);
// Step 2: Scale to final target size
let final_image = if width == intermediate_width && height == intermediate_height {
intermediate_image
} else {
image::imageops::resize(
&intermediate_image,
width,
height,
image::imageops::FilterType::Triangle,
)
};
let raw_data = final_image.into_raw();
// info!("Efficient resize completed: {}x{}, {} bytes", width, height, raw_data.len());
Ok(raw_data)
} else {
error!("Failed to create image from raw bytes");
Err("Failed to create image from raw bytes".to_string())
}
} else {
error!("Display {} not found", display_id);
Err(format!("Display {} not found", display_id))
}
})
});
match screenshot_data {
Ok(data) => {
Response::builder()
.header("Content-Type", "application/octet-stream")
.header("Access-Control-Allow-Origin", "*")
.header("X-Image-Width", width.to_string())
.header("X-Image-Height", height.to_string())
.body(data)
.unwrap_or_else(|_| {
Response::builder()
.status(500)
.body("Failed to build response".as_bytes().to_vec())
.unwrap()
})
}
Err(e) => {
error!("Failed to get screenshot: {}", e);
Response::builder()
.status(500)
.body(format!("Error: {}", e).into_bytes())
.unwrap()
}
}
} else {
warn!("Invalid ambient-light URL format: {}", url_str);
Response::builder()
.status(400)
.body("Invalid URL format".as_bytes().to_vec())
.unwrap()
}
}
#[tokio::main]
async fn main() {
env_logger::init();
@@ -232,6 +350,7 @@ async fn main() {
let _volume = VolumeManager::global().await;
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
greet,
list_display_info,
@@ -248,143 +367,9 @@ async fn main() {
get_boards,
get_displays
])
.register_uri_scheme_protocol("ambient-light", move |_app, request| {
let response = ResponseBuilder::new().header("Access-Control-Allow-Origin", "*");
.register_uri_scheme_protocol("ambient-light", handle_ambient_light_protocol)
let uri = request.uri();
let uri = percent_encoding::percent_decode_str(uri)
.decode_utf8()
.unwrap()
.to_string();
let url = url_build_parse::parse_url(uri.as_str());
if let Err(err) = url {
error!("url parse error: {}", err);
return response
.status(500)
.mimetype("text/plain")
.body("Parse uri failed.".as_bytes().to_vec());
}
let url = url.unwrap();
let re = regex::Regex::new(r"^/displays/(\d+)$").unwrap();
let path = url.path;
let captures = re.captures(path.as_str());
if let None = captures {
error!("path not matched: {:?}", path);
return response
.status(404)
.mimetype("text/plain")
.body("Path Not Found.".as_bytes().to_vec());
}
let captures = captures.unwrap();
let display_id = captures[1].parse::<u32>().unwrap();
let bytes = tokio::task::block_in_place(move || {
tauri::async_runtime::block_on(async move {
let screenshot_manager = ScreenshotManager::global().await;
let rx: Result<tokio::sync::watch::Receiver<Screenshot>, anyhow::Error> =
screenshot_manager.subscribe_by_display_id(display_id).await;
if let Err(err) = rx {
anyhow::bail!("Display#{}: not found. {}", display_id, err);
}
let mut rx = rx.unwrap();
if rx.changed().await.is_err() {
anyhow::bail!("Display#{}: no more screenshot.", display_id);
}
let screenshot = rx.borrow().clone();
let bytes = screenshot.bytes.read().await;
if bytes.len() == 0 {
anyhow::bail!("Display#{}: no screenshot.", display_id);
}
log::debug!("Display#{}: screenshot size: {}", display_id, bytes.len());
let (scale_factor_x, scale_factor_y, width, height) = if url.query.is_some()
&& url.query.as_ref().unwrap().contains_key("height")
&& url.query.as_ref().unwrap().contains_key("width")
{
let width = url.query.as_ref().unwrap()["width"]
.parse::<u32>()
.map_err(|err| {
warn!("width parse error: {}", err);
err
})?;
let height = url.query.as_ref().unwrap()["height"]
.parse::<u32>()
.map_err(|err| {
warn!("height parse error: {}", err);
err
})?;
(
screenshot.width as f32 / width as f32,
screenshot.height as f32 / height as f32,
width,
height,
)
} else {
log::debug!("scale by scale_factor");
let scale_factor = screenshot.scale_factor;
(
scale_factor,
scale_factor,
(screenshot.width as f32 / scale_factor) as u32,
(screenshot.height as f32 / scale_factor) as u32,
)
};
log::debug!(
"scale by query. width: {}, height: {}, scale_factor: {}, len: {}",
width,
height,
screenshot.width as f32 / width as f32,
width * height * 4,
);
let bytes_per_row = screenshot.bytes_per_row as f32;
let mut rgba_buffer = vec![0u8; (width * height * 4) as usize];
for y in 0..height {
for x in 0..width {
let offset = ((y as f32) * scale_factor_y).floor() as usize
* bytes_per_row as usize
+ ((x as f32) * scale_factor_x).floor() as usize * 4;
let b = bytes[offset];
let g = bytes[offset + 1];
let r = bytes[offset + 2];
let a = bytes[offset + 3];
let offset_2 = (y * width + x) as usize * 4;
rgba_buffer[offset_2] = r;
rgba_buffer[offset_2 + 1] = g;
rgba_buffer[offset_2 + 2] = b;
rgba_buffer[offset_2 + 3] = a;
}
}
Ok(rgba_buffer.clone())
})
});
if let Ok(bytes) = bytes {
return response
.mimetype("octet/stream")
.status(200)
.body(bytes.to_vec());
}
let err = bytes.unwrap_err();
error!("request screenshot bin data failed: {}", err);
return response
.mimetype("text/plain")
.status(500)
.body(err.to_string().into_bytes());
})
.setup(move |app| {
let app_handle = app.handle().clone();
tokio::spawn(async move {
@@ -400,7 +385,7 @@ async fn main() {
let config = config_update_receiver.borrow().clone();
app_handle.emit_all("config_changed", config).unwrap();
app_handle.emit("config_changed", config).unwrap();
}
});
@@ -417,7 +402,7 @@ async fn main() {
let publisher = publisher_update_receiver.borrow().clone();
app_handle
.emit_all("led_sorted_colors_changed", publisher)
.emit("led_sorted_colors_changed", publisher)
.unwrap();
}
});
@@ -435,7 +420,7 @@ async fn main() {
let publisher = publisher_update_receiver.borrow().clone();
app_handle
.emit_all("led_colors_changed", publisher)
.emit("led_colors_changed", publisher)
.unwrap();
}
});
@@ -456,7 +441,7 @@ async fn main() {
let boards = boards.into_iter().collect::<Vec<_>>();
app_handle.emit_all("boards_changed", boards).unwrap();
app_handle.emit("boards_changed", boards).unwrap();
}
}
Err(err) => {
@@ -477,7 +462,7 @@ async fn main() {
log::info!("displays changed. emit displays_changed event.");
app_handle.emit_all("displays_changed", displays).unwrap();
app_handle.emit("displays_changed", displays).unwrap();
}
});

View File

@@ -145,9 +145,16 @@ impl Screenshot {
for (x, y) in led_points {
// log::info!("x: {}, y: {}, bytes_per_row: {}", x, y, bytes_per_row);
let position = x * 4 + y * bytes_per_row;
// Add bounds checking to prevent index out of bounds
if position + 2 < bitmap.len() {
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
} else {
// Skip invalid positions or use default values
log::warn!("Invalid pixel position: x={}, y={}, position={}, bitmap_len={}", x, y, position, bitmap.len());
}
}
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);
colors.push(color);
@@ -169,9 +176,16 @@ impl Screenshot {
for (x, y) in led_points {
// log::info!("x: {}, y: {}, bytes_per_row: {}", x, y, bytes_per_row);
let position = x * 4 + y * bytes_per_row;
// Add bounds checking to prevent index out of bounds
if position + 2 < bitmap.len() as usize {
b += bitmap[position] as f64;
g += bitmap[position + 1] as f64;
r += bitmap[position + 2] as f64;
} else {
// Skip invalid positions or use default values
log::warn!("Invalid pixel position in CG image: x={}, y={}, position={}, bitmap_len={}", x, y, position, bitmap.len());
}
// log::info!("position: {}, total: {}", position, bitmap.len());
}
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);

View File

@@ -6,9 +6,11 @@ use core_graphics::display::{
};
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use paris::{info, warn};
use rust_swift_screencapture::display::CGDisplayId;
use screen_capture_kit::shareable_content::{SCDisplay, SCShareableContent};
use screen_capture_kit::stream::{SCStream, SCStreamConfiguration, SCContentFilter, SCStreamOutput};
use screen_capture_kit::stream::SCStreamDelegate;
use tauri::async_runtime::RwLock;
use tokio::sync::{broadcast, watch, Mutex, OnceCell};
use tokio::sync::{broadcast, watch, OnceCell};
use tokio::task::yield_now;
use tokio::time::sleep;
@@ -20,7 +22,7 @@ pub fn get_display_colors(
sample_points: &Vec<Vec<LedSamplePoints>>,
bound_scale_factor: f32,
) -> anyhow::Result<Vec<LedColor>> {
log::debug!("take_screenshot");
let cg_display = CGDisplay::new(display_id);
let mut colors = vec![];
@@ -112,7 +114,7 @@ impl ScreenshotManager {
.unwrap_or_else(|err| {
warn!("start_one failed: display_id: {}, err: {}", display.id, err);
});
info!("start_one finished: display_id: {}", display.id);
});
futures::future::join_all(futures).await;
@@ -120,6 +122,8 @@ impl ScreenshotManager {
}
async fn start_one(&self, display_id: u32, scale_factor: f32) -> anyhow::Result<()> {
let merged_screenshot_tx = self.merged_screenshot_tx.clone();
let (tx, _) = watch::channel(Screenshot::new(
@@ -138,43 +142,96 @@ impl ScreenshotManager {
drop(channels);
// Implement screen capture using screen-capture-kit
loop {
let display = rust_swift_screencapture::display::Display::new(display_id);
let mut frame_rx = display.subscribe_frame().await;
display.start_capture(30).await;
match Self::capture_display_screenshot(display_id, scale_factor).await {
Ok(screenshot) => {
let tx_for_send = tx.read().await;
while frame_rx.changed().await.is_ok() {
let frame = frame_rx.borrow().clone();
let screenshot = Screenshot::new(
display_id,
frame.height as u32,
frame.width as u32,
frame.bytes_per_row as usize,
frame.bytes,
scale_factor,
scale_factor,
);
let merged_screenshot_tx = merged_screenshot_tx.write().await;
if let Err(err) = merged_screenshot_tx.send(screenshot.clone()) {
// log::warn!("merged_screenshot_tx.send failed: {}", err);
}
if let Err(err) = tx_for_send.send(screenshot.clone()) {
log::warn!("display {} screenshot_tx.send failed: {}", display_id, err);
} else {
log::debug!("screenshot: {:?}", screenshot);
}
}
Err(err) => {
warn!("Failed to capture screenshot for display {}: {}", display_id, err);
// Create a fallback empty screenshot to maintain the interface
let screenshot = Screenshot::new(
display_id,
1080,
1920,
1920 * 4, // Assuming RGBA format
Arc::new(vec![0u8; 1920 * 1080 * 4]),
scale_factor,
scale_factor,
);
let tx_for_send = tx.read().await;
let merged_screenshot_tx = merged_screenshot_tx.write().await;
if let Err(err) = merged_screenshot_tx.send(screenshot.clone()) {
// log::warn!("merged_screenshot_tx.send failed: {}", err);
}
if let Err(err) = tx_for_send.send(screenshot.clone()) {
log::warn!("display {} screenshot_tx.send failed: {}", display_id, err);
}
}
}
// Sleep for a frame duration (30 FPS)
sleep(Duration::from_millis(33)).await;
yield_now().await;
}
sleep(Duration::from_secs(5)).await;
info!(
"display {} frame_rx.changed() failed, try to restart",
display_id
);
}
async fn capture_display_screenshot(display_id: u32, scale_factor: f32) -> anyhow::Result<Screenshot> {
// For now, use the existing CGDisplay approach as a fallback
// TODO: Implement proper screen-capture-kit integration
let cg_display = CGDisplay::new(display_id);
let bounds = cg_display.bounds();
let cg_image = CGDisplay::screenshot(
bounds,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowImageDefault,
)
.ok_or_else(|| anyhow::anyhow!("Display#{}: take screenshot failed - possibly no screen recording permission", display_id))?;
let bitmap = cg_image.data();
let width = cg_image.width() as u32;
let height = cg_image.height() as u32;
let bytes_per_row = cg_image.bytes_per_row();
// Convert CFData to Vec<u8>
let data_ptr = bitmap.bytes().as_ptr();
let data_len = bitmap.len() as usize;
let screenshot_data = unsafe {
std::slice::from_raw_parts(data_ptr, data_len).to_vec()
};
Ok(Screenshot::new(
display_id,
height,
width,
bytes_per_row,
Arc::new(screenshot_data),
scale_factor,
scale_factor,
))
}
pub fn get_sorted_colors(colors: &Vec<u8>, mappers: &Vec<SamplePointMapper>) -> Vec<u8> {
@@ -232,7 +289,7 @@ impl ScreenshotManager {
pub async fn subscribe_by_display_id(
&self,
display_id: CGDisplayId,
display_id: u32,
) -> anyhow::Result<watch::Receiver<Screenshot>> {
let channels = self.channels.read().await;
if let Some(tx) = channels.get(&display_id) {

View File

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

View File

@@ -2,7 +2,7 @@ import { Routes, Route } from '@solidjs/router';
import { LedStripConfiguration } from './components/led-strip-configuration/led-strip-configuration';
import { WhiteBalance } from './components/white-balance/white-balance';
import { createEffect } from 'solid-js';
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import { setLedStripStore } from './stores/led-strip.store';
import { LedStripConfigContainer } from './models/led-strip-config';
import { InfoIndex } from './components/info/info-index';

View File

@@ -1,7 +1,7 @@
import { Component, For, createEffect, createSignal } from 'solid-js';
import { listen } from '@tauri-apps/api/event';
import debug from 'debug';
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import { DisplayState, RawDisplayState } from '../../models/display-state.model';
import { DisplayStateCard } from './display-state-card';

View File

@@ -2,7 +2,7 @@ import { Component, For, createEffect, createSignal } from 'solid-js';
import { BoardInfo } from '../../models/board-info.model';
import { listen } from '@tauri-apps/api/event';
import debug from 'debug';
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import { BoardInfoPanel } from './board-info-panel';
const logger = debug('app:components:info:board-index');

View File

@@ -23,7 +23,6 @@ export const DisplayView: Component<DisplayViewProps> = (props) => {
}));
const ledStripConfigs = createMemo(() => {
console.log('ledStripConfigs', ledStripStore.strips);
return ledStripStore.strips.filter((c) => c.display_id === props.display.id);
});

View File

@@ -1,5 +1,5 @@
import { createEffect, onCleanup } from 'solid-js';
import { invoke } from '@tauri-apps/api/tauri';
import { invoke } from '@tauri-apps/api/core';
import { DisplayView } from './display-view';
import { DisplayListContainer } from './display-list-container';
import { displayStore, setDisplayStore } from '../../stores/display.store';
@@ -13,6 +13,7 @@ import {
LedStripConfigurationContextType,
} from '../../contexts/led-strip-configuration.context';
export const LedStripConfiguration = () => {
createEffect(() => {
invoke<string>('list_display_info').then((displays) => {
@@ -21,7 +22,6 @@ export const LedStripConfiguration = () => {
});
});
invoke<LedStripConfigContainer>('read_led_strip_configs').then((configs) => {
console.log(configs);
setLedStripStore(configs);
});
});
@@ -30,7 +30,6 @@ export const LedStripConfiguration = () => {
createEffect(() => {
const unlisten = listen('config_changed', (event) => {
const { strips, mappers } = event.payload as LedStripConfigContainer;
console.log(event.payload);
setLedStripStore({
strips,
mappers,

View File

@@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import {
Component,
createEffect,
@@ -34,7 +34,7 @@ export const Pixel: Component<PixelProps> = (props) => {
title={props.color}
>
<div
class="absolute top-1/2 -translate-y-1/2 h-2.5 w-2.5 rounded-full ring-1 ring-stone-300"
class="absolute top-1/2 -translate-y-1/2 h-2 w-2 rounded-full ring-1 ring-stone-300/30"
style={style()}
/>
</div>
@@ -60,21 +60,44 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
);
if (index === -1) {
console.log('🔍 LED: Strip config not found', {
displayId: localProps.config.display_id,
border: localProps.config.border,
availableStrips: ledStripStore.strips.length
});
return;
}
const mapper = ledStripStore.mappers[index];
if (!mapper) {
console.log('🔍 LED: Mapper not found', { index, mappersCount: ledStripStore.mappers.length });
return;
}
const offset = mapper.pos * 3;
const offset = mapper.start * 3;
console.log('🎨 LED: Updating colors', {
displayId: localProps.config.display_id,
border: localProps.config.border,
stripLength: localProps.config.len,
mapperPos: mapper.pos,
offset,
colorsArrayLength: ledStripStore.colors.length,
firstFewColors: Array.from(ledStripStore.colors.slice(offset, offset + 9))
});
const colors = new Array(localProps.config.len).fill(null).map((_, i) => {
const index = offset + i * 3;
return `rgb(${ledStripStore.colors[index]}, ${ledStripStore.colors[index + 1]}, ${
ledStripStore.colors[index + 2]
})`;
const r = ledStripStore.colors[index] || 0;
const g = ledStripStore.colors[index + 1] || 0;
const b = ledStripStore.colors[index + 2] || 0;
return `rgb(${r}, ${g}, ${b})`;
});
console.log('🎨 LED: Generated colors', {
border: localProps.config.border,
colorsCount: colors.length,
sampleColors: colors.slice(0, 3)
});
setColors(colors);
@@ -120,7 +143,7 @@ export const LedStripPart: Component<LedStripPartProps> = (props) => {
{...rootProps}
ref={setAnchor}
class={
'flex rounded-full flex-nowrap justify-around items-center overflow-hidden ' +
'flex rounded-full flex-nowrap justify-around items-center overflow-hidden bg-gray-800/20 border border-gray-600/30 min-h-[16px] min-w-[16px] ' +
rootProps.class
}
classList={{

View File

@@ -16,7 +16,7 @@ import {
} from 'solid-js';
import { LedStripConfig, LedStripPixelMapper } from '../../models/led-strip-config';
import { ledStripStore } from '../../stores/led-strip.store';
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import { LedStripConfigurationContext } from '../../contexts/led-strip-configuration.context';
import background from '../../assets/transparent-grid-background.svg?url';

View File

@@ -1,4 +1,3 @@
import { convertFileSrc } from '@tauri-apps/api/tauri';
import {
Component,
createEffect,
@@ -18,6 +17,10 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
let canvas: HTMLCanvasElement;
let root: HTMLDivElement;
const [ctx, setCtx] = createSignal<CanvasRenderingContext2D | null>(null);
// Cache temporary canvas for scaling
let tempCanvas: HTMLCanvasElement | null = null;
let tempCtx: CanvasRenderingContext2D | null = null;
const [drawInfo, setDrawInfo] = createSignal({
drawX: 0,
drawY: 0,
@@ -30,9 +33,134 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
height: number;
} | null>(null);
const [hidden, setHidden] = createSignal(false);
const [isLoading, setIsLoading] = createSignal(false);
let isMounted = true;
// Fetch screenshot data from backend
const fetchScreenshot = async () => {
console.log('📸 FETCH: Starting screenshot fetch', {
isLoading: isLoading(),
isMounted,
hidden: hidden(),
timestamp: new Date().toLocaleTimeString()
});
if (isLoading()) {
console.log('⏳ FETCH: Already loading, skipping');
return; // Skip if already loading
}
try {
setIsLoading(true);
const timestamp = Date.now();
const response = await fetch(`ambient-light://displays/${localProps.displayId}?width=400&height=225&t=${timestamp}`);
if (!response.ok) {
console.error('❌ FETCH: Screenshot fetch failed', response.status, response.statusText);
const errorText = await response.text();
console.error('❌ FETCH: Error response body:', errorText);
return;
}
const width = parseInt(response.headers.get('X-Image-Width') || '400');
const height = parseInt(response.headers.get('X-Image-Height') || '225');
const arrayBuffer = await response.arrayBuffer();
const buffer = new Uint8ClampedArray(arrayBuffer);
const expectedSize = width * height * 4;
// Validate buffer size
if (buffer.length !== expectedSize) {
console.error('❌ FETCH: Invalid buffer size!', {
received: buffer.length,
expected: expectedSize,
ratio: buffer.length / expectedSize
});
return;
}
console.log('📊 FETCH: Setting image data', { width, height, bufferSize: buffer.length });
setImageData({
buffer,
width,
height
});
// Use setTimeout to ensure the signal update has been processed
setTimeout(() => {
console.log('🖼️ FETCH: Triggering draw after data set');
draw(false);
}, 0);
// Schedule next frame after rendering is complete
const shouldContinue = !hidden() && isMounted;
console.log('🔄 FETCH: Scheduling next frame', {
hidden: hidden(),
isMounted,
shouldContinue,
nextFrameDelay: '1000ms'
});
if (shouldContinue) {
setTimeout(() => {
if (isMounted) {
console.log('🔄 FETCH: Starting next frame');
fetchScreenshot();
} else {
console.log('❌ FETCH: Component unmounted, stopping loop');
}
}, 1000); // Wait 1 second before next frame
} else {
console.log('❌ FETCH: Loop stopped - component hidden or unmounted');
}
} catch (error) {
console.error('❌ FETCH: Error fetching screenshot:', error);
// Even on error, schedule next frame
const shouldContinueOnError = !hidden() && isMounted;
console.log('🔄 FETCH: Error recovery - scheduling next frame', {
error: error.message,
shouldContinue: shouldContinueOnError,
nextFrameDelay: '2000ms'
});
if (shouldContinueOnError) {
setTimeout(() => {
if (isMounted) {
console.log('🔄 FETCH: Retrying after error');
fetchScreenshot();
}
}, 2000); // Wait longer on error
}
} finally {
setIsLoading(false);
}
};
const resetSize = () => {
const aspectRatio = canvas.width / canvas.height;
console.log('📏 CANVAS: Resizing', {
rootClientWidth: root.clientWidth,
rootClientHeight: root.clientHeight,
oldCanvasWidth: canvas.width,
oldCanvasHeight: canvas.height
});
// Set canvas size first
canvas.width = root.clientWidth;
canvas.height = root.clientHeight;
console.log('📏 CANVAS: Size set', {
newCanvasWidth: canvas.width,
newCanvasHeight: canvas.height
});
// Use a default aspect ratio if canvas dimensions are invalid
const aspectRatio = (canvas.width > 0 && canvas.height > 0)
? canvas.width / canvas.height
: 16 / 9; // Default 16:9 aspect ratio
const drawWidth = Math.round(
Math.min(root.clientWidth, root.clientHeight * aspectRatio),
@@ -51,106 +179,114 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
drawHeight,
});
canvas.width = root.clientWidth;
canvas.height = root.clientHeight;
draw(true);
};
const draw = (cached: boolean = false) => {
const { drawX, drawY } = drawInfo();
const { drawX, drawY, drawWidth, drawHeight } = drawInfo();
let _ctx = ctx();
let raw = imageData();
console.log('🖼️ DRAW: Called with', {
cached,
hasContext: !!_ctx,
hasImageData: !!raw,
imageDataSize: raw ? `${raw.width}x${raw.height}` : 'none',
drawInfo: { drawX, drawY, drawWidth, drawHeight },
canvasSize: `${canvas.width}x${canvas.height}`,
contextType: _ctx ? 'valid' : 'null',
rawBufferSize: raw ? raw.buffer.length : 0
});
if (_ctx && raw) {
console.log('🖼️ DRAW: Starting to draw image');
_ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply transparency effect for cached images if needed
let buffer = raw.buffer;
if (cached) {
for (let i = 3; i < raw.buffer.length; i += 8) {
raw.buffer[i] = Math.floor(raw.buffer[i] * 0.7);
buffer = new Uint8ClampedArray(raw.buffer);
for (let i = 3; i < buffer.length; i += 4) {
buffer[i] = Math.floor(buffer[i] * 0.7);
}
}
const img = new ImageData(raw.buffer, raw.width, raw.height);
try {
// Create ImageData and draw directly
const img = new ImageData(buffer, raw.width, raw.height);
// If the image size matches the draw size, use putImageData directly
if (raw.width === drawWidth && raw.height === drawHeight) {
console.log('🖼️ DRAW: Using putImageData directly');
_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,
});
console.log('✅ DRAW: putImageData completed');
} else {
setImageData(null);
console.log('🖼️ DRAW: Using scaling with temp canvas');
// Otherwise, use cached temporary canvas for scaling
if (!tempCanvas || tempCanvas.width !== raw.width || tempCanvas.height !== raw.height) {
tempCanvas = document.createElement('canvas');
tempCanvas.width = raw.width;
tempCanvas.height = raw.height;
tempCtx = tempCanvas.getContext('2d');
console.log('🖼️ DRAW: Created new temp canvas');
}
draw();
if (tempCtx) {
tempCtx.putImageData(img, 0, 0);
_ctx.drawImage(tempCanvas, drawX, drawY, drawWidth, drawHeight);
console.log('✅ DRAW: Scaled drawing completed');
}
}
} catch (error) {
console.error('❌ DRAW: Error in draw():', error);
}
} else {
console.log('❌ DRAW: Cannot draw - missing context or image data', {
hasContext: !!_ctx,
hasImageData: !!raw
});
}
};
(async () => {
while (!stopped) {
if (hidden()) {
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
await frame();
}
})();
onCleanup(() => {
stopped = true;
});
});
// resize
createEffect(() => {
let resizeObserver: ResizeObserver;
// Initialize canvas and resize observer
onMount(() => {
setCtx(canvas.getContext('2d'));
new ResizeObserver(() => {
console.log('🚀 CANVAS: Component mounted');
const context = canvas.getContext('2d');
console.log('🚀 CANVAS: Context obtained', !!context);
setCtx(context);
console.log('🚀 CANVAS: Context signal set');
// Initial size setup
resetSize();
const resizeObserver = new ResizeObserver(() => {
resetSize();
}).observe(root);
});
resizeObserver.observe(root);
// Start screenshot fetching after context is ready
console.log('🚀 SCREENSHOT: Starting screenshot fetching');
setTimeout(() => {
console.log('🚀 SCREENSHOT: Context ready, starting fetch');
fetchScreenshot(); // Initial fetch - will self-schedule subsequent frames
}, 100); // Small delay to ensure context is ready
onCleanup(() => {
isMounted = false; // Stop scheduling new frames
resizeObserver?.unobserve(root);
console.log('🧹 CLEANUP: Component unmounted');
});
});
// update hidden
createEffect(() => {
const hide = () => {
setHidden(true);
console.log('hide');
};
const show = () => {
setHidden(false);
console.log('show');
};
window.addEventListener('focus', show);
window.addEventListener('blur', hide);
onCleanup(() => {
window.removeEventListener('focus', show);
window.removeEventListener('blur', hide);
});
});
// Note: Removed window focus/blur logic as it was causing screenshot loop to stop
// when user interacted with dev tools or other windows
return (
<div
@@ -158,7 +294,15 @@ export const ScreenView: Component<ScreenViewProps> = (props) => {
{...rootProps}
class={'overflow-hidden h-full w-full ' + rootProps.class}
>
<canvas ref={canvas!} />
<canvas
ref={canvas!}
style={{
display: 'block',
width: '100%',
height: '100%',
'background-color': '#f0f0f0'
}}
/>
{rootProps.children}
</div>
);

View File

@@ -4,7 +4,7 @@ import { ColorCalibration, LedStripConfigContainer } from '../../models/led-stri
import { ledStripStore, setLedStripStore } from '../../stores/led-strip.store';
import { ColorSlider } from './color-slider';
import { TestColorsBg } from './test-colors-bg';
import { invoke } from '@tauri-apps/api';
import { invoke } from '@tauri-apps/api/core';
import { VsClose } from 'solid-icons/vs';
import { BiRegularReset } from 'solid-icons/bi';
import transparentBg from '../../assets/transparent-grid-background.svg?url';

View File

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