Files
insta360-link-controller/crates/insta360-link-gui/src/main.rs
T

2451 lines
123 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod right_panel;
mod state;
mod theme;
mod transport;
mod video_engine;
use std::time::{Duration, Instant};
use eframe::egui::{self, Align2, Color32, FontId, Margin, Pos2, Rect, RichText, Rounding, Stroke, Vec2};
use insta360_link_protocol::{
list_connected_cameras, CameraModel, DiscoveredCamera, Insta360CameraController,
MockCameraTransport, NusbTransport, PowerLineFrequency, VideoMode, VideoModeAuxData,
};
use crate::right_panel::render_right_settings_panel;
use crate::state::{
CameraAppState, PreferenceTab, ToolbarPopup, UiMode, VideoFormatPreset,
};
use crate::theme::{apply_ins_theme, ins_bottom_mode_button, InsTheme};
use crate::transport::{DeviceSelection, GuiTransport};
use crate::video_engine::VideoPreviewEngine;
/// Top-level application struct for Insta360 Link Controller.
pub struct Insta360App {
state: CameraAppState,
controller: Insta360CameraController<GuiTransport>,
video_engine: VideoPreviewEngine,
connected_cameras: Vec<DiscoveredCamera>,
selected_device: DeviceSelection,
self_test_enabled: bool,
self_test_start: Option<Instant>,
self_test_step: u8,
}
impl Insta360App {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
// Enumerate physical USB cameras and OS video capture devices on startup
let connected_cameras = list_connected_cameras().unwrap_or_default();
let mut video_engine = VideoPreviewEngine::new();
video_engine.refresh_capture_devices();
// Connect to physical USB camera if detected, otherwise default to simulator
let (controller, selected_device) = if let Some(first_cam) = connected_cameras.first() {
match NusbTransport::open_by_info(first_cam.clone()) {
Ok(usb) => (
Insta360CameraController::new(GuiTransport::Usb(usb)),
DeviceSelection::Usb(first_cam.clone()),
),
Err(_) => (
Insta360CameraController::new(GuiTransport::Mock(MockCameraTransport::new(
CameraModel::Link2,
))),
DeviceSelection::Simulate(CameraModel::Link2),
),
}
} else {
(
Insta360CameraController::new(GuiTransport::Mock(MockCameraTransport::new(
CameraModel::Link2,
))),
DeviceSelection::Simulate(CameraModel::Link2),
)
};
let state = CameraAppState::default();
let self_test_enabled = std::env::var("INSTA360_GUI_SELF_TEST")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let mut app = Self {
state,
controller,
video_engine,
connected_cameras,
selected_device,
self_test_enabled,
self_test_start: None,
self_test_step: 0,
};
app.sync_device_info();
if matches!(app.selected_device, DeviceSelection::Usb(_)) {
app.auto_select_insta360_video_feed();
}
app
}
fn auto_select_insta360_video_feed(&mut self) {
let (w, h) = self.state.format_preset.dimensions();
let fps = self.state.format_preset.fps();
self.video_engine
.set_capture_format(w as usize, h as usize, fps);
let candidate = self
.video_engine
.available_capture_devices()
.iter()
.find(|d| {
let low = d.to_lowercase();
low.contains("insta360") && !low.contains("virtual")
})
.or_else(|| {
self.video_engine
.available_capture_devices()
.iter()
.find(|d| d.to_lowercase().contains("insta360"))
})
.cloned();
if let Some(dev_name) = candidate {
self.video_engine.start_physical_capture(&dev_name);
}
}
fn switch_camera(&mut self, selection: DeviceSelection) {
match selection.clone() {
DeviceSelection::Usb(cam) => match NusbTransport::open_by_info(cam.clone()) {
Ok(transport) => {
self.controller =
Insta360CameraController::new(GuiTransport::Usb(transport));
self.selected_device = selection;
self.sync_device_info();
self.auto_select_insta360_video_feed();
self.state
.show_toast(format!("Connected to USB device: {}", cam.name), false);
}
Err(e) => {
self.state
.show_toast(format!("Failed to open camera: {}", e), true);
}
},
DeviceSelection::Simulate(model) => {
let mock = MockCameraTransport::new(model);
self.controller = Insta360CameraController::new(GuiTransport::Mock(mock));
self.selected_device = selection;
self.video_engine.stop_physical_capture();
self.sync_device_info();
self.state
.show_toast(format!("Switched to Simulator ({})", model.display_name()), false);
}
}
}
fn sync_device_info(&mut self) {
if let Ok(info) = self.controller.get_device_info() {
self.state.device_info = info;
}
if let Ok(ptz) = self.controller.get_ptz_version_and_angles() {
self.state.pan_deg = (ptz.pan as f32 / 10.0_f32).clamp(-130.0_f32, 130.0_f32);
self.state.tilt_deg = (ptz.tilt as f32 / 10.0_f32).clamp(-90.0_f32, 90.0_f32);
self.state.zoom_x = (ptz.zoom as f32 / 100.0_f32).clamp(1.0_f32, 4.0_f32);
self.state.ptz_info = ptz;
}
if let Ok(status) = self.controller.get_device_status() {
self.state.device_status = status;
}
if let Ok((mode, _)) = self.controller.get_video_mode() {
self.state.video_mode = mode;
}
if let Ok(b) = self.controller.get_brightness() {
self.state.brightness = b.clamp(0, 100);
}
if let Ok(c) = self.controller.get_contrast() {
self.state.contrast = c.clamp(0, 100);
}
if let Ok(s) = self.controller.get_saturation() {
self.state.saturation = s.clamp(0, 100);
}
if let Ok(sh) = self.controller.get_sharpness() {
self.state.sharpness = sh.clamp(0, 100);
}
if let Ok(wb_auto) = self.controller.get_auto_white_balance() {
self.state.auto_white_balance = wb_auto;
}
if let Ok(wb) = self.controller.get_white_balance_temperature() {
self.state.wb_kelvin = wb.clamp(2000, 10000);
}
if let Ok(ext) = self.controller.get_extend_function_status() {
for (func, en) in ext {
match func {
insta360_link_protocol::ExtendFunction::AiZoom => {
self.state.ai_zoom_enabled = en
}
insta360_link_protocol::ExtendFunction::AllGestures => {
self.state.enable_all_gestures = en
}
insta360_link_protocol::ExtendFunction::AutoTracking => {
self.state.enable_auto_tracking = en
}
insta360_link_protocol::ExtendFunction::SingleTapTracking => {
self.state.enable_single_tap_tracking = en
}
insta360_link_protocol::ExtendFunction::Hdr => self.state.hdr_enabled = en,
insta360_link_protocol::ExtendFunction::HorizontalMirror => {
self.state.horizontal_mirror = en
}
insta360_link_protocol::ExtendFunction::VerticalMirror => {
self.state.vertical_mirror = en
}
insta360_link_protocol::ExtendFunction::VerticalMode => {
self.state.vertical_resolution_mode = en
}
insta360_link_protocol::ExtendFunction::ForcedVertical => {
self.state.forced_vertical_lock = en
}
insta360_link_protocol::ExtendFunction::LowResolution => {
self.state.low_resolution_mode = en
}
insta360_link_protocol::ExtendFunction::SmartAdjustment => {
self.state.smart_adjustment = en
}
insta360_link_protocol::ExtendFunction::StartupParams => {
self.state.startup_params_enabled = en
}
insta360_link_protocol::ExtendFunction::UltimatePrivacyMode => {
self.state.ultimate_privacy = en
}
_ => {}
}
}
}
if let Ok(style) = self.controller.get_composition_style() {
self.state.composition_style = style;
}
if let Ok(speed) = self.controller.get_track_speed() {
self.state.track_speed = speed;
}
if let Ok(gestures) = self.controller.get_gesture_status() {
for (g, en) in gestures {
match g {
insta360_link_protocol::Gesture::Ok => self.state.use_gesture_ok = en,
insta360_link_protocol::Gesture::Palm => self.state.use_gesture_palm = en,
insta360_link_protocol::Gesture::LShape => self.state.use_gesture_l = en,
insta360_link_protocol::Gesture::VShape => self.state.use_gesture_v = en,
insta360_link_protocol::Gesture::Rock => self.state.use_gesture_rock = en,
}
}
}
if let Ok(ae) = self.controller.get_auto_exposure() {
self.state.auto_exposure = ae;
}
if let Ok(af) = self.controller.get_auto_focus() {
self.state.auto_focus = af;
}
if let Ok(denoise) = self.controller.get_audio_noise_reduction() {
self.state.audio_denoise = denoise;
}
}
fn set_video_mode(&mut self, target_mode: VideoMode) {
let prev_mode = self.state.video_mode;
let mode_to_set = if self.state.video_mode == target_mode {
VideoMode::Normal
} else {
target_mode
};
if mode_to_set == VideoMode::AutoTracking {
// `Webcam::CameraInsta::autoTrackClicked` (`0x10006c400`) ->
// `onHandleVideoModeChangeEvent` (`0x10005a400`..`0x10005a4cc`) -> `readyAutoTrack` (`0x10006c8c8`):
// 1. If the camera was in a non-Normal mode (`>= 2`: Whiteboard, Overhead, DeskView),
// first exit to `VideoMode::Normal` (`0`) so `GetTrackObjLists` (`XU1 0x14`) runs.
// 2. Do NOT send `SetTrackObj(0.5, 0.5)` or `SetVideoMode(AutoTracking)` before
// `GetTrackObjLists` detects a person — doing so aborts firmware person detection!
// 3. Start the 200ms `ready_auto_track` polling loop (`SetCheckTrack(true)`).
if prev_mode != VideoMode::Normal && prev_mode != VideoMode::AutoTracking {
let _ = self
.controller
.set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default());
}
self.state.video_mode = VideoMode::AutoTracking;
self.state.ready_auto_track = true;
self.state.last_track_poll = Some(Instant::now());
self.state.show_toast(
"AI Tracking ready — looking for person (or click on video to lock target)",
false,
);
return;
}
self.state.ready_auto_track = false;
if mode_to_set == VideoMode::Normal {
self.state.tracked_targets.clear();
}
self.state.video_mode = mode_to_set;
let mut aux = VideoModeAuxData::default();
if mode_to_set == VideoMode::DeskView {
aux.pitch = self.state.deskview_pitch;
}
let _ = self.controller.set_video_mode_raw(mode_to_set, &aux);
self.state.last_hw_sync = Some(Instant::now() + Duration::from_millis(700));
if (prev_mode == VideoMode::DeskView || prev_mode == VideoMode::OverheadShot)
&& mode_to_set == VideoMode::Normal
{
let _ = self.controller.set_zoom((self.state.zoom_x * 100.0_f32) as u16);
self.state.pending_ptz_restore = Some((
Instant::now() + Duration::from_millis(800),
(self.state.pan_deg * 3600.0_f32) as i32,
(self.state.tilt_deg * 3600.0_f32) as i32,
));
}
}
// ========================================================================
// Top Bar (TopBarWin.qml - 44px height, theme.bg01)
// ========================================================================
fn render_top_bar(&mut self, ctx: &egui::Context, theme: &InsTheme) {
egui::TopBottomPanel::top("ins_top_bar")
.exact_height(44.0_f32)
.frame(
egui::Frame::none()
.fill(theme.bg01)
.inner_margin(Margin::symmetric(14.0_f32, 6.0_f32)),
)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
// Left: Green Insta360 camera icon + "Insta360 Link Controller" title
ui.label(
RichText::new("📷")
.color(theme.brand_green)
.size(17.0_f32),
);
ui.label(
RichText::new("Insta360 Link Controller")
.strong()
.size(14.5_f32)
.color(theme.text01),
);
ui.add_space(24.0_f32);
// Center: Camera Controller Selector
let show_simulators = cfg!(debug_assertions)
|| std::env::var("INSTA360_SIMULATOR")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let current_dev_label = match &self.selected_device {
DeviceSelection::Usb(_) => self.selected_device.label(),
DeviceSelection::Simulate(_) if show_simulators => {
self.selected_device.label()
}
DeviceSelection::Simulate(_) => "No Camera Connected".to_string(),
};
egui::ComboBox::from_id_salt("top_camera_selector")
.selected_text(&current_dev_label)
.show_ui(ui, |ui| {
if self.connected_cameras.is_empty() && !show_simulators {
ui.label(
RichText::new("No Camera Connected").color(theme.text03),
);
}
for cam in self.connected_cameras.clone() {
let dev = DeviceSelection::Usb(cam);
let label = dev.label();
if ui.selectable_label(self.selected_device == dev, label).clicked() {
self.switch_camera(dev);
}
}
if show_simulators {
if !self.connected_cameras.is_empty() {
ui.separator();
}
for model in [
CameraModel::Link2,
CameraModel::Link2C,
CameraModel::Link,
] {
let dev = DeviceSelection::Simulate(model);
let label = dev.label();
if ui.selectable_label(self.selected_device == dev, label).clicked() {
self.switch_camera(dev);
}
}
}
});
let refresh_btn = ui.button("↻");
if refresh_btn.on_hover_text("Refresh USB camera devices").clicked() {
self.connected_cameras = list_connected_cameras().unwrap_or_default();
self.video_engine.refresh_capture_devices();
if !matches!(self.selected_device, DeviceSelection::Usb(_)) {
if let Some(first) = self.connected_cameras.first().cloned() {
self.switch_camera(DeviceSelection::Usb(first));
}
}
self.state.show_toast("Refreshed USB devices", false);
}
if show_simulators {
ui.add_space(10.0_f32);
// Video Feed Source Selector (debug / simulator mode only)
let active_feed_label = if self.video_engine.is_using_physical_capture() {
self.video_engine
.active_capture_device()
.map(|s| s.to_string())
.unwrap_or_else(|| "Physical Webcam".to_string())
} else {
"Studio Camera Feed (Live)".to_string()
};
egui::ComboBox::from_id_salt("top_video_feed_selector")
.selected_text(&active_feed_label)
.show_ui(ui, |ui| {
if ui
.selectable_label(
!self.video_engine.is_using_physical_capture(),
"Studio Camera Feed (Live)",
)
.clicked()
{
self.video_engine.stop_physical_capture();
}
let devs = self.video_engine.available_capture_devices().to_vec();
for dev_name in devs {
let is_active = self.video_engine.is_using_physical_capture()
&& self
.video_engine
.active_capture_device()
.map(|d| d == dev_name.as_str())
.unwrap_or(false);
if ui.selectable_label(is_active, dev_name.as_str()).clicked() {
self.video_engine.start_physical_capture(&dev_name);
}
}
});
}
// Right group
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Preferences ("⋯") button
let pref_btn = ui.button(RichText::new("⋯").size(16.0_f32));
if pref_btn.on_hover_text("Preferences").clicked() {
self.state.show_preferences = true;
}
// Toolbar Mode icon button ("🗗 Toolbar")
let tb_btn = ui.button("🗗 Toolbar");
if tb_btn.on_hover_text("Compact Floating Toolbar Mode").clicked() {
self.state.ui_mode = UiMode::Toolbar;
self.state.toolbar_popup = ToolbarPopup::None;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
780.0_f32, 74.0_f32,
)));
}
// Remote Control icon button ("📱 Remote")
let rem_btn = ui.button("📱 Remote");
if rem_btn.on_hover_text("Mobile Web Remote Control").clicked() {
self.state.show_qr_dialog = true;
}
// Virtual Camera icon button ("🎥 Virtual Cam")
let vcam_color = if self.state.virtual_camera_enabled {
theme.brand_green
} else {
theme.text02
};
let vcam_btn =
ui.button(RichText::new("🎥 Virtual Cam").color(vcam_color));
if vcam_btn.on_hover_text("Virtual Camera Settings").clicked() {
self.state.show_virtual_cam_pop = true;
}
});
});
});
}
// ========================================================================
// Bottom Control Bar (BottomControl.qml - 66px height, theme.bg02)
// ========================================================================
fn render_bottom_control_bar(&mut self, ui: &mut egui::Ui, theme: &InsTheme) {
ui.horizontal(|ui| {
ui.set_height(66.0_f32);
ui.add_space(8.0_f32);
// Left group: Preview On/Off button (40x40) + Divider + Format Selector
let preview_icon = if self.state.preview_open { "👁" } else { "🚫" };
let preview_tooltip = if self.state.preview_open {
"Close Preview"
} else {
"Open Preview"
};
let preview_btn = ui.add_sized(
[40.0_f32, 40.0_f32],
egui::Button::new(RichText::new(preview_icon).size(15.0_f32)),
);
if preview_btn.on_hover_text(preview_tooltip).clicked() {
self.state.preview_open = !self.state.preview_open;
}
ui.add_space(4.0_f32);
let (div_rect, _) = ui.allocate_exact_size(egui::vec2(1.0_f32, 16.0_f32), egui::Sense::hover());
ui.painter().rect_filled(div_rect, Rounding::ZERO, theme.border01);
ui.add_space(4.0_f32);
// Resolution / Format preset combo
let prev_preset = self.state.format_preset;
egui::ComboBox::from_id_salt("bottom_format_combo")
.selected_text(self.state.format_preset.label())
.show_ui(ui, |ui| {
for preset in VideoFormatPreset::ALL {
if ui
.selectable_value(
&mut self.state.format_preset,
preset,
preset.label(),
)
.clicked()
{
let (w, h) = preset.dimensions();
let fps = preset.fps();
self.state.vertical_resolution_mode = preset.is_vertical();
self.state.low_resolution_mode = w == 640;
self.video_engine.set_capture_format(w as usize, h as usize, fps);
self.state
.show_toast(format!("Resolution set to {}", preset.label()), false);
}
}
});
if self.state.format_preset != prev_preset {
let (w, h) = self.state.format_preset.dimensions();
let fps = self.state.format_preset.fps();
self.video_engine.set_capture_format(w as usize, h as usize, fps);
}
ui.add_space(16.0_f32);
// Center group: ins_bottom_mode_button items
let caps = self.controller.capabilities();
let model = self.controller.model();
if caps.support_ptz_drag {
// Link 2 / Link: AI Tracking
let is_tracking_active = self.state.video_mode == VideoMode::AutoTracking
|| self.state.video_mode == VideoMode::AutoFraming;
if ins_bottom_mode_button(ui, "🎯", "AI Tracking", is_tracking_active, true, theme).clicked() {
let next = if is_tracking_active {
VideoMode::Normal
} else if self.state.default_track_mode == 0 {
VideoMode::AutoTracking
} else {
VideoMode::AutoFraming
};
self.set_video_mode(next);
}
// Small ▴ toggle for Single Tracking (0) vs Group Tracking (1) on Link 2
if model == CameraModel::Link2 {
let arrow_label = if self.state.default_track_mode == 0 { "Single" } else { "Group" };
let arrow_btn = ui.button(RichText::new("▴").size(10.0_f32));
if arrow_btn
.on_hover_text(format!("Track mode: {} (Click to toggle Single/Group)", arrow_label))
.clicked()
{
self.state.default_track_mode = if self.state.default_track_mode == 0 { 1 } else { 0 };
let _ = self.controller.set_default_track_mode(self.state.default_track_mode);
if is_tracking_active {
let target = if self.state.default_track_mode == 0 {
VideoMode::AutoTracking
} else {
VideoMode::AutoFraming
};
self.set_video_mode(target);
}
}
}
} else {
// Link 2C: Auto Framing
let is_framing_active = self.state.video_mode == VideoMode::AutoFraming;
if ins_bottom_mode_button(ui, "👥", "Auto Framing", is_framing_active, true, theme).clicked() {
self.set_video_mode(VideoMode::AutoFraming);
}
}
// Whiteboard
let is_wb_active = self.state.video_mode == VideoMode::WhiteBoard;
if ins_bottom_mode_button(ui, "📋", "Whiteboard", is_wb_active, true, theme).clicked() {
self.set_video_mode(VideoMode::WhiteBoard);
}
// Overhead (Link 1 only)
if model == CameraModel::Link {
let is_overhead_active = self.state.video_mode == VideoMode::OverheadShot;
if ins_bottom_mode_button(ui, "⬇", "Overhead", is_overhead_active, true, theme).clicked() {
self.set_video_mode(VideoMode::OverheadShot);
}
}
// Smart Whiteboard
let is_swb_active = self.state.video_mode == VideoMode::SmartWhiteBoard;
if ins_bottom_mode_button(ui, "🖽", "Smart Whiteboard", is_swb_active, true, theme).clicked() {
self.set_video_mode(VideoMode::SmartWhiteBoard);
}
// DeskView + pitch adjustment popover button
let is_desk_active = self.state.video_mode == VideoMode::DeskView;
if ins_bottom_mode_button(ui, "📐", "DeskView", is_desk_active, true, theme).clicked() {
self.set_video_mode(VideoMode::DeskView);
}
if is_desk_active {
let pitch_btn = ui.button(RichText::new("▴").size(10.0_f32));
if pitch_btn
.on_hover_text("DeskView Vertical Correction pitch adjustment")
.clicked()
{
self.state.show_deskview_pitch_pop = !self.state.show_deskview_pitch_pop;
}
}
// Right group: Screenshot + Divider + Record
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0_f32);
// Record button
if self.state.is_recording {
let elapsed = self
.state
.recording_started
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let time_str = format!("{:02}:{:02}", elapsed / 60, elapsed % 60);
let rec_btn = ui.button(
RichText::new(format!("⏹ {}", time_str))
.color(theme.error)
.strong(),
);
if rec_btn.on_hover_text("Stop Recording").clicked() {
self.state.is_recording = false;
self.state.recording_started = None;
match self.video_engine.stop_recording() {
Ok(path) => {
let (w, h) = self.video_engine.capture_dimensions();
self.state.show_toast(
format!("Saved {w}x{h} video to: {}", path.display()),
false,
);
}
Err(e) => {
self.state
.show_toast(format!("Stop recording error: {e}"), true);
}
}
}
} else {
let rec_btn = ui.add_sized(
[40.0_f32, 40.0_f32],
egui::Button::new(RichText::new("⏺").size(16.0_f32)),
);
if rec_btn.on_hover_text("Record Video").clicked() {
let (w, h) = self.state.format_preset.dimensions();
let fps = self.state.format_preset.fps();
self.video_engine.set_capture_format(w as usize, h as usize, fps);
match self.video_engine.start_recording(&self.state.recording_dir) {
Ok(path) => {
self.state.is_recording = true;
self.state.recording_started = Some(Instant::now());
self.state.show_toast(
format!("Recording {w}x{h} video: {}", path.display()),
false,
);
}
Err(e) => {
self.state
.show_toast(format!("Failed to start recording: {e}"), true);
}
}
}
}
ui.add_space(4.0_f32);
let (div_r, _) = ui.allocate_exact_size(egui::vec2(1.0_f32, 16.0_f32), egui::Sense::hover());
ui.painter().rect_filled(div_r, Rounding::ZERO, theme.border01);
ui.add_space(4.0_f32);
// Screenshot button
let snap_btn = ui.add_sized(
[40.0_f32, 40.0_f32],
egui::Button::new(RichText::new("📷").size(15.0_f32)),
);
if snap_btn.on_hover_text("Take Screenshot").clicked() {
let (w, h) = self.state.format_preset.dimensions();
let fps = self.state.format_preset.fps();
self.video_engine.set_capture_format(w as usize, h as usize, fps);
match self
.video_engine
.save_screenshot(&self.state.screenshot_dir, &self.state)
{
Ok(path) => {
let (cw, ch) = self.video_engine.capture_dimensions();
self.state.show_toast(
format!("Saved {cw}x{ch} screenshot to: {}", path.display()),
false,
);
}
Err(e) => {
self.state
.show_toast(format!("Screenshot failed: {}", e), true);
}
}
}
});
});
}
// ========================================================================
// Video Preview Area (Previewer.qml / DefCamera.qml)
// ========================================================================
fn render_preview_area(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, theme: &InsTheme) {
let avail_size = ui.available_size();
if !self.state.preview_open {
// Preview closed dark overlay
let (rect, _) = ui.allocate_exact_size(avail_size, egui::Sense::hover());
ui.painter().rect_filled(rect, Rounding::same(6.0_f32), Color32::from_rgb(12, 16, 22));
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(rect), |ui| {
ui.vertical_centered(|ui| {
ui.add_space((rect.height() * 0.32_f32).max(10.0_f32));
ui.label(
RichText::new("Preview closed")
.size(21.0_f32)
.strong()
.color(theme.text01),
);
ui.add_space(8.0_f32);
ui.label(
RichText::new(
"If you need to use the device in a third-party software, please close the preview first.",
)
.size(13.0_f32)
.color(theme.text02),
);
ui.add_space(16.0_f32);
let open_btn = ui.button(
RichText::new("Open Preview")
.color(Color32::BLACK)
.strong(),
);
if open_btn.clicked() {
self.state.preview_open = true;
}
});
});
return;
}
// Live video feed rendering with aspect ratio preservation
let target_aspect = if self.state.vertical_resolution_mode {
9.0_f32 / 16.0_f32
} else {
16.0_f32 / 9.0_f32
};
let (video_w, video_h) = if avail_size.x / avail_size.y > target_aspect {
(avail_size.y * target_aspect, avail_size.y)
} else {
(avail_size.x, avail_size.x / target_aspect)
};
let pad_x = (avail_size.x - video_w) * 0.5_f32;
let pad_y = (avail_size.y - video_h) * 0.5_f32;
let (full_rect, _) = ui.allocate_exact_size(avail_size, egui::Sense::hover());
let video_rect = Rect::from_min_size(
Pos2::new(full_rect.min.x + pad_x, full_rect.min.y + pad_y),
Vec2::new(video_w, video_h),
);
let tex = self.video_engine.render_frame(ctx, &mut self.state);
// Paint background and video frame
ui.painter().rect_filled(full_rect, Rounding::same(6.0_f32), Color32::from_rgb(10, 14, 20));
ui.painter().image(
tex.id(),
video_rect,
Rect::from_min_max(Pos2::new(0.0_f32, 0.0_f32), Pos2::new(1.0_f32, 1.0_f32)),
Color32::WHITE,
);
// Mouse interaction response on video rect
let video_response = ui.interact(video_rect, ui.id().with("video_canvas"), egui::Sense::click_and_drag());
// Interactive mouse wheel zoom (WheelHandler)
if video_response.hovered() {
let scroll_delta = ui.input(|i| i.raw_scroll_delta.y);
if scroll_delta.abs() > 0.1_f32 {
let zoom_change = if scroll_delta > 0.0_f32 { 0.1_f32 } else { -0.1_f32 };
self.state.zoom_x = (self.state.zoom_x + zoom_change).clamp(1.0_f32, 4.0_f32);
let _ = self.controller.set_zoom((self.state.zoom_x * 100.0_f32) as u16);
}
}
// Interactive click-and-drag pan/tilt (`DefCamera.qml` `dragMovePanTilt` + `dragDropTransition`)
// Only active in Normal mode (`!ready_auto_track`) so clicking on the video during AI Tracking
// does not send a manual PTZ velocity/stop command that aborts tracking.
let manual_ptz_drag_allowed = self.controller.capabilities().support_ptz_drag
&& self.state.video_mode == VideoMode::Normal
&& !self.state.ready_auto_track;
if video_response.dragged() && manual_ptz_drag_allowed {
let drag_delta = video_response.drag_delta();
if drag_delta.length_sq() > 0.25_f32 {
self.state.pan_deg =
(self.state.pan_deg - drag_delta.x * 0.15_f32).clamp(-130.0_f32, 130.0_f32);
self.state.tilt_deg =
(self.state.tilt_deg + drag_delta.y * 0.15_f32).clamp(-90.0_f32, 90.0_f32);
let pan_spd = ((drag_delta.x.abs() * 0.35_f32).round() as u8).clamp(1, 6);
let tilt_spd = ((drag_delta.y.abs() * 0.35_f32).round() as u8).clamp(1, 6);
let pan_dir = if drag_delta.x.abs() > 0.5_f32 {
if drag_delta.x > 0.0_f32 { -1i8 } else { 1i8 }
} else {
0i8
};
let tilt_dir = if drag_delta.y.abs() > 0.5_f32 {
if drag_delta.y > 0.0_f32 { -1i8 } else { 1i8 }
} else {
0i8
};
let can_send = self
.state
.last_ptz_send_time
.map(|t| t.elapsed().as_millis() >= 60)
.unwrap_or(true);
if can_send {
let _ = self.controller.set_pantilt_relative_ext(
if pan_dir == 0 { 0 } else { pan_spd },
pan_dir,
if tilt_dir == 0 { 0 } else { tilt_spd },
tilt_dir,
);
self.state.last_ptz_cmd = (pan_spd, pan_dir, tilt_spd, tilt_dir);
self.state.last_ptz_send_time = Some(Instant::now());
}
}
} else if video_response.drag_stopped() && manual_ptz_drag_allowed {
let _ = self.controller.stop_pantilt_relative();
self.state.last_ptz_cmd = (0, 0, 0, 0);
}
// AI Tracking bounding boxes (`personRect`, normalized 0..10000 in hardware `UvcRect`)
if self.state.video_mode == VideoMode::AutoTracking
|| self.state.video_mode == VideoMode::AutoFraming
{
let mut newly_selected = None;
for target in &self.state.tracked_targets {
let norm_x = (target.x as f32 / 10000.0_f32).clamp(0.0_f32, 1.0_f32);
let norm_y = (target.y as f32 / 10000.0_f32).clamp(0.0_f32, 1.0_f32);
let norm_w = (target.width as f32 / 10000.0_f32).clamp(0.02_f32, 1.0_f32);
let norm_h = (target.height as f32 / 10000.0_f32).clamp(0.02_f32, 1.0_f32);
let box_rect = Rect::from_min_size(
Pos2::new(
video_rect.min.x + norm_x * video_rect.width(),
video_rect.min.y + norm_y * video_rect.height(),
),
Vec2::new(norm_w * video_rect.width(), norm_h * video_rect.height()),
);
let is_selected = target.track_id == self.state.selected_target_id;
let stroke_color = if is_selected {
theme.brand_green
} else {
theme.text03
};
ui.painter().rect_stroke(
box_rect,
Rounding::same(4.0_f32),
Stroke::new(2.0_f32, stroke_color),
);
if video_response.clicked() {
if let Some(pos) = video_response.interact_pointer_pos() {
if box_rect.contains(pos) {
newly_selected = Some(*target);
}
}
}
}
if let Some(selected_target) = newly_selected {
let _ = self.controller.select_track_object(&selected_target);
self.state.selected_target_id = selected_target.track_id;
self.state.ready_auto_track = false;
self.state.tracked_targets.clear();
let _ = self
.controller
.set_video_mode_raw(VideoMode::AutoTracking, &VideoModeAuxData::default());
self.state.show_toast("AI Tracking locked onto selected target", false);
} else if video_response.clicked() && self.state.ready_auto_track {
if let Some(pos) = video_response.interact_pointer_pos() {
let norm_x =
((pos.x - video_rect.min.x) / video_rect.width()).clamp(0.05_f32, 0.95_f32);
let norm_y =
((pos.y - video_rect.min.y) / video_rect.height()).clamp(0.05_f32, 0.95_f32);
let _ = self
.controller
.select_track_object_norm(norm_x, norm_y, 0.0_f32, 0.0_f32);
self.state.ready_auto_track = false;
self.state.tracked_targets.clear();
let _ = self
.controller
.set_video_mode_raw(VideoMode::AutoTracking, &VideoModeAuxData::default());
self.state.show_toast("AI Tracking locked onto selected target", false);
}
}
}
// Smart Whiteboard 4-Corner Overlay (SmartWhiteBoardBox.qml)
if self.state.video_mode == VideoMode::SmartWhiteBoard {
let corner_screen: Vec<Pos2> = self
.state
.whiteboard_corners
.iter()
.map(|c| {
Pos2::new(
video_rect.min.x + c.x * video_rect.width(),
video_rect.min.y + c.y * video_rect.height(),
)
})
.collect();
// Draw bounding polygon edges
for i in 0..4 {
let p1 = corner_screen[i];
let p2 = corner_screen[(i + 1) % 4];
ui.painter().line_segment([p1, p2], Stroke::new(2.0_f32, theme.brand_green));
}
// Draggable corner handles
for (i, corner) in self.state.whiteboard_corners.iter_mut().enumerate() {
let p = corner_screen[i];
let handle_rect = Rect::from_center_size(p, Vec2::new(16.0_f32, 16.0_f32));
let handle_resp = ui.interact(
handle_rect,
ui.id().with(("wb_corner", i)),
egui::Sense::drag(),
);
if handle_resp.dragged() {
let d = handle_resp.drag_delta();
corner.x = (corner.x + d.x / video_rect.width()).clamp(0.0_f32, 1.0_f32);
corner.y = (corner.y + d.y / video_rect.height()).clamp(0.0_f32, 1.0_f32);
}
ui.painter().circle_filled(p, 6.0_f32, theme.brand_green);
ui.painter().circle_stroke(p, 6.0_f32, Stroke::new(2.0_f32, Color32::WHITE));
}
// Floating bottom pill for whiteboard controls
let pill_rect = Rect::from_center_size(
Pos2::new(video_rect.center().x, video_rect.max.y - 32.0_f32),
Vec2::new(320.0_f32, 38.0_f32),
);
ui.painter().rect_filled(pill_rect, Rounding::same(19.0_f32), Color32::from_rgba_unmultiplied(20, 26, 36, 230));
ui.painter().rect_stroke(pill_rect, Rounding::same(19.0_f32), Stroke::new(1.0_f32, theme.border01));
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(pill_rect), |ui| {
ui.horizontal_centered(|ui| {
if ui.button("Rescan").clicked() {
self.state.whiteboard_corners = [
Pos2::new(0.18_f32, 0.16_f32),
Pos2::new(0.82_f32, 0.16_f32),
Pos2::new(0.82_f32, 0.78_f32),
Pos2::new(0.18_f32, 0.78_f32),
];
self.state.show_toast("Rescanning whiteboard corners...", false);
}
if ui.button("Manual Adjustment").clicked() {
self.state.show_toast("Drag corner handles to adjust whiteboard bounds", false);
}
if ui.button(RichText::new("Exit").color(theme.error)).clicked() {
self.set_video_mode(VideoMode::Normal);
}
});
});
}
// Pause-Track Area Overlay (TrackForbiddenAreaSetting.qml)
if self.state.show_pause_track_overlay {
// Render exclusion zones
for (idx, area) in self.state.forbidden_areas.iter().enumerate() {
if !area.enabled {
continue;
}
let norm_x_min = ((area.pan_min + 350) as f32 / 700.0_f32).clamp(0.0_f32, 1.0_f32);
let norm_x_max = ((area.pan_max + 350) as f32 / 700.0_f32).clamp(0.0_f32, 1.0_f32);
let norm_y_min = ((200 - area.tilt_max) as f32 / 400.0_f32).clamp(0.0_f32, 1.0_f32);
let norm_y_max = ((200 - area.tilt_min) as f32 / 400.0_f32).clamp(0.0_f32, 1.0_f32);
let z_rect = Rect::from_min_max(
Pos2::new(video_rect.min.x + norm_x_min * video_rect.width(), video_rect.min.y + norm_y_min * video_rect.height()),
Pos2::new(video_rect.min.x + norm_x_max * video_rect.width(), video_rect.min.y + norm_y_max * video_rect.height()),
);
ui.painter().rect_filled(z_rect, Rounding::same(4.0_f32), Color32::from_rgba_unmultiplied(239, 68, 68, 60));
ui.painter().rect_stroke(z_rect, Rounding::same(4.0_f32), Stroke::new(1.5_f32, Color32::from_rgb(239, 68, 68)));
ui.painter().text(
z_rect.center(),
Align2::CENTER_CENTER,
format!("Zone {}", idx + 1),
FontId::proportional(13.0_f32),
Color32::WHITE,
);
}
// Floating header control bar
let header_rect = Rect::from_center_size(
Pos2::new(video_rect.center().x, video_rect.min.y + 32.0_f32),
Vec2::new((video_rect.width() * 0.85_f32).min(540.0_f32), 44.0_f32),
);
ui.painter().rect_filled(header_rect, Rounding::same(8.0_f32), Color32::from_rgba_unmultiplied(20, 26, 36, 235));
ui.painter().rect_stroke(header_rect, Rounding::same(8.0_f32), Stroke::new(1.0_f32, theme.border01));
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(header_rect), |ui| {
ui.horizontal_centered(|ui| {
ui.label(RichText::new("Pause-Track Areas").strong().color(theme.text01));
ui.separator();
for i in 0..4 {
let active = self.state.forbidden_areas[i].enabled;
let btn = ui.selectable_label(active, format!("Zone {}", i + 1));
if btn.clicked() {
self.state.forbidden_areas[i].enabled = !active;
}
}
ui.separator();
if ui.button("Clear all").clicked() {
for a in &mut self.state.forbidden_areas {
a.enabled = false;
}
}
if ui.button(RichText::new("Done").color(theme.brand_green).strong()).clicked() {
let _ = self.controller.set_track_forbidden_areas(&self.state.forbidden_areas);
let _ = self.controller.exit_track_forbidden_area_setting();
self.state.show_pause_track_overlay = false;
}
});
});
}
// Toast Banner
if let Some((msg, ts, is_err)) = &self.state.toast_message {
if ts.elapsed().as_secs_f32() < 3.5_f32 {
let toast_w = (msg.len() as f32 * 7.5_f32 + 48.0_f32).max(180.0_f32);
let toast_rect = Rect::from_center_size(
Pos2::new(video_rect.center().x, video_rect.min.y + 26.0_f32),
Vec2::new(toast_w, 32.0_f32),
);
let badge_color = if *is_err { theme.error } else { theme.brand_green };
ui.painter().rect_filled(
toast_rect,
Rounding::same(16.0_f32),
Color32::from_rgba_unmultiplied(18, 24, 34, 235),
);
ui.painter().rect_stroke(
toast_rect,
Rounding::same(16.0_f32),
Stroke::new(1.0_f32, badge_color),
);
ui.painter().text(
Pos2::new(toast_rect.min.x + 14.0_f32, toast_rect.center().y),
Align2::LEFT_CENTER,
if *is_err { "⚠" } else { "✓" },
FontId::proportional(14.0_f32),
badge_color,
);
ui.painter().text(
Pos2::new(toast_rect.min.x + 32.0_f32, toast_rect.center().y),
Align2::LEFT_CENTER,
msg,
FontId::proportional(12.5_f32),
theme.text01,
);
}
}
}
// ========================================================================
// Compact Floating Toolbar Mode (Toolbar.qml - 780x74 or 780x280 with popup)
// ========================================================================
fn render_toolbar_mode(&mut self, ctx: &egui::Context, theme: &InsTheme) {
// Keep physical camera frame pipeline alive in background so AI tracking and hardware commands work
let _ = self.video_engine.render_frame(ctx, &mut self.state);
egui::CentralPanel::default()
.frame(
egui::Frame::none()
.fill(theme.bg01)
.inner_margin(Margin::symmetric(8.0_f32, 5.0_f32)),
)
.show(ctx, |ui| {
// 1. Optional Popover Panel above the toolbar (`ToolbarPopup`)
if self.state.toolbar_popup != ToolbarPopup::None {
let pop_h = (ui.available_height() - 68.0_f32).max(150.0_f32);
let (pop_rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), pop_h),
egui::Sense::hover(),
);
ui.painter().rect_filled(pop_rect, Rounding::same(8.0_f32), theme.bg02);
ui.painter().rect_stroke(
pop_rect,
Rounding::same(8.0_f32),
Stroke::new(1.0_f32, theme.border01),
);
let inner_rect = pop_rect.shrink2(egui::vec2(14.0_f32, 10.0_f32));
let mut pop_ui = ui.new_child(egui::UiBuilder::new().max_rect(inner_rect));
pop_ui.vertical(|ui| {
ui.horizontal(|ui| {
let title = match self.state.toolbar_popup {
ToolbarPopup::PictureSetting => "Image & Effects Settings",
ToolbarPopup::GestureCtrl => "Gesture Control",
ToolbarPopup::RemoteCtrl => "Mobile Remote Control",
ToolbarPopup::MoreSetting => "More Camera Settings",
_ => "Settings",
};
ui.label(RichText::new(title).strong().size(13.5_f32).color(theme.text01));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("✕").clicked() {
self.state.toolbar_popup = ToolbarPopup::None;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(
egui::vec2(780.0_f32, 74.0_f32),
));
}
});
});
ui.add_space(6.0_f32);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| match self.state.toolbar_popup {
ToolbarPopup::GestureCtrl => {
if theme::ins_switch_row(
ui,
"Enable Gesture Control",
None,
&mut self.state.enable_all_gestures,
true,
theme,
) {
let _ = self
.controller
.set_all_gestures_enabled(self.state.enable_all_gestures);
}
ui.add_space(4.0_f32);
ui.horizontal(|ui| {
if ui
.checkbox(&mut self.state.use_gesture_palm, "✋ AI Tracking")
.changed()
{
let _ = self.controller.set_gesture_enabled(
insta360_link_protocol::Gesture::Palm,
self.state.use_gesture_palm,
);
}
ui.add_space(12.0_f32);
if ui
.checkbox(&mut self.state.use_gesture_v, "✌ Whiteboard")
.changed()
{
let _ = self.controller.set_gesture_enabled(
insta360_link_protocol::Gesture::VShape,
self.state.use_gesture_v,
);
}
ui.add_space(12.0_f32);
if ui
.checkbox(&mut self.state.use_gesture_l, "👆 Zoom")
.changed()
{
let _ = self.controller.set_gesture_enabled(
insta360_link_protocol::Gesture::LShape,
self.state.use_gesture_l,
);
}
});
}
ToolbarPopup::PictureSetting => {
ui.columns(2, |cols| {
cols[0].vertical(|ui| {
if theme::ins_switch_row(
ui,
"Auto Exposure",
None,
&mut self.state.auto_exposure,
!self.state.hdr_enabled,
theme,
) {
let _ = self
.controller
.set_auto_exposure(self.state.auto_exposure);
}
if self.state.auto_exposure {
if theme::ins_slider_f32(
ui,
Some("EV"),
&mut self.state.ev_compensation,
-3.0,
3.0,
0.3,
1,
"EV",
!self.state.hdr_enabled,
theme,
) {
let _ = self
.controller
.set_exposure_compensation(
self.state.ev_compensation,
);
}
}
if theme::ins_switch_row(
ui,
"Auto Focus",
None,
&mut self.state.auto_focus,
true,
theme,
) {
let _ =
self.controller.set_auto_focus(self.state.auto_focus);
}
if theme::ins_switch_row(
ui,
"Auto White Balance",
None,
&mut self.state.auto_white_balance,
true,
theme,
) {
let _ = self.controller.set_auto_white_balance(
self.state.auto_white_balance,
);
}
});
cols[1].vertical(|ui| {
let mut br = self.state.brightness as i32;
if theme::ins_slider_i32(
ui,
Some("Brightness"),
&mut br,
0,
100,
"%",
true,
theme,
) {
self.state.brightness = br as i16;
let _ = self
.controller
.set_brightness(self.state.brightness);
}
let mut ct = self.state.contrast as i32;
if theme::ins_slider_i32(
ui,
Some("Contrast"),
&mut ct,
0,
100,
"%",
true,
theme,
) {
self.state.contrast = ct as u16;
let _ =
self.controller.set_contrast(self.state.contrast);
}
let mut sat = self.state.saturation as i32;
if theme::ins_slider_i32(
ui,
Some("Saturation"),
&mut sat,
0,
100,
"%",
true,
theme,
) {
self.state.saturation = sat as u16;
let _ = self
.controller
.set_saturation(self.state.saturation);
}
if theme::ins_switch_row(
ui,
"HDR",
None,
&mut self.state.hdr_enabled,
true,
theme,
) {
let _ =
self.controller.set_hdr(self.state.hdr_enabled);
}
});
});
}
ToolbarPopup::RemoteCtrl => {
ui.label(
RichText::new(
"Open http://127.0.0.1:9360 on your phone or browser to control the camera remotely.",
)
.color(theme.text02),
);
}
ToolbarPopup::MoreSetting | _ => {
ui.columns(2, |cols| {
cols[0].vertical(|ui| {
if ui
.checkbox(
&mut self.state.horizontal_mirror,
"Horizontal Flip",
)
.changed()
{
let _ = self.controller.set_horizontal_mirror(
self.state.horizontal_mirror,
);
}
if self.controller.capabilities().support_mirror_vertically
&& ui
.checkbox(
&mut self.state.vertical_mirror,
"Vertical Flip",
)
.changed()
{
let _ = self.controller.set_vertical_mirror(
self.state.vertical_mirror,
);
}
if ui
.checkbox(
&mut self.state.audio_denoise,
"AI Audio Noise Reduction",
)
.changed()
{
let _ = self.controller.set_audio_noise_reduction(
self.state.audio_denoise,
);
}
});
cols[1].vertical(|ui| {
ui.horizontal(|ui| {
ui.label("Tracking Speed:");
egui::ComboBox::from_id_salt("tb_track_speed")
.selected_text(match self.state.track_speed {
insta360_link_protocol::TrackSpeed::Fast => {
"Quick"
}
insta360_link_protocol::TrackSpeed::Normal => {
"Ordinary"
}
insta360_link_protocol::TrackSpeed::Slow => {
"Slow"
}
})
.show_ui(ui, |ui| {
if ui
.selectable_value(
&mut self.state.track_speed,
insta360_link_protocol::TrackSpeed::Fast,
"Quick",
)
.clicked()
{
let _ = self.controller.set_track_speed(
insta360_link_protocol::TrackSpeed::Fast,
);
}
if ui
.selectable_value(
&mut self.state.track_speed,
insta360_link_protocol::TrackSpeed::Normal,
"Ordinary",
)
.clicked()
{
let _ = self.controller.set_track_speed(
insta360_link_protocol::TrackSpeed::Normal,
);
}
if ui
.selectable_value(
&mut self.state.track_speed,
insta360_link_protocol::TrackSpeed::Slow,
"Slow",
)
.clicked()
{
let _ = self.controller.set_track_speed(
insta360_link_protocol::TrackSpeed::Slow,
);
}
});
});
ui.horizontal(|ui| {
ui.label("Anti-Flicker:");
egui::ComboBox::from_id_salt("tb_anti_flicker")
.selected_text(match self.state.anti_flicker {
PowerLineFrequency::Auto => "Auto",
PowerLineFrequency::Hz50 => "50Hz",
PowerLineFrequency::Hz60 => "60Hz",
PowerLineFrequency::Disabled => "Off",
})
.show_ui(ui, |ui| {
if ui
.selectable_value(
&mut self.state.anti_flicker,
PowerLineFrequency::Auto,
"Auto",
)
.clicked()
{
let _ = self
.controller
.set_power_line_frequency(
PowerLineFrequency::Auto,
);
}
if ui
.selectable_value(
&mut self.state.anti_flicker,
PowerLineFrequency::Hz50,
"50Hz",
)
.clicked()
{
let _ = self
.controller
.set_power_line_frequency(
PowerLineFrequency::Hz50,
);
}
if ui
.selectable_value(
&mut self.state.anti_flicker,
PowerLineFrequency::Hz60,
"60Hz",
)
.clicked()
{
let _ = self
.controller
.set_power_line_frequency(
PowerLineFrequency::Hz60,
);
}
});
});
});
});
}
});
});
ui.add_space(4.0_f32);
}
// 2. Main 64px Pill Bar (`Toolbar.qml` `toolbarRect`)
let (bar_rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 64.0_f32),
egui::Sense::hover(),
);
ui.painter()
.rect_filled(bar_rect, Rounding::same(8.0_f32), theme.bg02);
ui.painter().rect_stroke(
bar_rect,
Rounding::same(8.0_f32),
Stroke::new(1.0_f32, theme.border01),
);
let inner_bar = bar_rect.shrink2(egui::vec2(10.0_f32, 6.0_f32));
let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(inner_bar));
bar_ui.horizontal_centered(|ui| {
// Leftmost stacked Close + Switch to Main Window (`Toolbar.qml` lines 473-511)
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 4.0_f32;
if ui
.add_sized([22.0_f32, 22.0_f32], egui::Button::new(RichText::new("✕").size(11.0_f32)))
.on_hover_text("Close Application")
.clicked()
{
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui
.add_sized([22.0_f32, 22.0_f32], egui::Button::new(RichText::new("🗖").size(11.0_f32)))
.on_hover_text("Switch to Main Window")
.clicked()
{
self.state.ui_mode = UiMode::MainWindow;
self.state.toolbar_popup = ToolbarPopup::None;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
1440.0_f32, 714.0_f32,
)));
}
});
ui.add_space(6.0_f32);
let (d0, _) = ui.allocate_exact_size(egui::vec2(1.0_f32, 32.0_f32), egui::Sense::hover());
ui.painter().rect_filled(d0, Rounding::ZERO, theme.border01);
ui.add_space(6.0_f32);
// PTZ Circular D-Pad (`PTZController.qml` 44x44 circle with directional sectors + center reset)
let (pad_rect, pad_resp) = ui.allocate_exact_size(
egui::vec2(46.0_f32, 46.0_f32),
egui::Sense::click_and_drag(),
);
let center = pad_rect.center();
let radius = 22.0_f32;
ui.painter().circle_filled(center, radius, theme.overlay03);
ui.painter().circle_stroke(center, radius, Stroke::new(1.0_f32, theme.border01));
ui.painter().circle_filled(center, 7.0_f32, theme.bg02);
// Directional chevrons on the circular pad
for (pos, glyph) in [
(Pos2::new(center.x, center.y - 14.5_f32), "▴"),
(Pos2::new(center.x, center.y + 14.5_f32), "▾"),
(Pos2::new(center.x - 14.5_f32, center.y), "◂"),
(Pos2::new(center.x + 14.5_f32, center.y), "▸"),
] {
ui.painter().text(
pos,
Align2::CENTER_CENTER,
glyph,
FontId::proportional(11.0_f32),
theme.text02,
);
}
if pad_resp.is_pointer_button_down_on() {
if let Some(ptr) = pad_resp.interact_pointer_pos() {
let d = ptr - center;
if d.length() < 7.5_f32 && pad_resp.clicked() {
self.state.pan_deg = 0.0_f32;
self.state.tilt_deg = 0.0_f32;
let _ = self.controller.reset_gimbal();
} else if d.length() >= 7.5_f32 {
let pan_dir = if d.x.abs() > d.y.abs() * 0.5_f32 {
if d.x > 0.0_f32 { 1i8 } else { -1i8 }
} else {
0i8
};
let tilt_dir = if d.y.abs() > d.x.abs() * 0.5_f32 {
if d.y > 0.0_f32 { 1i8 } else { -1i8 }
} else {
0i8
};
let can_send = self
.state
.last_ptz_send_time
.map(|t| t.elapsed().as_millis() >= 60)
.unwrap_or(true);
if can_send {
let _ = self.controller.set_pantilt_relative_ext(
if pan_dir == 0 { 0 } else { 4 },
pan_dir,
if tilt_dir == 0 { 0 } else { 4 },
tilt_dir,
);
self.state.last_ptz_cmd = (4, pan_dir, 4, tilt_dir);
self.state.last_ptz_send_time = Some(Instant::now());
self.state.nudge_stop_at =
Some(Instant::now() + Duration::from_millis(140));
}
}
}
}
pad_resp.on_hover_text("Drag or click arrows to Pan/Tilt • Click center dot to Reset");
ui.add_space(6.0_f32);
// Compact Zoom controls (`−`, slider, `+`)
if ui
.add_sized([22.0_f32, 26.0_f32], egui::Button::new("−"))
.on_hover_text("Zoom Out")
.clicked()
{
self.state.zoom_x = (self.state.zoom_x - 0.25_f32).clamp(1.0_f32, 4.0_f32);
let _ = self.controller.set_zoom((self.state.zoom_x * 100.0_f32) as u16);
}
ui.spacing_mut().slider_width = 64.0_f32;
if ui
.add(
egui::Slider::new(&mut self.state.zoom_x, 1.0_f32..=4.0_f32)
.show_value(false),
)
.on_hover_text(format!("Zoom: {:.1}x", self.state.zoom_x))
.changed()
{
let _ = self.controller.set_zoom((self.state.zoom_x * 100.0_f32) as u16);
}
if ui
.add_sized([22.0_f32, 26.0_f32], egui::Button::new("+"))
.on_hover_text("Zoom In")
.clicked()
{
self.state.zoom_x = (self.state.zoom_x + 0.25_f32).clamp(1.0_f32, 4.0_f32);
let _ = self.controller.set_zoom((self.state.zoom_x * 100.0_f32) as u16);
}
ui.add_space(8.0_f32);
let (d1, _) = ui.allocate_exact_size(egui::vec2(1.0_f32, 32.0_f32), egui::Sense::hover());
ui.painter().rect_filled(d1, Rounding::ZERO, theme.border01);
ui.add_space(8.0_f32);
// AI Modes (`SecondLastSubarea.qml`: 40x40 rounded icon buttons with tooltips)
let trk_act = self.state.video_mode == VideoMode::AutoTracking
|| self.state.video_mode == VideoMode::AutoFraming;
let wb_act = self.state.video_mode == VideoMode::WhiteBoard;
let ovh_act = self.state.video_mode == VideoMode::OverheadShot;
let swb_act = self.state.video_mode == VideoMode::SmartWhiteBoard;
let dsk_act = self.state.video_mode == VideoMode::DeskView;
for (icon, tooltip, active, target_mode) in [
("🎯", "AI Tracking", trk_act, VideoMode::AutoTracking),
("📋", "Whiteboard", wb_act, VideoMode::WhiteBoard),
("⬇", "Overhead", ovh_act, VideoMode::OverheadShot),
("🖽", "Smart Whiteboard", swb_act, VideoMode::SmartWhiteBoard),
("📐", "DeskView", dsk_act, VideoMode::DeskView),
] {
let fill = if active {
theme.brand_green
} else {
theme.overlay03
};
let fg = if active {
Color32::BLACK
} else {
theme.text01
};
let btn = egui::Button::new(RichText::new(icon).size(16.0_f32).color(fg))
.fill(fill)
.rounding(Rounding::same(8.0_f32));
if ui
.add_sized([40.0_f32, 40.0_f32], btn)
.on_hover_text(tooltip)
.clicked()
{
self.set_video_mode(target_mode);
}
ui.add_space(2.0_f32);
}
ui.add_space(6.0_f32);
let (d2, _) = ui.allocate_exact_size(egui::vec2(1.0_f32, 32.0_f32), egui::Sense::hover());
ui.painter().rect_filled(d2, Rounding::ZERO, theme.border01);
ui.add_space(6.0_f32);
// Quick Popover Buttons (`LastSubarea.qml`: Gesture, Image/Effects, Remote, More)
for (icon, tooltip, popup) in [
("✋", "Gesture Control", ToolbarPopup::GestureCtrl),
("🎨", "Image & Effects", ToolbarPopup::PictureSetting),
("📱", "Remote Control", ToolbarPopup::RemoteCtrl),
("⚙", "More Settings", ToolbarPopup::MoreSetting),
] {
let active = self.state.toolbar_popup == popup;
let fill = if active {
theme.brand_green
} else {
theme.overlay03
};
let fg = if active {
Color32::BLACK
} else {
theme.text01
};
let btn = egui::Button::new(RichText::new(icon).size(15.0_f32).color(fg))
.fill(fill)
.rounding(Rounding::same(8.0_f32));
if ui
.add_sized([38.0_f32, 40.0_f32], btn)
.on_hover_text(tooltip)
.clicked()
{
if active {
self.state.toolbar_popup = ToolbarPopup::None;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(
egui::vec2(780.0_f32, 74.0_f32),
));
} else {
self.state.toolbar_popup = popup;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(
egui::vec2(780.0_f32, 270.0_f32),
));
}
}
ui.add_space(2.0_f32);
}
ui.add_space(6.0_f32);
if ui
.add_sized(
[92.0_f32, 36.0_f32],
egui::Button::new(
RichText::new("🗖 Main View")
.size(12.0_f32)
.color(theme.text01),
)
.fill(theme.overlay03)
.rounding(Rounding::same(8.0_f32)),
)
.on_hover_text("Restore full Insta360 Link Controller window")
.clicked()
{
self.state.ui_mode = UiMode::MainWindow;
self.state.toolbar_popup = ToolbarPopup::None;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
1440.0_f32, 714.0_f32,
)));
}
});
});
}
// ========================================================================
// Modals & Dialogs (Preferences, Virtual Cam, Remote QR, Factory Reset, DeskView Pitch)
// ========================================================================
fn render_modals(&mut self, ctx: &egui::Context, theme: &InsTheme) {
// Preferences modal (preference/MoreSettings.qml - 640x480)
if self.state.show_preferences {
let mut open = self.state.show_preferences;
egui::Window::new("Settings")
.fixed_size([640.0_f32, 480.0_f32])
.collapsible(false)
.resizable(false)
.open(&mut open)
.show(ctx, |ui| {
ui.horizontal(|ui| {
// Left navigation pane (160px)
ui.vertical(|ui| {
ui.set_width(150.0_f32);
let tabs = [
(PreferenceTab::General, "General"),
(PreferenceTab::Hotkeys, "Hotkeys"),
(PreferenceTab::GetLog, "Report Issue"),
(PreferenceTab::About, "About Us"),
];
for (tab, label) in tabs {
let is_active = self.state.preference_tab == tab;
if ui.selectable_label(is_active, label).clicked() {
self.state.preference_tab = tab;
}
}
});
ui.separator();
// Right content pane
ui.vertical(|ui| {
match self.state.preference_tab {
PreferenceTab::General => {
ui.label(RichText::new("General Settings").strong().color(theme.text01));
ui.add_space(8.0_f32);
ui.label("Appearance:");
ui.horizontal(|ui| {
ui.radio_value(&mut self.state.is_light_mode, false, "Dark");
ui.radio_value(&mut self.state.is_light_mode, true, "Light");
});
ui.add_space(12.0_f32);
ui.label("Screenshot Save Location:");
ui.label(
RichText::new(self.state.screenshot_dir.display().to_string())
.monospace()
.color(theme.text02),
);
ui.add_space(8.0_f32);
ui.label("Recording Save Location:");
ui.label(
RichText::new(self.state.recording_dir.display().to_string())
.monospace()
.color(theme.text02),
);
}
PreferenceTab::Hotkeys => {
ui.label(RichText::new("Hotkey Shortcuts").strong().color(theme.text01));
ui.add_space(8.0_f32);
egui::Grid::new("hotkeys_table")
.striped(true)
.spacing([24.0_f32, 8.0_f32])
.show(ui, |ui| {
ui.label("Action");
ui.label("Shortcut");
ui.end_row();
let shortcuts = [
("AI Tracking", "Ctrl + Shift + T"),
("Whiteboard", "Ctrl + Shift + W"),
("Overhead", "Ctrl + Shift + O"),
("DeskView", "Ctrl + Shift + D"),
("Gimbal Up/Down/Left/Right", "Arrow Keys"),
("Center gimbal", "Ctrl + Shift + C"),
("Enlarge / Zoom out", "+ / -"),
("Toggle Toolbar Mode", "Ctrl + Shift + M"),
];
for (act, key) in shortcuts {
ui.label(act);
ui.label(RichText::new(key).monospace().color(theme.brand_green));
ui.end_row();
}
});
}
PreferenceTab::GetLog => {
ui.label(RichText::new("Report Issue & Diagnostics").strong().color(theme.text01));
ui.add_space(8.0_f32);
ui.label(
"If you encounter camera issues, switch to Read-Only USB Mass Storage mode to retrieve hardware and gimbal logs.",
);
ui.add_space(12.0_f32);
if ui
.button("Retrieve Camera Logs (Read-Only USB Mode)")
.clicked()
{
let _ = self.controller.switch_msc_readonly_class();
self.state.show_toast(
"Switched camera to Read-Only USB Mass Storage Mode",
false,
);
}
}
PreferenceTab::About => {
ui.label(
RichText::new("Insta360 Link Controller")
.size(16.0_f32)
.strong()
.color(theme.text01),
);
ui.add_space(4.0_f32);
ui.label("Version: v0.1.0 (Pure-Rust Open Desktop Controller)");
ui.add_space(8.0_f32);
ui.label(format!("Active Device: {}", self.state.device_info.device_name));
ui.label(format!(
"Firmware Version: {}",
self.state.device_info.firmware_version
));
ui.label(format!(
"Serial Number: {}",
self.state.device_info.serial_number
));
ui.label(format!("PTZ Version: {}", self.state.ptz_info.version));
}
}
});
});
});
self.state.show_preferences = open;
}
// Virtual Camera modal (DefVirCamPop.qml)
if self.state.show_virtual_cam_pop {
let mut open = self.state.show_virtual_cam_pop;
egui::Window::new("Virtual Camera")
.fixed_size([420.0_f32, 230.0_f32])
.collapsible(false)
.open(&mut open)
.show(ctx, |ui| {
ui.label(RichText::new("Virtual Camera Output").strong().color(theme.text01));
ui.add_space(6.0_f32);
ui.horizontal(|ui| {
ui.label("Enable Virtual Camera:");
let mut vcam = self.state.virtual_camera_enabled;
if ui.checkbox(&mut vcam, "").changed() {
self.state.virtual_camera_enabled = vcam;
let msg = if vcam {
"Virtual Camera enabled"
} else {
"Virtual Camera disabled"
};
self.state.show_toast(msg, false);
}
});
ui.add_space(8.0_f32);
ui.label(
RichText::new(
"Virtual Camera allows you to output video with AI tracking, filters, and background effects to third-party applications like Zoom, Microsoft Teams, OBS, and Google Meet.",
)
.size(12.0_f32)
.color(theme.text02),
);
ui.add_space(4.0_f32);
ui.label(
RichText::new(
"Select 'Insta360 Virtual Camera' as the video input in your conferencing software.",
)
.size(12.0_f32)
.color(theme.text03),
);
});
self.state.show_virtual_cam_pop = open;
}
// Remote Control modal (QRCodeDialog.qml)
if self.state.show_qr_dialog {
let mut open = self.state.show_qr_dialog;
egui::Window::new("Mobile Remote Control")
.fixed_size([380.0_f32, 240.0_f32])
.collapsible(false)
.open(&mut open)
.show(ctx, |ui| {
ui.label(RichText::new("Local Web Remote").strong().color(theme.text01));
ui.add_space(6.0_f32);
ui.label(
"Scan QR code or open the following URL in your phone or browser on the same Wi-Fi network:",
);
ui.add_space(8.0_f32);
ui.label(
RichText::new("http://127.0.0.1:9360")
.monospace()
.size(14.0_f32)
.color(theme.brand_green),
);
ui.add_space(8.0_f32);
ui.label(
RichText::new(
"Remotely control gimbal pan/tilt, AI tracking mode, zoom factor, and preset positions.",
)
.size(12.0_f32)
.color(theme.text02),
);
});
self.state.show_qr_dialog = open;
}
// Factory Reset confirmation modal
if self.state.show_factory_reset_pop {
let mut open = self.state.show_factory_reset_pop;
egui::Window::new("Restore Factory Settings")
.fixed_size([360.0_f32, 160.0_f32])
.collapsible(false)
.open(&mut open)
.show(ctx, |ui| {
ui.label(
RichText::new("Are you sure you want to restore factory settings?")
.strong()
.color(theme.text01),
);
ui.add_space(6.0_f32);
ui.label(
"This will reset all gimbal presets, image parameters, and tracking settings to factory defaults.",
);
ui.add_space(14.0_f32);
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
self.state.show_factory_reset_pop = false;
}
if ui
.button(RichText::new("Confirm").color(theme.error).strong())
.clicked()
{
let _ = self.controller.restore_factory_settings(false);
self.state.show_factory_reset_pop = false;
self.state
.show_toast("Restored camera to factory default settings", false);
}
});
});
self.state.show_factory_reset_pop = open;
}
// DeskView Vertical Correction Pitch Popover
if self.state.show_deskview_pitch_pop {
let mut open = self.state.show_deskview_pitch_pop;
egui::Window::new("DeskView Vertical Correction")
.fixed_size([260.0_f32, 120.0_f32])
.collapsible(false)
.open(&mut open)
.show(ctx, |ui| {
ui.label("DeskView Pitch Correction:");
let mut pitch_deg = self.state.deskview_pitch as f32 / 10.0_f32;
if ui
.add(egui::Slider::new(&mut pitch_deg, -90.0_f32..=0.0_f32).text("°"))
.changed()
{
self.state.deskview_pitch = (pitch_deg * 10.0_f32) as i16;
let mut aux = VideoModeAuxData::default();
aux.pitch = self.state.deskview_pitch;
let _ = self.controller.set_video_mode(VideoMode::DeskView, &aux);
}
ui.add_space(8.0_f32);
if ui.button("Close").clicked() {
self.state.show_deskview_pitch_pop = false;
}
});
self.state.show_deskview_pitch_pop = open;
}
}
}
impl eframe::App for Insta360App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Continuous repaint loop for smooth 30fps live video preview
if self.state.preview_open {
ctx.request_repaint_after(Duration::from_millis(33));
}
// Keep capture resolution synced with selected format preset
let (target_w, target_h) = self.state.format_preset.dimensions();
let target_fps = self.state.format_preset.fps();
if self.video_engine.capture_dimensions() != (target_w as usize, target_h as usize) {
self.video_engine
.set_capture_format(target_w as usize, target_h as usize, target_fps);
}
// 1. One-time hardware initialization once the physical video stream is open and delivering frames
if self.video_engine.has_live_physical_frame() && !self.state.stream_initialized {
self.state.stream_initialized = true;
self.sync_device_info();
// If the camera was left in Overhead or DeskView mode (which flips the ISP 180 degrees),
// restore Normal mode so the live video stream is right-side up.
if self.state.video_mode == VideoMode::OverheadShot
|| self.state.video_mode == VideoMode::DeskView
{
let _ = self
.controller
.set_video_mode_raw(VideoMode::Normal, &VideoModeAuxData::default());
self.state.video_mode = VideoMode::Normal;
}
}
// 2. Auto-stop short PTZ directional nudges (`CameraInsta::addPanTilt` 100-120ms timer)
if let Some(stop_at) = self.state.nudge_stop_at {
if Instant::now() >= stop_at {
let _ = self.controller.stop_pantilt_relative();
self.state.nudge_stop_at = None;
self.state.last_ptz_cmd = (0, 0, 0, 0);
if let Ok((pan_arc, tilt_arc)) = self.controller.get_pantilt_absolute() {
self.state.pan_deg = (pan_arc as f32 / 3600.0_f32).clamp(-130.0_f32, 130.0_f32);
self.state.tilt_deg = (tilt_arc as f32 / 3600.0_f32).clamp(-90.0_f32, 90.0_f32);
}
}
}
// 2b. Restore pre-DeskView / pre-Overhead PTZ angle once firmware finishes exiting mode
if let Some((restore_at, pan_arc, tilt_arc)) = self.state.pending_ptz_restore {
if Instant::now() >= restore_at {
self.state.pending_ptz_restore = None;
let _ = self.controller.set_pantilt_absolute(pan_arc, tilt_arc);
}
}
// 3. Non-blocking AI Tracking target discovery (`onGetTrackObjectTimeOut` every 200ms)
if self.state.ready_auto_track && self.state.video_mode == VideoMode::AutoTracking {
let should_poll = self
.state
.last_track_poll
.map(|t| t.elapsed().as_millis() >= 200)
.unwrap_or(true);
if should_poll {
self.state.last_track_poll = Some(Instant::now());
if let Ok(rects) = self.controller.get_track_objects() {
if rects.len() == 1 {
// `gotRect` (`0x1000740e8`): single person detected -> immediately lock target,
// stop `checkTrack` (`SetCheckTrack(false)`), clear boxes, and enter `AutoTracking` (`1`)!
let r = &rects[0];
let cx = (((r.x as f32) + (r.width as f32) * 0.5_f32) / 10000.0_f32)
.clamp(0.05_f32, 0.95_f32);
let cy = (((r.y as f32) + (r.height as f32) * 0.5_f32) / 10000.0_f32)
.clamp(0.05_f32, 0.95_f32);
if self.self_test_enabled {
println!(
"[GUI-SELF-TEST] Detected 1 person rect={:?}, locking onto ({:.3}, {:.3})",
r, cx, cy
);
}
let _ = self.controller.select_track_object_norm(cx, cy, 0.0_f32, 0.0_f32);
std::thread::sleep(Duration::from_millis(30));
self.state.ready_auto_track = false;
self.state.tracked_targets.clear();
let _ = self.controller.set_video_mode_raw(
VideoMode::AutoTracking,
&VideoModeAuxData::default(),
);
self.state.last_hw_sync =
Some(Instant::now() + Duration::from_millis(700));
self.state.show_toast("AI Tracking locked onto target", false);
} else if rects.len() > 1 {
// `gotRect` (`0x100074164`): multiple people detected -> display target selection boxes!
self.state.tracked_targets = rects;
}
}
}
}
// 4. Periodic hardware sync (`onGetVideoModeTimeOut` every 500ms)
// IMPORTANT: Do NOT poll `GetTrackObjLists` (`XU1 0x14`) or `GetPanTiltAbsoluteValue` (`XU1 0x1a`)
// while `AutoTracking` is locked and active (`19_40_33.log` lines 412-419)!
if self.state.stream_initialized
&& self.state.last_ptz_cmd == (0, 0, 0, 0)
&& self.state.pending_ptz_restore.is_none()
{
let should_sync = self
.state
.last_hw_sync
.map(|t| t.elapsed().as_millis() >= 500)
.unwrap_or(true);
if should_sync {
self.state.last_hw_sync = Some(Instant::now());
if !self.state.ready_auto_track {
if let Ok((hw_mode, _)) = self.controller.get_video_mode() {
if hw_mode != self.state.video_mode {
self.state.video_mode = hw_mode;
if hw_mode == VideoMode::Normal {
self.state.tracked_targets.clear();
}
}
}
}
if self.state.video_mode == VideoMode::Normal && !self.state.ready_auto_track {
if let Ok((pan_arc, tilt_arc)) = self.controller.get_pantilt_absolute() {
self.state.pan_deg =
(pan_arc as f32 / 3600.0_f32).clamp(-130.0_f32, 130.0_f32);
self.state.tilt_deg =
(tilt_arc as f32 / 3600.0_f32).clamp(-90.0_f32, 90.0_f32);
}
}
}
}
// 5. Optional automated live hardware GUI verification (`INSTA360_GUI_SELF_TEST=1`)
if self.self_test_enabled && self.state.stream_initialized {
let start = *self.self_test_start.get_or_insert_with(Instant::now);
let elapsed = start.elapsed().as_secs_f32();
if self.self_test_step == 0 && elapsed >= 3.2 {
self.self_test_step = 1;
if self.state.video_mode != VideoMode::Normal {
self.set_video_mode(VideoMode::Normal);
}
if let Ok((p, t)) = self.controller.get_pantilt_absolute() {
self.state.pan_deg = p as f32 / 3600.0;
self.state.tilt_deg = t as f32 / 3600.0;
}
println!(
"[GUI-SELF-TEST] Step 1: Stream initialized, device='{}', fw='{}', mode={:?}, pan={:.1}, tilt={:.1}",
self.state.device_info.device_name,
self.state.device_info.firmware_version,
self.state.video_mode,
self.state.pan_deg,
self.state.tilt_deg
);
// Start 1080p video recording test
let out_dir = std::env::temp_dir();
if let Ok(rec_path) = self.video_engine.start_recording(&out_dir) {
println!(
"[GUI-SELF-TEST] Started 1920x1080 video recording to {}",
rec_path.display()
);
}
} else if self.self_test_step == 1 && elapsed >= 4.8 {
self.self_test_step = 2;
let out_dir = std::env::temp_dir();
if let Ok(rec_path) = self.video_engine.stop_recording() {
let size = std::fs::metadata(&rec_path).map(|m| m.len()).unwrap_or(0);
println!(
"[GUI-SELF-TEST] Stopped 1920x1080 video recording: {} ({} bytes)",
rec_path.display(),
size
);
}
if let Ok(snap_1080) = self.video_engine.save_screenshot(&out_dir, &self.state) {
println!(
"[GUI-SELF-TEST] Saved 1920x1080 screenshot: {}",
snap_1080.display()
);
}
// Verify View Tab & Effects Tab & More Tab hardware settings readback
let _ = self.controller.set_zoom(200);
let z_read = self.controller.get_zoom().unwrap_or(0);
let _ = self.controller.set_zoom(100);
let _ = self.controller.set_track_speed(insta360_link_protocol::TrackSpeed::Fast);
let ts_fast = self.controller.get_track_speed().ok();
let _ = self.controller.set_track_speed(insta360_link_protocol::TrackSpeed::Normal);
let ts_norm = self.controller.get_track_speed().ok();
let _ = self.controller.set_auto_exposure(true);
let _ = self.controller.set_exposure_compensation(1.5);
let ev_15 = self.controller.get_exposure_compensation().unwrap_or(0.0);
let _ = self.controller.set_exposure_compensation(0.0);
let ev_0 = self.controller.get_exposure_compensation().unwrap_or(99.0);
let _ = self.controller.set_auto_exposure(false);
let ae_off = self.controller.get_auto_exposure().unwrap_or(true);
let _ = self.controller.set_iso(400);
let iso_400 = self.controller.get_iso().unwrap_or(0);
let _ = self.controller.set_shutter_speed(120);
let sh_120 = self.controller.get_shutter_speed().unwrap_or(0);
let _ = self.controller.set_auto_exposure(true);
let ae_on = self.controller.get_auto_exposure().unwrap_or(false);
let _ = self.controller.set_auto_focus(false);
let af_off = self.controller.get_auto_focus().unwrap_or(true);
let _ = self.controller.set_focus(60);
let f_60 = self.controller.get_focus().unwrap_or(0);
let _ = self.controller.set_auto_focus(true);
let af_on = self.controller.get_auto_focus().unwrap_or(false);
let _ = self.controller.set_auto_white_balance(false);
let awb_off = self.controller.get_auto_white_balance().unwrap_or(true);
let _ = self.controller.set_white_balance_temperature(4200);
let wb_4200 = self.controller.get_white_balance_temperature().unwrap_or(0);
let _ = self.controller.set_auto_white_balance(true);
let awb_on = self.controller.get_auto_white_balance().unwrap_or(false);
let _ = self.controller.set_brightness(65);
let br_65 = self.controller.get_brightness().unwrap_or(0);
let _ = self.controller.set_brightness(50);
let _ = self.controller.set_contrast(65);
let ct_65 = self.controller.get_contrast().unwrap_or(0);
let _ = self.controller.set_contrast(50);
let _ = self.controller.set_saturation(65);
let sat_65 = self.controller.get_saturation().unwrap_or(0);
let _ = self.controller.set_saturation(50);
let _ = self.controller.set_sharpness(65);
let sh_65 = self.controller.get_sharpness().unwrap_or(0);
let _ = self.controller.set_sharpness(50);
let _ = self.controller.set_power_line_frequency(PowerLineFrequency::Hz50);
let pl_50 = self.controller.get_power_line_frequency().ok();
let _ = self.controller.set_power_line_frequency(PowerLineFrequency::Hz60);
let pl_60 = self.controller.get_power_line_frequency().ok();
let _ = self.controller.set_audio_noise_reduction(false);
let nr_off = self.controller.get_audio_noise_reduction().ok();
let _ = self.controller.set_audio_noise_reduction(true);
let nr_on = self.controller.get_audio_noise_reduction().ok();
let _ = self.controller.set_all_gestures_enabled(true);
let _ = self
.controller
.set_gesture_enabled(insta360_link_protocol::Gesture::Palm, true);
let gest = self.controller.get_gesture_status().ok();
println!(
"[GUI-SELF-TEST] Hardware Settings Verified:\n zoom={z_read}, track_speed=({ts_fast:?} -> {ts_norm:?})\n AE=({ae_off} -> {ae_on}), EV=({ev_15:.1} -> {ev_0:.1}), ISO={iso_400}, Shutter=1/{sh_120}s\n AF=({af_off} -> {af_on}), Focus={f_60}, AWB=({awb_off} -> {awb_on}), WB={wb_4200}K\n Bright={br_65}, Contrast={ct_65}, Sat={sat_65}, Sharp={sh_65}\n AntiFlicker=({pl_50:?} -> {pl_60:?}), AudioDenoise=({nr_off:?} -> {nr_on:?}), Gestures={gest:?}"
);
println!(
"[GUI-SELF-TEST] Step 2: Starting smooth joystick velocity pan(speed=3, dir=1) from pan={:.1} deg, tilt={:.1} deg",
self.state.pan_deg, self.state.tilt_deg
);
let _ = self.controller.set_pantilt_relative_ext(3, 1, 0, 0);
self.state.last_ptz_cmd = (3, 1, 0, 0);
} else if self.self_test_step == 2 && elapsed >= 5.5 {
self.self_test_step = 3;
let _ = self.controller.stop_pantilt_relative();
self.state.last_ptz_cmd = (0, 0, 0, 0);
if let Ok((p, t)) = self.controller.get_pantilt_absolute() {
self.state.pan_deg = p as f32 / 3600.0;
self.state.tilt_deg = t as f32 / 3600.0;
}
println!(
"[GUI-SELF-TEST] Step 3: Stopped joystick velocity (pan={:.1}, tilt={:.1}); testing Toolbar Mode & activating AI Tracking",
self.state.pan_deg, self.state.tilt_deg
);
self.state.ui_mode = UiMode::Toolbar;
self.set_video_mode(VideoMode::AutoTracking);
} else if self.self_test_step == 3
&& ((!self.state.ready_auto_track && elapsed >= 7.0) || elapsed >= 10.5)
{
self.self_test_step = 4;
std::thread::sleep(Duration::from_millis(500));
self.state.ui_mode = UiMode::MainWindow;
let hw_mode = self.controller.get_video_mode().ok().map(|m| m.0);
println!(
"[GUI-SELF-TEST] Step 4: AI Tracking active (ui_mode={:?}, hw_mode={:?}); switching to DeskView",
self.state.video_mode, hw_mode
);
self.set_video_mode(VideoMode::DeskView);
} else if self.self_test_step == 4 && elapsed >= 12.5 {
self.self_test_step = 5;
let hw_mode = self.controller.get_video_mode().ok().map(|m| m.0);
println!(
"[GUI-SELF-TEST] Step 5: DeskView active (hw_mode={:?}); restoring Normal mode",
hw_mode
);
self.set_video_mode(VideoMode::Normal);
} else if self.self_test_step == 5 && elapsed >= 15.0 {
self.self_test_step = 6;
if let Ok((p, t)) = self.controller.get_pantilt_absolute() {
self.state.pan_deg = p as f32 / 3600.0;
self.state.tilt_deg = t as f32 / 3600.0;
}
let out_dir = std::env::temp_dir();
if let Ok(path) = self.video_engine.save_screenshot(&out_dir, &self.state) {
println!(
"[GUI-SELF-TEST] Step 6: Restored pan={:.1} deg, tilt={:.1} deg; saved final live GUI video frame to {}",
self.state.pan_deg,
self.state.tilt_deg,
path.display()
);
}
println!("[GUI-SELF-TEST] ALL GUI HARDWARE INTERACTION STEPS PASSED");
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
let theme = apply_ins_theme(ctx, self.state.is_light_mode);
match self.state.ui_mode {
UiMode::MainWindow => {
// Top Bar (TopBarWin.qml - 44px height, theme.bg01)
self.render_top_bar(ctx, &theme);
// Right Sidebar Settings Panel (292px fixed width, theme.bg01)
egui::SidePanel::right("ins_right_settings")
.exact_width(292.0_f32)
.resizable(false)
.frame(
egui::Frame::none()
.fill(theme.bg01)
.inner_margin(Margin {
left: 8.0_f32,
right: 8.0_f32,
top: 0.0_f32,
bottom: 8.0_f32,
}),
)
.show(ctx, |ui| {
render_right_settings_panel(
ui,
ctx,
&mut self.state,
&mut self.controller,
&self.video_engine,
&theme,
);
});
// Central Panel: Video Preview Area + 66px Bottom Control Bar in a theme.bg02 container
egui::CentralPanel::default()
.frame(
egui::Frame::none()
.fill(theme.bg01)
.inner_margin(Margin::same(8.0_f32)),
)
.show(ctx, |ui| {
egui::Frame::none()
.fill(theme.bg02)
.rounding(Rounding::same(8.0_f32))
.show(ui, |ui| {
let total_size = ui.available_size();
let bottom_bar_height = 66.0_f32;
let preview_height =
(total_size.y - bottom_bar_height).max(100.0_f32);
// Video preview area
ui.allocate_ui(
egui::vec2(total_size.x, preview_height),
|ui| {
self.render_preview_area(ui, ctx, &theme);
},
);
// Bottom mode & recording bar
ui.allocate_ui(
egui::vec2(total_size.x, bottom_bar_height),
|ui| {
self.render_bottom_control_bar(ui, &theme);
},
);
});
});
}
UiMode::Toolbar => {
// Compact Floating Toolbar Mode (Toolbar.qml)
self.render_toolbar_mode(ctx, &theme);
}
}
// Popups and modals
self.render_modals(ctx, &theme);
}
}
fn build_app_icon() -> egui::IconData {
let w = 64u32;
let h = 64u32;
let mut rgba = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let idx = ((y * w + x) * 4) as usize;
let dx = x as f32 - 31.5_f32;
let dy = y as f32 - 26.0_f32;
let dist = (dx * dx + dy * dy).sqrt();
// Dark rounded card background
let in_card = x >= 4 && x < 60 && y >= 4 && y < 60;
if !in_card {
continue;
}
let (mut r, mut g, mut b) = (20u8, 24u8, 32u8);
// Green camera ring
if (10.5_f32..=14.0_f32).contains(&dist) {
r = 0;
g = 230;
b = 118;
} else if dist < 5.0_f32 {
r = 0;
g = 230;
b = 118;
} else if dist < 10.5_f32 {
r = 12;
g = 15;
b = 20;
} else if (18..=46).contains(&x) && (44..=52).contains(&y) {
// Gimbal base
r = 43;
g = 50;
b = 66;
}
rgba[idx] = r;
rgba[idx + 1] = g;
rgba[idx + 2] = b;
rgba[idx + 3] = 255;
}
}
egui::IconData {
rgba,
width: w,
height: h,
}
}
fn main() -> eframe::Result<()> {
tracing_subscriber::fmt::init();
// Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_app_id("insta360-link-controller")
.with_icon(build_app_icon())
.with_inner_size([1440.0_f32, 714.0_f32])
.with_min_inner_size([1120.0_f32, 536.0_f32])
.with_title("Insta360 Link Controller"),
..Default::default()
};
eframe::run_native(
"Insta360 Link Controller",
native_options,
Box::new(|cc| Ok(Box::new(Insta360App::new(cc)))),
)
}