commit 9d35b244c22a0bc629887380ed9bc6635d5afe5c Author: Alicia Moore Date: Mon Jun 22 22:05:39 2026 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8566a88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +olympuswifi +src/*.c diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c20e75a --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +CC = valac +PKGS = --pkg gtk4 --pkg libadwaita-1 --pkg libsoup-2.4 --pkg gio-2.0 --pkg gdk-pixbuf-2.0 --pkg pango +SRCS = src/main.vala src/camera-client.vala +TARGET = olympuswifi +CFLAGS = -g + +all: $(TARGET) + +$(TARGET): $(SRCS) + $(CC) $(CFLAGS) $(PKGS) $(SRCS) -o $(TARGET) + +run: $(TARGET) + ./$(TARGET) + +clean: + rm -f $(TARGET) + +.PHONY: all run clean diff --git a/OPC_Communication_Protocol_EN_1.0a.pdf b/OPC_Communication_Protocol_EN_1.0a.pdf new file mode 100644 index 0000000..a04dd67 Binary files /dev/null and b/OPC_Communication_Protocol_EN_1.0a.pdf differ diff --git a/src/camera-client.vala b/src/camera-client.vala new file mode 100644 index 0000000..859b476 --- /dev/null +++ b/src/camera-client.vala @@ -0,0 +1,412 @@ +// Camera client for Olympus OPC protocol +// Communicates with camera at 192.168.0.10:80 via HTTP + +public class ImageInfo : Object { + public string directory { get; set; } + public string filename { get; set; } + public int64 size { get; set; } + public int attribute { get; set; } + public DateTime? date_time { get; set; } + public string full_path { get; set; } + + public bool is_hidden { + get { return (attribute & 2) != 0; } + } + + public bool is_directory { + get { return (attribute & 16) != 0; } + } + + public string display_date { + owned get { + if (date_time == null) return "Unknown"; + return date_time.format ("%Y-%m-%d %H:%M"); + } + } + + public ImageInfo (string directory, string filename, int64 size, int attribute, + DateTime? date_time, string full_path) { + this.directory = directory; + this.filename = filename; + this.size = size; + this.attribute = attribute; + this.date_time = date_time; + this.full_path = full_path; + } +} + +public class CameraClient : Object { + private Soup.Session session; + private MainContext? context = null; + private bool owns_context = false; + private const string CAMERA_HOST = "192.168.0.10"; + private const int CAMERA_PORT = 80; + private const int TIMEOUT_SECONDS = 5; + + public bool connected { get; private set; default = false; } + public static bool debug { get; set; default = false; } + + public CameraClient () { + // Worker threads typically have no thread-default GMainContext. + // Soup.Session.send_message internally iterates a GMainContext, + // so we must push one to avoid crashes with NULL context. + context = MainContext.get_thread_default (); + if (context == null) { + context = new MainContext (); + context.push_thread_default (); + owns_context = true; + } + session = new Soup.Session (); + session.user_agent = "OI.Share v2"; + session.timeout = TIMEOUT_SECONDS; + } + + ~CameraClient () { + if (owns_context && context != null) { + context.pop_thread_default (); + } + } + + private string build_url (string path) { + return "http://%s:%d%s".printf( CAMERA_HOST, CAMERA_PORT, path); + } + + // Perform synchronous HTTP GET, return response body as Bytes + private Bytes do_get (string url) throws Error { + if (debug) { + stdout.printf (">>> GET %s\n", url); + } + + var msg = new Soup.Message ("GET", url); + session.send_message (msg); + + if (debug) { + stdout.printf ("<<< HTTP %u %s\n", msg.status_code, msg.reason_phrase); + } + + if (msg.status_code != 200) { + if (debug) { + stdout.printf ("<<< ERROR: HTTP %u - body follows:\n", msg.status_code); + unowned uint8[] err_raw = msg.response_body.data; + if (err_raw != null) { + var err_body = (string) err_raw; + if (((string) err_body).validate ()) + stdout.printf ("%s\n", (string) err_body); + } + } + throw new IOError.FAILED ( + "HTTP request to %s failed with status %u", url, msg.status_code + ); + } + + // Extract response body as Bytes. + // Use unowned access to avoid an extra intermediate copy. + unowned uint8[] raw_data = msg.response_body.data; + var bytes = new Bytes (raw_data); + + if (debug) { + if (raw_data != null) { + var body_str = (string) raw_data; + if (((string) body_str).validate ()) { + stdout.printf ("<<< Body (%ld bytes):\n%s\n", raw_data.length, (string) body_str); + } else { + stdout.printf ("<<< Body: %ld bytes (binary data)\n", raw_data.length); + } + } else { + stdout.printf ("<<< Body: 0 bytes (empty)\n"); + } + } + + return bytes; + } + + // --- Public API --- + + // Probe camera by fetching its command list. + // Modern Olympus cameras don't necessarily return OPC mode from + // get_connectmode.cgi; instead they respond to get_commandlist.cgi + // directly. This matches the approach used by the working + // olympus-wifi library (OI.Share v2 user-agent). + public bool check_connection( ) throws Error { + if (debug) { + stdout.printf ("[debug] Probing camera with get_commandlist.cgi…\n"); + } + try { + var body = do_get (build_url ("/get_commandlist.cgi")); + string xml = (string)body.get_data (); + if (xml.contains ("OPC")) { + connected = true; + return true; + } + if (debug) { + stdout.printf ("[debug] get_connectmode returned: %s\n", xml.replace( "\n", " ").strip( )); + } + } catch (Error e) { + if (debug) { + stdout.printf ("[debug] get_connectmode failed: %s\n", e.message); + } + } + + return false; + } + + // Switch operation mode (play, rec) + // Note: modern Olympus cameras use "switch_cammode.cgi" (one 'm') + // not "switch_cameramode.cgi" as in the older OPC spec. + public bool switch_mode( string mode) throws Error { + string url = build_url ("/switch_cammode.cgi?mode=%s".printf( mode)); + var body = do_get (url); + string xml = (string)body.get_data (); + if (debug) { + stdout.printf ("[debug] switch_cammode response: %s\n", xml.replace( "\n", " ").strip( )); + } + // Some cameras return OK, some just return HTTP 200 with empty body + return true; + } + + // Activate session (some cameras require this before other commands) + public void activate( ) throws Error { + try { + do_get (build_url ("/get_activate.cgi")); + if (debug) { + stdout.printf ("[debug] get_activate succeeded\n"); + } + } catch (Error e) { + if (debug) { + stdout.printf ("[debug] get_activate failed (non-fatal): %s\n", e.message); + } + } + } + + // Get list of items in a DCF directory + // Returns an array of ImageInfo + // Note: 404 means empty directory on modern cameras, returns empty list. + public ImageInfo[] get_image_list( string dir) throws Error { + string url = build_url ("/get_imglist.cgi?DIR=%s".printf( dir)); + Bytes body; + try { + body = do_get (url); + } catch (Error e) { + if (e.message.contains ("status 404")) { + if (debug) { + stdout.printf ("[debug] get_imglist(%s): 404 (empty directory)\n", dir); + } + return new ImageInfo[0]; + } + throw e; + } + string text = (string)body.get_data (); + + string[] lines = text.split ("\n"); + + if (debug) { + stdout.printf ("[debug] get_imglist(%s) response (%lu bytes):\n", dir, body.get_size( )); + // Print first few lines for debugging + for (int i = 0; i < lines.length && i < 5; i++) { + stdout.printf ("[debug] line %d: %s\n", i, lines[i]); + } + } + var results = new ImageInfo[lines.length - 1]; // max possible + int count = 0; + + for (int i = 0; i < lines.length; i++) { + string line = lines[i].strip (); + if (line.length == 0 || line == "VER_100") continue; + + string[] parts = line.split (",", 6); + if (parts.length < 6) continue; + + string directory = parts[0]; + string filename = parts[1]; + int64 size = int64.parse (parts[2]); + int attribute = int.parse (parts[3]); + string full_path = "%s/%s".printf( directory, filename); + + // Parse packed date + int64 packed_date = int64.parse( parts[4]); + int64 packed_time = int64.parse (parts[5]); + + DateTime? dt = decode_opc_date (packed_date, packed_time); + + results[count] = new ImageInfo (directory, filename, size, attribute, dt, full_path); + count++; + } + + // Trim to actual count + var trimmed = new ImageInfo[count]; + for (int i = 0; i < count; i++) { + trimmed[i] = results[i]; + } + return trimmed; + } + + // Discover all DCF directories on the camera. + // Probes multiple paths and tries common DCF directory names. + // Returns an array of directory paths that contain images. + public string[] discover_dcf_directories( ) throws Error { + string[] found_dirs = new string[0]; + + // Probe known DCF paths that could contain subdirectories + string[] probe_paths = {"/", "/DCIM/"}; + + int count = 0; + foreach (string probe in probe_paths) { + try { + var items = get_image_list (probe); + // Make a first pass to count subdirectories + int sub_count = 0; + foreach (var item in items) { + if (item.is_directory && !item.is_hidden) { + sub_count++; + } + } + if (sub_count > 0) { + found_dirs = new string[sub_count]; + int idx = 0; + foreach (var item in items) { + if (item.is_directory && !item.is_hidden) { + found_dirs[idx] = item.full_path; + idx++; + if (debug) { + stdout.printf ("[debug] Discovered DCF dir: %s\n", item.full_path); + } + } + } + count = sub_count; + break; + } + } catch (Error e) { + if (debug) { + stdout.printf ("[debug] Probe %s: %s\n", probe, e.message); + } + } + } + + // If probe paths yielded directories, return them + if (count > 0) { + return found_dirs; + } + + // Fallback: try standard DCF directory names directly + string[] fallbacks = {"/DCIM/100OLYMP", "/DCIM/101OLYMP", "/DCIM/102OLYMP", + "/DCIM/103OLYMP", "/DCIM/104OLYMP", "/DCIM/105OLYMP"}; + int fb_count = 0; + foreach (string d in fallbacks) { + try { + get_image_list (d); + fb_count++; + } catch (Error e) { + // doesn't exist, skip + } + } + if (fb_count > 0) { + found_dirs = new string[fb_count]; + int idx = 0; + foreach (string d in fallbacks) { + try { + get_image_list (d); + found_dirs[idx] = d; + idx++; + if (debug) { + stdout.printf ("[debug] Found DCF directory (fallback): %s\n", d); + } + } catch (Error e) { + } + } + return found_dirs; + } + + if (debug) { + stdout.printf ("[debug] No DCF directories found - camera may have no SD card\n"); + } + + return found_dirs; + } + + // Get all images across all DCF directories + public ImageInfo[] get_all_images( ) throws Error { + var dirs = discover_dcf_directories (); + + // First pass: count total images + int total = 0; + foreach (string dir in dirs) { + var images = get_image_list (dir); + foreach (var img in images) { + if (!img.is_directory && !img.is_hidden) { + total++; + } + } + } + + var all = new ImageInfo[total]; + int idx = 0; + foreach (string dir in dirs) { + var images = get_image_list (dir); + foreach (var img in images) { + if (!img.is_directory && !img.is_hidden) { + all[idx] = img; + idx++; + } + } + } + + return all; + } + + // Get thumbnail image (160x120 JPEG) + public Bytes get_thumbnail( string path) throws Error { + string url = build_url ("/get_thumbnail.cgi?DIR=%s".printf( path)); + return do_get (url); + } + + // Get image for control device display (1920x1440 max) + public Bytes get_screennail( string path) throws Error { + string url = build_url ("/get_screennail.cgi?DIR=%s".printf( path)); + return do_get (url); + } + + // Download original image file from camera + public Bytes download_original( string path) throws Error { + return do_get (build_url (path)); + } +} + +// Helper to decode packed date integer from OPC protocol +// bits 4-0: day, bits 8-5: month, bits 15-9: year (offset from 1980) +DateTime? decode_opc_date( int64 packed_date, int64 packed_time) { + int day = (int)(packed_date & 0x1F); + int month = (int)((packed_date >> 5) & 0x0F); + int year = (int)((packed_date >> 9) + 1980); + int seconds = (int)(packed_time & 0x1F) * 2; + int minutes = (int)((packed_time >> 5) & 0x3F); + int hours = (int)((packed_time >> 11) & 0x1F); + + if (year < 1980 || month < 1 || month > 12 || day < 1 || day > 31) { + return null; + } + + return new DateTime.local (year, month, day, hours, minutes, seconds); +} diff --git a/src/main.vala b/src/main.vala new file mode 100644 index 0000000..485536d --- /dev/null +++ b/src/main.vala @@ -0,0 +1,674 @@ +// Olympus Wi-Fi Camera Viewer +// GTK4 + Libadwaita application for browsing and importing images +// from Olympus cameras using the OPC protocol + +public class ImageItem : Gtk.FlowBoxChild { + private Gtk.Image thumbnail_image; + private Gtk.Label name_label; + private Gtk.Label date_label; + private Gtk.Box main_box; + private Gtk.Overlay overlay; + private Gtk.CheckButton check_button; + + public ImageInfo info { get; construct; } + public bool selected { + get { return check_button.active; } + set { check_button.active = value; } + } + + public signal void selection_changed (); + + public ImageItem (ImageInfo info) { + Object (info: info); + } + + construct { + hexpand = true; + halign = Gtk.Align.CENTER; + + overlay = new Gtk.Overlay (); + + main_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 4); + main_box.margin_start = 4; + main_box.margin_end = 4; + main_box.margin_top = 4; + main_box.margin_bottom = 4; + + // Thumbnail image + thumbnail_image = new Gtk.Image( ); + thumbnail_image.pixel_size = 160; + thumbnail_image.icon_name = "image-x-generic"; + thumbnail_image.halign = Gtk.Align.CENTER; + thumbnail_image.valign = Gtk.Align.CENTER; + + var image_frame = new Gtk.Frame (null); + image_frame.child = thumbnail_image; + image_frame.width_request = 160; + image_frame.height_request = 120; + main_box.append (image_frame); + + // File name + name_label = new Gtk.Label( info.filename); + name_label.ellipsize = Pango.EllipsizeMode.END; + name_label.max_width_chars = 20; + name_label.halign = Gtk.Align.CENTER; + var attr_list = new Pango.AttrList (); + attr_list.insert (Pango.attr_weight_new (Pango.Weight.BOLD)); + name_label.attributes = attr_list; + main_box.append (name_label); + + // Date + date_label = new Gtk.Label( info.display_date); + date_label.ellipsize = Pango.EllipsizeMode.END; + date_label.max_width_chars = 20; + date_label.halign = Gtk.Align.CENTER; + date_label.opacity = 0.7; + main_box.append (date_label); + + overlay.child = main_box; + + // Check button overlay for selection (top-left corner) + check_button = new Gtk.CheckButton( ); + check_button.halign = Gtk.Align.START; + check_button.valign = Gtk.Align.START; + check_button.margin_start = 8; + check_button.margin_top = 8; + check_button.add_css_class ("selection-mode"); + overlay.add_overlay (check_button); + + child = overlay; + + check_button.toggled.connect (() => { + update_selection_style (); + selection_changed (); + }); + + // Click toggles selection + var gesture = new Gtk.GestureClick( ); + gesture.pressed.connect ((n_press, x, y) => { + if (n_press == 1) { + check_button.active = !check_button.active; + } + }); + add_controller (gesture); + + add_css_class ("image-item"); + } + + private void update_selection_style () { + if (check_button.active) { + add_css_class ("selected-image"); + } else { + remove_css_class ("selected-image"); + } + } + + public void set_thumbnail_from_bytes (Bytes bytes) { + try { + var texture = Gdk.Texture.from_bytes (bytes); + if (texture != null) { + thumbnail_image.set_from_paintable (texture); + } else { + thumbnail_image.icon_name = "image-x-generic"; + } + } catch (Error e) { + warning ("Failed to load thumbnail: %s", e.message); + thumbnail_image.icon_name = "image-x-generic"; + } + } +} + +public class ConnectPage : Gtk.Box { + private Gtk.Button connect_button; + private Gtk.Label status_label; + private Gtk.Spinner spinner; + private Gtk.Box status_box; + + public signal void connection_succeeded (); + + public ConnectPage () { + Object ( + orientation: Gtk.Orientation.VERTICAL, + spacing: 16, + halign: Gtk.Align.CENTER, + valign: Gtk.Align.CENTER + ); + } + + construct { + var title_label = new Gtk.Label ("Olympus Wi-Fi Camera"); + var title_attrs = new Pango.AttrList (); + title_attrs.insert (Pango.attr_scale_new (Pango.Scale.XX_LARGE)); + title_attrs.insert (Pango.attr_weight_new (Pango.Weight.BOLD)); + title_label.attributes = title_attrs; + title_label.add_css_class ("title-label"); + append (title_label); + + var subtitle_label = new Gtk.Label ("Browse and import photos from your Olympus camera"); + subtitle_label.opacity = 0.7; + append (subtitle_label); + + append (new Gtk.Label ("")); + + // Connect button + connect_button = new Gtk.Button.with_label( "Connect to Camera"); + connect_button.add_css_class ("suggested-action"); + connect_button.add_css_class ("circular"); + connect_button.hexpand = false; + connect_button.width_request = 250; + connect_button.height_request = 60; + connect_button.valign = Gtk.Align.CENTER; + + var button_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0); + button_box.halign = Gtk.Align.CENTER; + button_box.append (connect_button); + append (button_box); + + // Status box (spinner + label) + status_box = new Gtk.Box( Gtk.Orientation.HORIZONTAL, 8); + status_box.halign = Gtk.Align.CENTER; + status_box.visible = false; + + spinner = new Gtk.Spinner (); + status_box.append (spinner); + + status_label = new Gtk.Label (""); + status_box.append (status_label); + + append (status_box); + + connect_button.clicked.connect (on_connect_clicked); + } + + private void on_connect_clicked () { + set_connecting_state ("Connecting to camera…"); + connect_button.sensitive = false; + new Thread ("connect", connect_thread); + } + + private void* connect_thread () { + var camera = new CameraClient (); + + try { + // Step 1: Check/establish OPC connection + Idle.add( () => { + status_label.label = "Establishing OPC connection…"; + return false; + }); + Thread.usleep (100000); + + if (!camera.check_connection ()) { + string msg = "Failed: Camera is not responding with OPC protocol."; + Idle.add (() => { + set_idle_state (msg); + connect_button.sensitive = true; + return false; + }); + return null; + } + + // Step 2: Activate session (some cameras require this) + Idle.add( () => { + status_label.label = "Activating session…"; + return false; + }); + Thread.usleep (100000); + camera.activate (); + + // Step 3: Switch to playback mode + Idle.add( () => { + status_label.label = "Switching to playback mode…"; + return false; + }); + Thread.usleep (100000); + + if (!camera.switch_mode ("play")) { + string msg = "Failed to switch camera to playback mode."; + Idle.add (() => { + set_idle_state (msg); + connect_button.sensitive = true; + return false; + }); + return null; + } + + Idle.add (() => { + set_idle_state ("Connected! Loading gallery…"); + connect_button.sensitive = true; + connection_succeeded (); + return false; + }); + + } catch (Error connect_err) { + string msg = "Connection failed: %s".printf( connect_err.message); + Idle.add (() => { + set_idle_state (msg); + connect_button.sensitive = true; + return false; + }); + } + + return null; + } + + private void set_connecting_state (string message) { + status_box.visible = true; + spinner.start (); + status_label.label = message; + status_label.remove_css_class ("error"); + status_label.remove_css_class ("success"); + } + + private void set_idle_state (string message) { + spinner.stop (); + status_label.label = message; + if (message.has_prefix ("Failed") || message.has_prefix( "Connection failed")) { + status_label.add_css_class ("error"); + status_label.remove_css_class ("success"); + } else if (message.has_prefix ("Connected")) { + status_label.add_css_class ("success"); + status_label.remove_css_class ("error"); + } + } +} + +public class GalleryPage : Gtk.Box { + private Gtk.FlowBox flowbox; + private Gtk.Label status_label; + private Gtk.Button import_button; + private Gtk.Button disconnect_button; + private Gtk.ProgressBar progress_bar; + private Gtk.Box progress_box; + private Gtk.Label progress_label; + + private ImageInfo[] all_images; + private ImageItem[] image_widgets; + + public signal void disconnect_requested (); + + public GalleryPage () { + Object ( + orientation: Gtk.Orientation.VERTICAL, + spacing: 0 + ); + this.all_images = new ImageInfo[0]; + this.image_widgets = new ImageItem[0]; + } + + construct { + // Toolbar + var toolbar_box = new Gtk.Box( Gtk.Orientation.HORIZONTAL, 8); + toolbar_box.margin_start = 12; + toolbar_box.margin_end = 12; + toolbar_box.margin_top = 8; + toolbar_box.margin_bottom = 8; + + disconnect_button = new Gtk.Button.with_label ("Disconnect"); + disconnect_button.add_css_class ("flat"); + toolbar_box.append (disconnect_button); + + var spacer = new Gtk.Label (""); + spacer.hexpand = true; + toolbar_box.append (spacer); + + status_label = new Gtk.Label ("Loading images…"); + status_label.halign = Gtk.Align.CENTER; + status_label.hexpand = true; + toolbar_box.append (status_label); + + var spacer2 = new Gtk.Label (""); + spacer2.hexpand = true; + toolbar_box.append (spacer2); + + import_button = new Gtk.Button.with_label ("Import Selected"); + import_button.add_css_class ("suggested-action"); + import_button.sensitive = false; + toolbar_box.append (import_button); + + append (toolbar_box); + append (new Gtk.Separator (Gtk.Orientation.HORIZONTAL)); + + // Scrolled window with FlowBox + var scrolled = new Gtk.ScrolledWindow( ); + scrolled.vexpand = true; + scrolled.hexpand = true; + + flowbox = new Gtk.FlowBox (); + flowbox.max_children_per_line = 6; + flowbox.selection_mode = Gtk.SelectionMode.NONE; + flowbox.homogeneous = true; + flowbox.column_spacing = 8; + flowbox.row_spacing = 8; + flowbox.margin_start = 12; + flowbox.margin_end = 12; + flowbox.margin_top = 8; + flowbox.margin_bottom = 8; + flowbox.activate_on_single_click = false; + + scrolled.child = flowbox; + append (scrolled); + + // Progress box + progress_box = new Gtk.Box( Gtk.Orientation.VERTICAL, 4); + progress_box.margin_start = 12; + progress_box.margin_end = 12; + progress_box.margin_top = 8; + progress_box.margin_bottom = 8; + progress_box.visible = false; + + progress_label = new Gtk.Label (""); + progress_box.append (progress_label); + + progress_bar = new Gtk.ProgressBar (); + progress_bar.show_text = false; + progress_box.append (progress_bar); + + append (progress_box); + + disconnect_button.clicked.connect (() => { disconnect_requested (); }); + import_button.clicked.connect (on_import_clicked); + + load_images (); + } + + private void load_images () { + status_label.label = "Loading images…"; + import_button.sensitive = false; + new Thread ("load", load_images_thread); + } + + private void* load_images_thread () { + // Create dedicated CameraClient in this thread so Soup.Session + // runs on a GMainContext owned by this thread (avoids segfault). + var load_camera = new CameraClient (); + try { + var images = load_camera.get_all_images (); + Idle.add (() => { + all_images = images; + populate_gallery (); + status_label.label = "%d images found".printf( all_images.length); + update_import_button_state (); + return false; + }); + } catch (Error err) { + string msg = "Error: %s".printf( err.message); + Idle.add (() => { + status_label.label = msg; + return false; + }); + } + return null; + } + + private void populate_gallery () { + var child = flowbox.get_first_child (); + while (child != null) { + var next = child.get_next_sibling (); + flowbox.remove (child); + child = next; + } + image_widgets = new ImageItem[all_images.length]; + + for (int i = 0; i < all_images.length; i++) { + var info = all_images[i]; + var item = new ImageItem (info); + item.selection_changed.connect (update_import_button_state); + flowbox.append (item); + image_widgets[i] = item; + load_thumbnail_async (item, info.full_path); + } + } + + private void load_thumbnail_async (ImageItem item, string path) { + new Thread ("thumb", () => { + // Create a separate CameraClient per thread for thread safety + var thumb_camera = new CameraClient( ); + try { + var bytes = thumb_camera.get_thumbnail (path); + Idle.add (() => { + item.set_thumbnail_from_bytes (bytes); + return false; + }); + } catch (Error e) { + warning ("Thumbnail failed for %s: %s", path, e.message); + } + return null; + }); + } + + private void update_import_button_state () { + int count = 0; + for (int i = 0; i < image_widgets.length; i++) { + if (image_widgets[i].selected) count++; + } + import_button.sensitive = count > 0; + import_button.label = count > 0 + ? "Import Selected (%d)".printf( count) + : "Import Selected"; + } + + private void on_import_clicked () { + int count = 0; + for (int i = 0; i < image_widgets.length; i++) { + if (image_widgets[i].selected) count++; + } + if (count == 0) return; + + var selected = new ImageInfo[count]; + int idx = 0; + for (int i = 0; i < image_widgets.length; i++) { + if (image_widgets[i].selected) { + selected[idx] = image_widgets[i].info; + idx++; + } + } + + var file_dialog = new Gtk.FileDialog (); + file_dialog.title = "Select Destination Directory"; + file_dialog.select_folder.begin ((Gtk.Window?)this.get_root (), null, (obj, res) => { + try { + var dest = file_dialog.select_folder.end (res); + if (dest != null) { + start_import (selected, dest); + } + } catch (Error e) { + warning ("Folder selection failed: %s", e.message); + } + }); + } + + private void start_import (ImageInfo[] files, GLib.File dest_dir) { + import_button.sensitive = false; + disconnect_button.sensitive = false; + progress_box.visible = true; + progress_bar.fraction = 0.0; + + int total = files.length; + progress_label.label = "Importing 0 of %d…".printf( total); + + // Use a dedicated thread with its own CameraClient instance. + // Each thread gets a fresh Soup.Session so there's no sharing. + new Thread( "import", () => { + int completed = 0; + int failed = 0; + var import_camera = new CameraClient (); + + for (int f = 0; f < total; f++) { + var file_info = files[f]; + try { + var data = import_camera.download_original (file_info.full_path); + var dest_path = dest_dir.get_child (file_info.filename); + dest_path.replace_contents ( + data.get_data (), null, false, + GLib.FileCreateFlags.NONE, null, null + ); + completed++; + } catch (Error e) { + failed++; + warning ("Failed to import %s: %s", file_info.filename, e.message); + } + + // Snapshot progress locals so the idle callback does not + // race with the worker thread updating completed/failed. + int snap_completed = completed; + int snap_failed = failed; + var snap_info = file_info; + Idle.add (() => { + progress_bar.fraction = (double)(snap_completed + snap_failed) / total; + progress_label.label = "Importing %d of %d (%s)".printf( + snap_completed, total, snap_info.filename); + return false; + }); + } + + int final_completed = completed; + int final_failed = failed; + Idle.add (() => { + if (final_failed == 0) { + progress_label.label = "Successfully imported %d of %d images!".printf( final_completed, total); + progress_label.add_css_class ("success"); + } else { + progress_label.label = "Imported %d of %d (%d failed)".printf( final_completed, total, final_failed); + progress_label.add_css_class ("error"); + } + progress_bar.fraction = 1.0; + + Timeout.add (3000, () => { + progress_box.visible = false; + progress_bar.fraction = 0.0; + progress_label.remove_css_class ("success"); + progress_label.remove_css_class ("error"); + import_button.sensitive = true; + disconnect_button.sensitive = true; + for (int i = 0; i < image_widgets.length; i++) { + image_widgets[i].selected = false; + } + update_import_button_state (); + return false; + }); + return false; + }); + return null; + }); + } + + public void refresh () { + load_images (); + } +} + +public class OlympusApp : Adw.Application { + public OlympusApp () { + Object ( + application_id: "com.olympuswifi.viewer", + flags: ApplicationFlags.DEFAULT_FLAGS + ); + } + + protected override void activate () { + load_css (); + var window = new MainWindow (this); + window.present (); + } + + private void load_css () { + var display = Gdk.Display.get_default (); + if (display == null) return; + var css_provider = new Gtk.CssProvider (); + css_provider.load_from_string (CSS); + Gtk.StyleContext.add_provider_for_display ( + display, + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ); + } + + private const string CSS = """ + .title-label { font-weight: bold; } + .selected-image { + background-color: alpha(@accent_bg_color, 0.2); + border: 2px solid @accent_bg_color; + border-radius: 8px; + } + .image-item { + border-radius: 8px; + border: 2px solid transparent; + transition: all 200ms ease; + } + .image-item:hover { + background-color: alpha(@accent_bg_color, 0.08); + } + .error { color: @error_bg_color; font-weight: bold; } + .success { color: @success_bg_color; font-weight: bold; } + button.circular { + border-radius: 30px; + font-size: 1.2em; + font-weight: bold; + } + """; +} + +public class MainWindow : Adw.ApplicationWindow { + private Gtk.Stack stack; + private ConnectPage connect_page; + private GalleryPage? gallery_page; + + public MainWindow (Adw.Application app) { + Object ( + application: app, + title: "Olympus Wi-Fi Camera Viewer", + default_width: 900, + default_height: 700 + ); + } + + construct { + stack = new Gtk.Stack (); + stack.transition_type = Gtk.StackTransitionType.SLIDE_LEFT_RIGHT; + + connect_page = new ConnectPage (); + connect_page.connection_succeeded.connect (on_connected); + stack.add_named (connect_page, "connect"); + + set_content (stack); + stack.visible_child = connect_page; + } + + private void on_connected () { + if (gallery_page == null) { + gallery_page = new GalleryPage (); + gallery_page.disconnect_requested.connect (on_disconnect); + stack.add_named (gallery_page, "gallery"); + } else { + gallery_page.refresh (); + } + stack.visible_child = gallery_page; + } + + private void on_disconnect () { + stack.visible_child = connect_page; + } +} + +public static int main (string[] args) { + // Parse and strip debug flag before GTK processes args + var filtered = new string[args.length]; + int out = 0; + for (int i = 0; i < args.length; i++) { + if (args[i] == "-d" || args[i] == "--debug") { + CameraClient.debug = true; + stdout.printf ("[debug] Enabled verbose HTTP logging\n"); + } else { + filtered[out] = args[i]; + out++; + } + } + // Trim to actual count + var clean_args = new string[out]; + for (int i = 0; i < out; i++) { + clean_args[i] = filtered[i]; + } + + var app = new OlympusApp (); + return app.run (clean_args); +}