Improve Images, Compress, Output, Workflow steps
- Images step: folder drag-and-drop with recursive image scanning, per-file list with format and size info, total file size in header, supported formats label in empty state - Compress step: per-format quality controls moved into AdwExpanderRow, improved quality level descriptions - Output step: dynamic image count with total size from loaded_files, initial overwrite behavior from config - Workflow step: properly handle MetadataConfig::Custom in preset import, mapping all custom metadata fields to JobConfig
This commit is contained in:
@@ -33,7 +33,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
// Quality slider
|
// Quality slider
|
||||||
let quality_group = adw::PreferencesGroup::builder()
|
let quality_group = adw::PreferencesGroup::builder()
|
||||||
.title("Quality Level")
|
.title("Quality Level")
|
||||||
.description("Higher quality means larger files")
|
.description("Higher quality means larger files. This sets the overall quality target.")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let initial_val = match cfg.quality_preset {
|
let initial_val = match cfg.quality_preset {
|
||||||
@@ -78,10 +78,14 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
quality_group.add(&quality_box);
|
quality_group.add(&quality_box);
|
||||||
content.append(&quality_group);
|
content.append(&quality_group);
|
||||||
|
|
||||||
// Advanced options
|
// Advanced options in expander
|
||||||
let advanced_group = adw::PreferencesGroup::builder()
|
let advanced_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Advanced Options")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let advanced_expander = adw::ExpanderRow::builder()
|
||||||
.title("Per-Format Quality")
|
.title("Per-Format Quality")
|
||||||
.description("Fine-tune quality for each format individually")
|
.subtitle("Fine-tune quality for each format individually")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let jpeg_row = adw::SpinRow::builder()
|
let jpeg_row = adw::SpinRow::builder()
|
||||||
@@ -102,9 +106,11 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
.adjustment(>k::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0))
|
.adjustment(>k::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
advanced_group.add(&jpeg_row);
|
advanced_expander.add_row(&jpeg_row);
|
||||||
advanced_group.add(&png_row);
|
advanced_expander.add_row(&png_row);
|
||||||
advanced_group.add(&webp_row);
|
advanced_expander.add_row(&webp_row);
|
||||||
|
|
||||||
|
advanced_group.add(&advanced_expander);
|
||||||
content.append(&advanced_group);
|
content.append(&advanced_group);
|
||||||
|
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
@@ -118,7 +124,7 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
let jc = state.job_config.clone();
|
let jc = state.job_config.clone();
|
||||||
let label = quality_label.clone();
|
let label = quality_label;
|
||||||
quality_scale.connect_value_changed(move |scale| {
|
quality_scale.connect_value_changed(move |scale| {
|
||||||
let val = scale.value().round() as u32;
|
let val = scale.value().round() as u32;
|
||||||
let mut c = jc.borrow_mut();
|
let mut c = jc.borrow_mut();
|
||||||
@@ -167,10 +173,10 @@ pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
|
|
||||||
fn quality_description(val: u32) -> String {
|
fn quality_description(val: u32) -> String {
|
||||||
match val {
|
match val {
|
||||||
1 => "Web Optimized - smallest files, noticeable quality loss".into(),
|
1 => "Web Optimized - smallest files, noticeable quality loss. Best for thumbnails.".into(),
|
||||||
2 => "Low - small files, some quality loss".into(),
|
2 => "Low - small files, some quality loss. Good for email attachments.".into(),
|
||||||
3 => "Medium - good balance of quality and size".into(),
|
3 => "Medium - good balance of quality and size. Recommended for most uses.".into(),
|
||||||
4 => "High - large files, minimal quality loss".into(),
|
4 => "High - large files, minimal quality loss. Good for printing.".into(),
|
||||||
_ => "Maximum - largest files, best possible quality".into(),
|
_ => "Maximum - largest files, best possible quality. Archival use.".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
let empty_state = build_empty_state();
|
let empty_state = build_empty_state();
|
||||||
stack.add_named(&empty_state, Some("empty"));
|
stack.add_named(&empty_state, Some("empty"));
|
||||||
|
|
||||||
// Loaded state - thumbnail grid
|
// Loaded state - file list
|
||||||
let loaded_state = build_loaded_state(state);
|
let loaded_state = build_loaded_state(state);
|
||||||
stack.add_named(&loaded_state, Some("loaded"));
|
stack.add_named(&loaded_state, Some("loaded"));
|
||||||
|
|
||||||
@@ -24,20 +24,28 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
let loaded_files = state.loaded_files.clone();
|
let loaded_files = state.loaded_files.clone();
|
||||||
let stack_ref = stack.clone();
|
let stack_ref = stack.clone();
|
||||||
drop_target.connect_drop(move |_target, value, _x, _y| {
|
drop_target.connect_drop(move |_target, value, _x, _y| {
|
||||||
// Try single file
|
|
||||||
if let Ok(file) = value.get::<gtk::gio::File>()
|
if let Ok(file) = value.get::<gtk::gio::File>()
|
||||||
&& let Some(path) = file.path()
|
&& let Some(path) = file.path()
|
||||||
&& is_image_file(&path)
|
|
||||||
{
|
{
|
||||||
|
if path.is_dir() {
|
||||||
|
// Recursively add images from directory
|
||||||
|
let mut files = loaded_files.borrow_mut();
|
||||||
|
add_images_from_dir(&path, &mut files);
|
||||||
|
let count = files.len();
|
||||||
|
drop(files);
|
||||||
|
update_loaded_ui(&stack_ref, &loaded_files, count);
|
||||||
|
return true;
|
||||||
|
} else if is_image_file(&path) {
|
||||||
let mut files = loaded_files.borrow_mut();
|
let mut files = loaded_files.borrow_mut();
|
||||||
if !files.contains(&path) {
|
if !files.contains(&path) {
|
||||||
files.push(path);
|
files.push(path);
|
||||||
}
|
}
|
||||||
let count = files.len();
|
let count = files.len();
|
||||||
drop(files);
|
drop(files);
|
||||||
update_loaded_ui(&stack_ref, count);
|
update_loaded_ui(&stack_ref, &loaded_files, count);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
false
|
false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -58,29 +66,101 @@ fn is_image_file(path: &std::path::Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_loaded_ui(stack: >k::Stack, count: usize) {
|
fn add_images_from_dir(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
|
||||||
if count > 0 {
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
stack.set_visible_child_name("loaded");
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
add_images_from_dir(&path, files);
|
||||||
|
} else if is_image_file(&path) && !files.contains(&path) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(loaded_box) = stack.child_by_name("loaded") {
|
|
||||||
update_count_label(&loaded_box, count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_count_label(widget: >k::Widget, count: usize) {
|
fn update_loaded_ui(
|
||||||
|
stack: >k::Stack,
|
||||||
|
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
||||||
|
count: usize,
|
||||||
|
) {
|
||||||
|
if count > 0 {
|
||||||
|
stack.set_visible_child_name("loaded");
|
||||||
|
} else {
|
||||||
|
stack.set_visible_child_name("empty");
|
||||||
|
}
|
||||||
|
if let Some(loaded_widget) = stack.child_by_name("loaded") {
|
||||||
|
update_count_and_list(&loaded_widget, loaded_files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_count_and_list(
|
||||||
|
widget: >k::Widget,
|
||||||
|
loaded_files: &std::rc::Rc<std::cell::RefCell<Vec<std::path::PathBuf>>>,
|
||||||
|
) {
|
||||||
|
let files = loaded_files.borrow();
|
||||||
|
let count = files.len();
|
||||||
|
let total_size: u64 = files.iter()
|
||||||
|
.filter_map(|p| std::fs::metadata(p).ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum();
|
||||||
|
let size_str = format_size(total_size);
|
||||||
|
|
||||||
|
// Walk widget tree to find and update components
|
||||||
|
walk_loaded_widgets(widget, count, &size_str, &files);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_loaded_widgets(widget: >k::Widget, count: usize, size_str: &str, files: &[std::path::PathBuf]) {
|
||||||
if let Some(label) = widget.downcast_ref::<gtk::Label>()
|
if let Some(label) = widget.downcast_ref::<gtk::Label>()
|
||||||
&& label.css_classes().iter().any(|c| c == "heading")
|
&& label.css_classes().iter().any(|c| c == "heading")
|
||||||
{
|
{
|
||||||
label.set_label(&format!("{} images loaded", count));
|
label.set_label(&format!("{} images ({})", count, size_str));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if let Some(bx) = widget.downcast_ref::<gtk::Box>() {
|
if let Some(list_box) = widget.downcast_ref::<gtk::ListBox>()
|
||||||
let mut child = bx.first_child();
|
&& list_box.css_classes().iter().any(|c| c == "boxed-list")
|
||||||
|
{
|
||||||
|
// Clear existing rows
|
||||||
|
while let Some(row) = list_box.first_child() {
|
||||||
|
list_box.remove(&row);
|
||||||
|
}
|
||||||
|
// Add rows for each file
|
||||||
|
for path in files {
|
||||||
|
let name = path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let size = std::fs::metadata(path)
|
||||||
|
.map(|m| format_size(m.len()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let ext = path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_uppercase();
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(name)
|
||||||
|
.subtitle(format!("{} - {}", ext, size))
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
||||||
|
list_box.append(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recurse
|
||||||
|
let mut child = widget.first_child();
|
||||||
while let Some(c) = child {
|
while let Some(c) = child {
|
||||||
update_count_label(&c, count);
|
walk_loaded_widgets(&c, count, size_str, files);
|
||||||
child = c.next_sibling();
|
child = c.next_sibling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_size(bytes: u64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{} B", bytes)
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||||
|
} else if bytes < 1024 * 1024 * 1024 {
|
||||||
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_empty_state() -> gtk::Box {
|
fn build_empty_state() -> gtk::Box {
|
||||||
@@ -125,8 +205,17 @@ fn build_empty_state() -> gtk::Box {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let subtitle = gtk::Label::builder()
|
let subtitle = gtk::Label::builder()
|
||||||
.label("or click Browse to select files")
|
.label("or click Browse to select files.\nYou can also drop folders.")
|
||||||
.css_classes(["dim-label"])
|
.css_classes(["dim-label"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.justify(gtk::Justification::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let formats_label = gtk::Label::builder()
|
||||||
|
.label("Supported: JPEG, PNG, WebP, AVIF, GIF, TIFF, BMP")
|
||||||
|
.css_classes(["dim-label", "caption"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.margin_top(8)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let browse_button = gtk::Button::builder()
|
let browse_button = gtk::Button::builder()
|
||||||
@@ -141,6 +230,7 @@ fn build_empty_state() -> gtk::Box {
|
|||||||
inner.append(&icon);
|
inner.append(&icon);
|
||||||
inner.append(&title);
|
inner.append(&title);
|
||||||
inner.append(&subtitle);
|
inner.append(&subtitle);
|
||||||
|
inner.append(&formats_label);
|
||||||
inner.append(&browse_button);
|
inner.append(&browse_button);
|
||||||
drop_zone.append(&inner);
|
drop_zone.append(&inner);
|
||||||
|
|
||||||
@@ -165,7 +255,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let count_label = gtk::Label::builder()
|
let count_label = gtk::Label::builder()
|
||||||
.label("0 images loaded")
|
.label("0 images")
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.halign(gtk::Align::Start)
|
.halign(gtk::Align::Start)
|
||||||
.css_classes(["heading"])
|
.css_classes(["heading"])
|
||||||
@@ -173,7 +263,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
|
|
||||||
let add_button = gtk::Button::builder()
|
let add_button = gtk::Button::builder()
|
||||||
.icon_name("list-add-symbolic")
|
.icon_name("list-add-symbolic")
|
||||||
.tooltip_text("Add more images")
|
.tooltip_text("Add more images (Ctrl+O)")
|
||||||
.action_name("win.add-files")
|
.action_name("win.add-files")
|
||||||
.build();
|
.build();
|
||||||
add_button.add_css_class("flat");
|
add_button.add_css_class("flat");
|
||||||
@@ -190,8 +280,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
let count_label_c = count_label.clone();
|
let count_label_c = count_label.clone();
|
||||||
clear_button.connect_clicked(move |btn| {
|
clear_button.connect_clicked(move |btn| {
|
||||||
files.borrow_mut().clear();
|
files.borrow_mut().clear();
|
||||||
count_label_c.set_label("0 images loaded");
|
count_label_c.set_label("0 images");
|
||||||
// Navigate back to empty state by finding parent stack
|
|
||||||
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
|
if let Some(parent) = btn.ancestor(gtk::Stack::static_type())
|
||||||
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
|
&& let Some(stack) = parent.downcast_ref::<gtk::Stack>()
|
||||||
{
|
{
|
||||||
@@ -206,7 +295,7 @@ fn build_loaded_state(state: &AppState) -> gtk::Box {
|
|||||||
|
|
||||||
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||||
|
|
||||||
// File list showing loaded images
|
// File list
|
||||||
let list_scrolled = gtk::ScrolledWindow::builder()
|
let list_scrolled = gtk::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
.description("Review your processing settings before starting")
|
.description("Review your processing settings before starting")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let summary_placeholder = adw::ActionRow::builder()
|
let summary_row = adw::ActionRow::builder()
|
||||||
.title("No operations configured")
|
.title("No operations configured")
|
||||||
.subtitle("Go back and configure your workflow settings")
|
.subtitle("Go back and configure your workflow settings")
|
||||||
.build();
|
.build();
|
||||||
summary_placeholder.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic"));
|
summary_row.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic"));
|
||||||
|
|
||||||
summary_group.add(&summary_placeholder);
|
summary_group.add(&summary_row);
|
||||||
content.append(&summary_group);
|
content.append(&summary_group);
|
||||||
|
|
||||||
// Output directory
|
// Output directory
|
||||||
@@ -54,10 +54,12 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
output_row.add_suffix(&choose_button);
|
output_row.add_suffix(&choose_button);
|
||||||
output_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
output_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
|
||||||
|
let cfg = state.job_config.borrow();
|
||||||
|
|
||||||
let structure_row = adw::SwitchRow::builder()
|
let structure_row = adw::SwitchRow::builder()
|
||||||
.title("Preserve Directory Structure")
|
.title("Preserve Directory Structure")
|
||||||
.subtitle("Keep subfolder hierarchy in output")
|
.subtitle("Keep subfolder hierarchy in output")
|
||||||
.active(false)
|
.active(cfg.preserve_dir_structure)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
output_group.add(&output_row);
|
output_group.add(&output_row);
|
||||||
@@ -80,24 +82,33 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
"Skip existing files",
|
"Skip existing files",
|
||||||
]);
|
]);
|
||||||
overwrite_row.set_model(Some(&overwrite_model));
|
overwrite_row.set_model(Some(&overwrite_model));
|
||||||
|
overwrite_row.set_selected(cfg.overwrite_behavior as u32);
|
||||||
|
|
||||||
overwrite_group.add(&overwrite_row);
|
overwrite_group.add(&overwrite_row);
|
||||||
content.append(&overwrite_group);
|
content.append(&overwrite_group);
|
||||||
|
|
||||||
// Image count
|
// Image count - dynamically updated
|
||||||
let stats_group = adw::PreferencesGroup::builder()
|
let stats_group = adw::PreferencesGroup::builder()
|
||||||
.title("Batch Info")
|
.title("Batch Info")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
let file_count = state.loaded_files.borrow().len();
|
||||||
|
let total_size: u64 = state.loaded_files.borrow().iter()
|
||||||
|
.filter_map(|p| std::fs::metadata(p).ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum();
|
||||||
|
|
||||||
let count_row = adw::ActionRow::builder()
|
let count_row = adw::ActionRow::builder()
|
||||||
.title("Images to process")
|
.title("Images to process")
|
||||||
.subtitle("0 images")
|
.subtitle(format!("{} images ({})", file_count, format_size(total_size)))
|
||||||
.build();
|
.build();
|
||||||
count_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
count_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
||||||
|
|
||||||
stats_group.add(&count_row);
|
stats_group.add(&count_row);
|
||||||
content.append(&stats_group);
|
content.append(&stats_group);
|
||||||
|
|
||||||
|
drop(cfg);
|
||||||
|
|
||||||
// Wire preserve directory structure
|
// Wire preserve directory structure
|
||||||
{
|
{
|
||||||
let jc = state.job_config.clone();
|
let jc = state.job_config.clone();
|
||||||
@@ -127,3 +138,15 @@ pub fn build_output_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
.child(&clamp)
|
.child(&clamp)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_size(bytes: u64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{} B", bytes)
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||||
|
} else if bytes < 1024 * 1024 * 1024 {
|
||||||
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,9 +213,14 @@ fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
|||||||
cfg.metadata_enabled = true;
|
cfg.metadata_enabled = true;
|
||||||
cfg.metadata_mode = MetadataMode::KeepAll;
|
cfg.metadata_mode = MetadataMode::KeepAll;
|
||||||
}
|
}
|
||||||
Some(MetadataConfig::Custom { .. }) => {
|
Some(MetadataConfig::Custom { strip_gps, strip_camera, strip_software, strip_timestamps, strip_copyright }) => {
|
||||||
cfg.metadata_enabled = true;
|
cfg.metadata_enabled = true;
|
||||||
cfg.metadata_mode = MetadataMode::StripAll;
|
cfg.metadata_mode = MetadataMode::Custom;
|
||||||
|
cfg.strip_gps = *strip_gps;
|
||||||
|
cfg.strip_camera = *strip_camera;
|
||||||
|
cfg.strip_software = *strip_software;
|
||||||
|
cfg.strip_timestamps = *strip_timestamps;
|
||||||
|
cfg.strip_copyright = *strip_copyright;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
cfg.metadata_enabled = false;
|
cfg.metadata_enabled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user