diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 16f6a4a606..ac39d64319 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -683,6 +683,19 @@ std::string AppConfig::load() local_machine.printer_type = p["printer_type"].get(); m_local_machines[local_machine.dev_id] = local_machine; } + } else if (it.key() == "printer_cameras") { + for (auto m = it.value().begin(); m != it.value().end(); ++m) { + const auto& p = m.value(); + PrinterCameraConfig cam_config; + cam_config.dev_id = m.key(); + if (p.contains("custom_source")) + cam_config.custom_source = p["custom_source"].get(); + if (p.contains("source_type")) + cam_config.source_type = camera_source_type_from_string(p["source_type"].get()); + if (p.contains("enabled")) + cam_config.enabled = p["enabled"].get(); + m_printer_cameras[cam_config.dev_id] = cam_config; + } } else { if (it.value().is_object()) { for (auto iter = it.value().begin(); iter != it.value().end(); iter++) { @@ -880,6 +893,14 @@ void AppConfig::save() j["local_machines"][local_machine.first] = m_json; } + for (const auto& printer_camera : m_printer_cameras) { + json cam_json; + cam_json["custom_source"] = printer_camera.second.custom_source; + cam_json["source_type"] = camera_source_type_to_string(printer_camera.second.source_type); + cam_json["enabled"] = printer_camera.second.enabled; + + j["printer_cameras"][printer_camera.first] = cam_json; + } boost::nowide::ofstream c; c.open(path_pid, std::ios::out | std::ios::trunc); c << std::setw(4) << j << std::endl; diff --git a/src/libslic3r/AppConfig.hpp b/src/libslic3r/AppConfig.hpp index 9307877552..13d856d7ed 100644 --- a/src/libslic3r/AppConfig.hpp +++ b/src/libslic3r/AppConfig.hpp @@ -46,6 +46,45 @@ struct BBLocalMachine bool operator!=(const BBLocalMachine& other) const { return !operator==(other); } }; +enum class CameraSourceType : int { + Builtin = 0, + WebView = 1, + RTSP = 2, + MJPEG = 3 +}; + +inline std::string camera_source_type_to_string(CameraSourceType type) { + switch (type) { + case CameraSourceType::Builtin: return "builtin"; + case CameraSourceType::WebView: return "webview"; + case CameraSourceType::RTSP: return "rtsp"; + case CameraSourceType::MJPEG: return "mjpeg"; + } + return "builtin"; +} + +inline CameraSourceType camera_source_type_from_string(const std::string& s) { + if (s == "webview") return CameraSourceType::WebView; + if (s == "rtsp") return CameraSourceType::RTSP; + if (s == "mjpeg") return CameraSourceType::MJPEG; + return CameraSourceType::Builtin; +} + +struct PrinterCameraConfig +{ + std::string dev_id; + std::string custom_source; + CameraSourceType source_type = CameraSourceType::Builtin; + bool enabled = false; + + bool operator==(const PrinterCameraConfig& other) const + { + return dev_id == other.dev_id && custom_source == other.custom_source + && source_type == other.source_type && enabled == other.enabled; + } + bool operator!=(const PrinterCameraConfig& other) const { return !operator==(other); } +}; + class AppConfig { public: @@ -246,6 +285,40 @@ public: } } + const std::map& get_all_printer_cameras() const { return m_printer_cameras; } + bool has_printer_camera(const std::string& dev_id) const + { + return m_printer_cameras.find(dev_id) != m_printer_cameras.end(); + } + PrinterCameraConfig get_printer_camera(const std::string& dev_id) const + { + auto it = m_printer_cameras.find(dev_id); + if (it != m_printer_cameras.end()) + return it->second; + return PrinterCameraConfig{}; + } + void set_printer_camera(const PrinterCameraConfig& config) + { + auto it = m_printer_cameras.find(config.dev_id); + if (it != m_printer_cameras.end()) { + if (it->second != config) { + m_printer_cameras[config.dev_id] = config; + m_dirty = true; + } + } else { + m_printer_cameras[config.dev_id] = config; + m_dirty = true; + } + } + void erase_printer_camera(const std::string& dev_id) + { + auto it = m_printer_cameras.find(dev_id); + if (it != m_printer_cameras.end()) { + m_printer_cameras.erase(it); + m_dirty = true; + } + } + const std::vector &get_filament_presets() const { return m_filament_presets; } void set_filament_presets(const std::vector &filament_presets){ m_filament_presets = filament_presets; @@ -389,6 +462,7 @@ private: std::vector m_printer_cali_infos; std::map m_local_machines; + std::map m_printer_cameras; }; } // namespace Slic3r diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index ca4f9c4123..f91ec9d8cd 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -80,6 +80,8 @@ set(SLIC3R_GUI_SOURCES GUI/Camera.hpp GUI/CameraPopup.cpp GUI/CameraPopup.hpp + GUI/CameraManagementDialog.cpp + GUI/CameraManagementDialog.hpp GUI/CameraUtils.cpp GUI/CameraUtils.hpp GUI/CloneDialog.cpp @@ -650,18 +652,25 @@ if (APPLE) GUI/GUI_UtilsMac.mm GUI/wxMediaCtrl2.mm GUI/wxMediaCtrl2.h + GUI/NativeMediaCtrl.h + GUI/NativeMediaCtrl.cpp + GUI/NativeMediaCtrl_Mac.mm ) FIND_LIBRARY(DISKARBITRATION_LIBRARY DiskArbitration) else () list(APPEND SLIC3R_GUI_SOURCES GUI/wxMediaCtrl2.cpp GUI/wxMediaCtrl2.h + GUI/NativeMediaCtrl.h + GUI/NativeMediaCtrl.cpp + GUI/NativeMediaCtrl_Win.cpp ) endif () if (UNIX AND NOT APPLE) list(APPEND SLIC3R_GUI_SOURCES GUI/Printer/gstbambusrc.c + GUI/NativeMediaCtrl_Linux.cpp ) endif () @@ -764,8 +773,19 @@ if (UNIX AND NOT APPLE) # We add GStreamer for bambu:/// support. pkg_check_modules(GSTREAMER REQUIRED gstreamer-1.0) pkg_check_modules(GST_BASE REQUIRED gstreamer-base-1.0) - target_link_libraries(libslic3r_gui ${GSTREAMER_LIBRARIES} ${GST_BASE_LIBRARIES}) - target_include_directories(libslic3r_gui SYSTEM PRIVATE ${GSTREAMER_INCLUDE_DIRS} ${GST_BASE_INCLUDE_DIRS}) + pkg_check_modules(GST_VIDEO REQUIRED gstreamer-video-1.0) + target_link_libraries(libslic3r_gui ${GSTREAMER_LIBRARIES} ${GST_BASE_LIBRARIES} ${GST_VIDEO_LIBRARIES}) + target_include_directories(libslic3r_gui SYSTEM PRIVATE ${GSTREAMER_INCLUDE_DIRS} ${GST_BASE_INCLUDE_DIRS} ${GST_VIDEO_INCLUDE_DIRS}) +endif () + +if (APPLE) + find_package(PkgConfig REQUIRED) + pkg_check_modules(GSTREAMER REQUIRED gstreamer-1.0) + pkg_check_modules(GST_BASE REQUIRED gstreamer-base-1.0) + pkg_check_modules(GST_VIDEO REQUIRED gstreamer-video-1.0) + pkg_check_modules(GST_APP REQUIRED gstreamer-app-1.0) + target_link_libraries(libslic3r_gui ${GSTREAMER_LDFLAGS} ${GST_BASE_LDFLAGS} ${GST_VIDEO_LDFLAGS} ${GST_APP_LDFLAGS}) + target_include_directories(libslic3r_gui SYSTEM PRIVATE ${GSTREAMER_INCLUDE_DIRS} ${GST_BASE_INCLUDE_DIRS} ${GST_VIDEO_INCLUDE_DIRS} ${GST_APP_INCLUDE_DIRS}) endif () # Add a definition so that we can tell we are compiling slic3r. diff --git a/src/slic3r/GUI/CameraManagementDialog.cpp b/src/slic3r/GUI/CameraManagementDialog.cpp new file mode 100644 index 0000000000..ec905d3863 --- /dev/null +++ b/src/slic3r/GUI/CameraManagementDialog.cpp @@ -0,0 +1,417 @@ +#include "CameraManagementDialog.hpp" +#include "I18N.hpp" +#include "GUI_App.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/DialogButtons.hpp" +#include "DeviceCore/DevManager.h" + +namespace Slic3r { namespace GUI { + +CameraEditDialog::CameraEditDialog(wxWindow* parent, + const std::string& dev_id, + const std::string& url, + CameraSourceType source_type, + bool enabled) + : DPIDialog(parent, wxID_ANY, _L("Edit Camera Override"), wxDefaultPosition, wxSize(FromDIP(400), FromDIP(250)), wxDEFAULT_DIALOG_STYLE) + , m_initial_dev_id(dev_id) +{ + create_ui(); + populate_printer_list(); + + if (!dev_id.empty()) { + for (size_t i = 0; i < m_printers.size(); ++i) { + if (m_printers[i].first == dev_id) { + m_printer_combo->SetSelection(static_cast(i)); + break; + } + } + } + m_source_type_combo->SetSelection(static_cast(source_type)); + m_url_input->GetTextCtrl()->SetValue(url); + m_enabled_checkbox->SetValue(enabled); + + update_url_field_state(); + + wxGetApp().UpdateDarkUIWin(this); +} + +void CameraEditDialog::create_ui() +{ + SetBackgroundColour(*wxWHITE); + + wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); + + wxFlexGridSizer* grid_sizer = new wxFlexGridSizer(4, 2, FromDIP(10), FromDIP(10)); + grid_sizer->AddGrowableCol(1); + + wxStaticText* printer_label = new wxStaticText(this, wxID_ANY, _L("Printer:")); + m_printer_combo = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(FromDIP(250), -1), 0, nullptr, wxCB_READONLY); + + wxStaticText* source_type_label = new wxStaticText(this, wxID_ANY, _L("Source Type:")); + wxArrayString source_types; + source_types.Add(_L("Built-in Camera")); + source_types.Add(_L("Web View")); + source_types.Add(_L("RTSP Stream")); + source_types.Add(_L("MJPEG Stream")); + m_source_type_combo = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(FromDIP(250), -1), source_types, wxCB_READONLY); + m_source_type_combo->SetSelection(0); + m_source_type_combo->Bind(wxEVT_COMBOBOX, &CameraEditDialog::on_source_type_changed, this); + + wxStaticText* url_label = new wxStaticText(this, wxID_ANY, _L("Camera URL:")); + m_url_input = new TextInput(this, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, wxSize(FromDIP(250), -1)); + m_url_input->GetTextCtrl()->SetHint(_L("rtsp://user:pass@camera.local:554/stream")); + + wxStaticText* enabled_label = new wxStaticText(this, wxID_ANY, _L("Enabled:")); + m_enabled_checkbox = new CheckBox(this); + + grid_sizer->Add(printer_label, 0, wxALIGN_CENTER_VERTICAL); + grid_sizer->Add(m_printer_combo, 1, wxEXPAND); + grid_sizer->Add(source_type_label, 0, wxALIGN_CENTER_VERTICAL); + grid_sizer->Add(m_source_type_combo, 1, wxEXPAND); + grid_sizer->Add(url_label, 0, wxALIGN_CENTER_VERTICAL); + grid_sizer->Add(m_url_input, 1, wxEXPAND); + grid_sizer->Add(enabled_label, 0, wxALIGN_CENTER_VERTICAL); + grid_sizer->Add(m_enabled_checkbox, 0); + + main_sizer->Add(grid_sizer, 0, wxEXPAND | wxALL, FromDIP(15)); + + auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); + main_sizer->Add(dlg_btns, 0, wxEXPAND); + + dlg_btns->GetOK()->Bind(wxEVT_BUTTON, &CameraEditDialog::on_ok, this); + dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + SetSizer(main_sizer); + Layout(); + Fit(); + CenterOnParent(); +} + +void CameraEditDialog::populate_printer_list() +{ + m_printers.clear(); + + const auto& existing_cameras = wxGetApp().app_config->get_all_printer_cameras(); + auto has_existing_override = [&](const std::string& dev_id) { + if (!m_initial_dev_id.empty()) return false; + return existing_cameras.find(dev_id) != existing_cameras.end(); + }; + + auto* dev_manager = wxGetApp().getDeviceManager(); + if (dev_manager) { + for (const auto& pair : dev_manager->get_local_machinelist()) { + if (pair.second && !has_existing_override(pair.second->get_dev_id())) { + m_printers.emplace_back(pair.second->get_dev_id(), pair.second->get_dev_name()); + } + } + for (const auto& pair : dev_manager->get_user_machinelist()) { + if (pair.second && !has_existing_override(pair.second->get_dev_id())) { + bool exists = false; + for (const auto& p : m_printers) { + if (p.first == pair.second->get_dev_id()) { + exists = true; + break; + } + } + if (!exists) { + m_printers.emplace_back(pair.second->get_dev_id(), pair.second->get_dev_name()); + } + } + } + } + + const auto& local_machines = wxGetApp().app_config->get_local_machines(); + for (const auto& pair : local_machines) { + if (has_existing_override(pair.first)) continue; + bool exists = false; + for (const auto& p : m_printers) { + if (p.first == pair.first) { + exists = true; + break; + } + } + if (!exists) { + m_printers.emplace_back(pair.first, pair.second.dev_name); + } + } + + m_printer_combo->Clear(); + for (const auto& p : m_printers) { + wxString display = wxString::Format("%s (%s)", p.second, p.first); + m_printer_combo->Append(display); + } + + if (!m_printers.empty()) { + m_printer_combo->SetSelection(0); + } +} + +void CameraEditDialog::on_ok(wxCommandEvent& event) +{ + if (m_printer_combo->GetSelection() == wxNOT_FOUND) { + wxMessageBox(_L("Please select a printer"), _L("Error"), wxOK | wxICON_ERROR, this); + return; + } + CameraSourceType type = get_source_type(); + if (type != CameraSourceType::Builtin && m_url_input->GetTextCtrl()->GetValue().IsEmpty()) { + wxMessageBox(_L("Please enter a camera URL"), _L("Error"), wxOK | wxICON_ERROR, this); + return; + } + EndModal(wxID_OK); +} + +std::string CameraEditDialog::get_dev_id() const +{ + int sel = m_printer_combo->GetSelection(); + if (sel >= 0 && sel < static_cast(m_printers.size())) { + return m_printers[sel].first; + } + return ""; +} + +std::string CameraEditDialog::get_url() const +{ + return m_url_input->GetTextCtrl()->GetValue().ToStdString(); +} + +CameraSourceType CameraEditDialog::get_source_type() const +{ + int sel = m_source_type_combo->GetSelection(); + if (sel >= 0 && sel <= static_cast(CameraSourceType::MJPEG)) { + return static_cast(sel); + } + return CameraSourceType::Builtin; +} + +bool CameraEditDialog::get_enabled() const +{ + return m_enabled_checkbox->GetValue(); +} + +void CameraEditDialog::on_source_type_changed(wxCommandEvent& event) +{ + update_url_field_state(); +} + +void CameraEditDialog::update_url_field_state() +{ + CameraSourceType type = get_source_type(); + bool needs_url = (type != CameraSourceType::Builtin); + m_url_input->Enable(needs_url); + + if (type == CameraSourceType::RTSP) { + m_url_input->GetTextCtrl()->SetHint(_L("rtsp://user:pass@camera.local:554/stream")); + } else if (type == CameraSourceType::MJPEG) { + m_url_input->GetTextCtrl()->SetHint(_L("http://camera.local/mjpg/video.mjpg")); + } else if (type == CameraSourceType::WebView) { + m_url_input->GetTextCtrl()->SetHint(_L("http://camera.local/stream")); + } else { + m_url_input->GetTextCtrl()->SetHint(wxEmptyString); + } +} + +void CameraEditDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + Layout(); + Fit(); +} + +CameraManagementDialog::CameraManagementDialog(wxWindow* parent) + : DPIDialog(parent, wxID_ANY, _L("Camera Overrides"), wxDefaultPosition, wxSize(FromDIP(550), FromDIP(400)), wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) +{ + create_ui(); + refresh_list(); + wxGetApp().UpdateDarkUIWin(this); +} + +CameraManagementDialog::~CameraManagementDialog() +{ + cleanup_list_data(); +} + +void CameraManagementDialog::create_ui() +{ + SetBackgroundColour(*wxWHITE); + + wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); + + wxStaticText* title = new wxStaticText(this, wxID_ANY, _L("Configure custom camera URLs for each printer")); + title->SetFont(Label::Body_14); + main_sizer->Add(title, 0, wxALL, FromDIP(15)); + + m_list_ctrl = new wxDataViewListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxDV_SINGLE | wxDV_ROW_LINES); + m_list_ctrl->AppendTextColumn(_L("Printer"), wxDATAVIEW_CELL_INERT, FromDIP(120)); + m_list_ctrl->AppendTextColumn(_L("Serial"), wxDATAVIEW_CELL_INERT, FromDIP(80)); + m_list_ctrl->AppendTextColumn(_L("Type"), wxDATAVIEW_CELL_INERT, FromDIP(70)); + m_list_ctrl->AppendTextColumn(_L("Camera URL"), wxDATAVIEW_CELL_INERT, FromDIP(180)); + m_list_ctrl->AppendTextColumn(_L("Enabled"), wxDATAVIEW_CELL_INERT, FromDIP(60)); + + m_list_ctrl->Bind(wxEVT_DATAVIEW_SELECTION_CHANGED, &CameraManagementDialog::on_selection_changed, this); + m_list_ctrl->Bind(wxEVT_DATAVIEW_ITEM_ACTIVATED, &CameraManagementDialog::on_item_activated, this); + + main_sizer->Add(m_list_ctrl, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(15)); + + auto dlg_btns = new DialogButtons(this, {"Delete", "Add", "Edit", "Close"}, "", 1); + main_sizer->Add(dlg_btns, 0, wxEXPAND); + + m_btn_delete = dlg_btns->GetButtonFromLabel(_L("Delete")); + m_btn_add = dlg_btns->GetButtonFromLabel(_L("Add")); + m_btn_edit = dlg_btns->GetButtonFromLabel(_L("Edit")); + + m_btn_delete->Enable(false); + m_btn_edit->Enable(false); + + m_btn_add->Bind(wxEVT_BUTTON, &CameraManagementDialog::on_add, this); + m_btn_edit->Bind(wxEVT_BUTTON, &CameraManagementDialog::on_edit, this); + m_btn_delete->Bind(wxEVT_BUTTON, &CameraManagementDialog::on_delete, this); + + dlg_btns->GetButtonFromLabel(_L("Close"))->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); + + dlg_btns->SetAlertButton(_L("Delete")); + + SetSizer(main_sizer); + Layout(); + CenterOnParent(); +} + +static wxString source_type_display_name(CameraSourceType type) { + switch (type) { + case CameraSourceType::Builtin: return _L("Built-in"); + case CameraSourceType::WebView: return _L("WebView"); + case CameraSourceType::RTSP: return _L("RTSP"); + case CameraSourceType::MJPEG: return _L("MJPEG"); + } + return _L("Built-in"); +} + +void CameraManagementDialog::cleanup_list_data() +{ + if (!m_list_ctrl) return; + + for (unsigned int i = 0; i < m_list_ctrl->GetItemCount(); ++i) { + wxDataViewItem item = m_list_ctrl->RowToItem(i); + auto* dev_id_ptr = reinterpret_cast(m_list_ctrl->GetItemData(item)); + delete dev_id_ptr; + } +} + +void CameraManagementDialog::refresh_list() +{ + cleanup_list_data(); + m_list_ctrl->DeleteAllItems(); + + const auto& cameras = wxGetApp().app_config->get_all_printer_cameras(); + for (const auto& pair : cameras) { + wxVector data; + data.push_back(wxVariant(get_printer_name_for_dev_id(pair.first))); + data.push_back(wxVariant(truncate_serial(pair.first))); + data.push_back(wxVariant(source_type_display_name(pair.second.source_type))); + data.push_back(wxVariant(pair.second.custom_source)); + data.push_back(wxVariant(pair.second.enabled ? _L("Yes") : _L("No"))); + m_list_ctrl->AppendItem(data, reinterpret_cast(new std::string(pair.first))); + } + + m_btn_edit->Enable(false); + m_btn_delete->Enable(false); +} + +void CameraManagementDialog::on_add(wxCommandEvent& event) +{ + CameraEditDialog dlg(this); + if (dlg.ShowModal() == wxID_OK) { + PrinterCameraConfig config; + config.dev_id = dlg.get_dev_id(); + config.custom_source = dlg.get_url(); + config.source_type = dlg.get_source_type(); + config.enabled = dlg.get_enabled(); + wxGetApp().app_config->set_printer_camera(config); + refresh_list(); + } +} + +void CameraManagementDialog::on_edit(wxCommandEvent& event) +{ + int row = m_list_ctrl->GetSelectedRow(); + if (row == wxNOT_FOUND) return; + + auto* dev_id_ptr = reinterpret_cast(m_list_ctrl->GetItemData(m_list_ctrl->RowToItem(row))); + if (!dev_id_ptr) return; + + std::string dev_id = *dev_id_ptr; + auto config = wxGetApp().app_config->get_printer_camera(dev_id); + + CameraEditDialog dlg(this, dev_id, config.custom_source, config.source_type, config.enabled); + if (dlg.ShowModal() == wxID_OK) { + if (dlg.get_dev_id() != dev_id) { + wxGetApp().app_config->erase_printer_camera(dev_id); + } + + PrinterCameraConfig new_config; + new_config.dev_id = dlg.get_dev_id(); + new_config.custom_source = dlg.get_url(); + new_config.source_type = dlg.get_source_type(); + new_config.enabled = dlg.get_enabled(); + wxGetApp().app_config->set_printer_camera(new_config); + refresh_list(); + } +} + +void CameraManagementDialog::on_delete(wxCommandEvent& event) +{ + int row = m_list_ctrl->GetSelectedRow(); + if (row == wxNOT_FOUND) return; + + auto* dev_id_ptr = reinterpret_cast(m_list_ctrl->GetItemData(m_list_ctrl->RowToItem(row))); + if (!dev_id_ptr) return; + + int result = wxMessageBox(_L("Are you sure you want to delete this camera override?"), _L("Confirm Delete"), wxYES_NO | wxICON_QUESTION, this); + if (result == wxYES) { + wxGetApp().app_config->erase_printer_camera(*dev_id_ptr); + refresh_list(); + } +} + +void CameraManagementDialog::on_selection_changed(wxDataViewEvent& event) +{ + bool has_selection = m_list_ctrl->GetSelectedRow() != wxNOT_FOUND; + m_btn_edit->Enable(has_selection); + m_btn_delete->Enable(has_selection); +} + +void CameraManagementDialog::on_item_activated(wxDataViewEvent& event) +{ + wxCommandEvent evt; + on_edit(evt); +} + +std::string CameraManagementDialog::get_printer_name_for_dev_id(const std::string& dev_id) +{ + auto* dev_manager = wxGetApp().getDeviceManager(); + if (dev_manager) { + MachineObject* obj = dev_manager->get_local_machine(dev_id); + if (obj) return obj->get_dev_name(); + + obj = dev_manager->get_user_machine(dev_id); + if (obj) return obj->get_dev_name(); + } + + const auto& local_machines = wxGetApp().app_config->get_local_machines(); + auto it = local_machines.find(dev_id); + if (it != local_machines.end()) { + return it->second.dev_name; + } + + return truncate_serial(dev_id); +} + +std::string CameraManagementDialog::truncate_serial(const std::string& dev_id) +{ + return dev_id; +} + +void CameraManagementDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + Layout(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/CameraManagementDialog.hpp b/src/slic3r/GUI/CameraManagementDialog.hpp new file mode 100644 index 0000000000..7a0f6e5d62 --- /dev/null +++ b/src/slic3r/GUI/CameraManagementDialog.hpp @@ -0,0 +1,76 @@ +#ifndef slic3r_CameraManagementDialog_hpp_ +#define slic3r_CameraManagementDialog_hpp_ + +#include "GUI_Utils.hpp" +#include "Widgets/Button.hpp" +#include "Widgets/TextInput.hpp" +#include "Widgets/CheckBox.hpp" +#include "libslic3r/AppConfig.hpp" +#include + +namespace Slic3r { namespace GUI { + +class CameraEditDialog : public DPIDialog +{ +public: + CameraEditDialog(wxWindow* parent, + const std::string& dev_id = "", + const std::string& url = "", + CameraSourceType source_type = CameraSourceType::Builtin, + bool enabled = false); + + std::string get_dev_id() const; + std::string get_url() const; + CameraSourceType get_source_type() const; + bool get_enabled() const; + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void create_ui(); + void populate_printer_list(); + void on_ok(wxCommandEvent& event); + void on_source_type_changed(wxCommandEvent& event); + void update_url_field_state(); + + wxComboBox* m_printer_combo{nullptr}; + wxComboBox* m_source_type_combo{nullptr}; + TextInput* m_url_input{nullptr}; + CheckBox* m_enabled_checkbox{nullptr}; + + std::vector> m_printers; + std::string m_initial_dev_id; +}; + +class CameraManagementDialog : public DPIDialog +{ +public: + CameraManagementDialog(wxWindow* parent); + ~CameraManagementDialog(); + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void create_ui(); + void refresh_list(); + void cleanup_list_data(); + void on_add(wxCommandEvent& event); + void on_edit(wxCommandEvent& event); + void on_delete(wxCommandEvent& event); + void on_selection_changed(wxDataViewEvent& event); + void on_item_activated(wxDataViewEvent& event); + + std::string get_printer_name_for_dev_id(const std::string& dev_id); + std::string truncate_serial(const std::string& dev_id); + + wxDataViewListCtrl* m_list_ctrl{nullptr}; + Button* m_btn_add{nullptr}; + Button* m_btn_edit{nullptr}; + Button* m_btn_delete{nullptr}; +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_CameraManagementDialog_hpp_ diff --git a/src/slic3r/GUI/CameraPopup.cpp b/src/slic3r/GUI/CameraPopup.cpp index 6d38182e33..b9248e3fb7 100644 --- a/src/slic3r/GUI/CameraPopup.cpp +++ b/src/slic3r/GUI/CameraPopup.cpp @@ -3,11 +3,14 @@ #include "I18N.hpp" #include "Widgets/Label.hpp" #include "libslic3r/Utils.hpp" +#include "libslic3r/AppConfig.hpp" #include "BitmapCache.hpp" #include #include #include #include "GUI_App.hpp" +#include "MainFrame.hpp" +#include "CameraManagementDialog.hpp" #include #include "DeviceCore/DevManager.h" @@ -16,33 +19,23 @@ namespace Slic3r { namespace GUI { -wxIMPLEMENT_CLASS(CameraPopup, PopupWindow); - -wxBEGIN_EVENT_TABLE(CameraPopup, PopupWindow) - EVT_MOUSE_EVENTS(CameraPopup::OnMouse ) - EVT_SIZE(CameraPopup::OnSize) - EVT_SET_FOCUS(CameraPopup::OnSetFocus ) - EVT_KILL_FOCUS(CameraPopup::OnKillFocus ) -wxEND_EVENT_TABLE() - wxDEFINE_EVENT(EVT_VCAMERA_SWITCH, wxMouseEvent); wxDEFINE_EVENT(EVT_SDCARD_ABSENT_HINT, wxCommandEvent); wxDEFINE_EVENT(EVT_CAM_SOURCE_CHANGE, wxCommandEvent); -#define CAMERAPOPUP_CLICK_INTERVAL 20 - const wxColour TEXT_COL = wxColour(43, 52, 54); CameraPopup::CameraPopup(wxWindow *parent) - : PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS) + : DPIDialog(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, + wxBORDER_SIMPLE | wxFRAME_NO_TASKBAR | wxFRAME_FLOAT_ON_PARENT | wxSTAY_ON_TOP) { + SetBackgroundColour(*wxWHITE); #ifdef __WINDOWS__ SetDoubleBuffered(true); #endif m_panel = new wxScrolledWindow(this, wxID_ANY); m_panel->SetBackgroundColour(*wxWHITE); m_panel->SetMinSize(wxSize(FromDIP(180),-1)); - m_panel->Bind(wxEVT_MOTION, &CameraPopup::OnMouse, this); main_sizer = new wxBoxSizer(wxVERTICAL); wxFlexGridSizer* top_sizer = new wxFlexGridSizer(0, 2, 0, FromDIP(50)); @@ -101,34 +94,24 @@ CameraPopup::CameraPopup(wxWindow *parent) top_sizer->Add(0, 0, wxALL, 0); } - // custom IP camera - m_custom_camera_input_confirm = new Button(m_panel, _L("Enable")); - m_custom_camera_input_confirm->SetBackgroundColor(wxColour(38, 166, 154)); - m_custom_camera_input_confirm->SetBorderColor(wxColour(38, 166, 154)); - m_custom_camera_input_confirm->SetTextColor(wxColour(0xFFFFFE)); - m_custom_camera_input_confirm->SetFont(Label::Body_14); - m_custom_camera_input_confirm->SetMinSize(wxSize(FromDIP(90), FromDIP(30))); - m_custom_camera_input_confirm->SetPosition(wxDefaultPosition); - m_custom_camera_input_confirm->SetCornerRadius(FromDIP(12)); - m_custom_camera_input = new TextInput(m_panel, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, wxDefaultSize); - m_custom_camera_input->GetTextCtrl()->SetHint(_L("Hostname or IP")); - m_custom_camera_input->GetTextCtrl()->SetFont(Label::Body_14); - m_custom_camera_hint = new wxStaticText(m_panel, wxID_ANY, _L("Custom camera source")); + m_custom_camera_hint = new wxStaticText(m_panel, wxID_ANY, _L("Custom camera")); m_custom_camera_hint->Wrap(-1); m_custom_camera_hint->SetFont(Label::Head_14); m_custom_camera_hint->SetForegroundColour(TEXT_COL); - - m_custom_camera_input_confirm->Bind(wxEVT_BUTTON, &CameraPopup::on_camera_source_changed, this); - - if (!wxGetApp().app_config->get("camera", "custom_source").empty()) { - m_custom_camera_input->GetTextCtrl()->SetValue(wxGetApp().app_config->get("camera", "custom_source")); - set_custom_cam_button_state(wxGetApp().app_config->get("camera", "enable_custom_source") == "true"); - } + m_switch_custom_camera = new SwitchButton(m_panel); + m_switch_custom_camera->Bind(wxEVT_TOGGLEBUTTON, &CameraPopup::on_custom_camera_switch_toggled, this); top_sizer->Add(m_custom_camera_hint, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_LEFT | wxALL, FromDIP(5)); + top_sizer->Add(m_switch_custom_camera, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT | wxALL, FromDIP(5)); + + m_manage_cameras_link = new Label(m_panel, _L("Manage all camera overrides...")); + m_manage_cameras_link->SetForegroundColour(wxColour(0x1F, 0x8E, 0xEA)); + m_manage_cameras_link->SetFont(Label::Body_12); + m_manage_cameras_link->SetCursor(wxCursor(wxCURSOR_HAND)); + m_manage_cameras_link->Bind(wxEVT_LEFT_DOWN, &CameraPopup::on_manage_cameras_clicked, this); + top_sizer->Add(m_manage_cameras_link, 0, wxALIGN_LEFT | wxALL, FromDIP(5)); top_sizer->Add(0, 0, wxALL, 0); - top_sizer->Add(m_custom_camera_input, 2, wxALIGN_CENTER_VERTICAL | wxALIGN_LEFT | wxEXPAND | wxALL, FromDIP(5)); - top_sizer->Add(m_custom_camera_input_confirm, 1, wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT | wxALL, FromDIP(5)); + main_sizer->Add(top_sizer, 0, wxALL, FromDIP(10)); auto url = wxString::Format(L"https://wiki.bambulab.com/%s/software/bambu-studio/virtual-camera", L"en"); @@ -165,13 +148,13 @@ CameraPopup::CameraPopup(wxWindow *parent) evt.SetEventObject(this); GetEventHandler()->ProcessEvent(evt); }); - #ifdef __APPLE__ - m_panel->Bind(wxEVT_LEFT_UP, &CameraPopup::OnLeftUp, this); - #endif //APPLE - this->Bind(wxEVT_TIMER, &CameraPopup::stop_interval, this); - m_interval_timer = new wxTimer(); - m_interval_timer->SetOwner(this); + Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& e) { + if (!e.GetActive()) { + Hide(); + } + e.Skip(); + }); wxGetApp().UpdateDarkUIWin(this); } @@ -183,35 +166,55 @@ void CameraPopup::sdcard_absent_hint() GetEventHandler()->ProcessEvent(evt); } -void CameraPopup::on_camera_source_changed(wxCommandEvent &event) +void CameraPopup::on_custom_camera_switch_toggled(wxCommandEvent& event) { - if (m_obj && !m_custom_camera_input->GetTextCtrl()->IsEmpty()) { - handle_camera_source_change(); - } -} + if (!m_obj) return; -void CameraPopup::handle_camera_source_change() -{ - m_custom_camera_enabled = !m_custom_camera_enabled; + std::string dev_id = m_obj->get_dev_id(); + if (!wxGetApp().app_config->has_printer_camera(dev_id)) + return; - set_custom_cam_button_state(m_custom_camera_enabled); - - wxGetApp().app_config->set("camera", "custom_source", m_custom_camera_input->GetTextCtrl()->GetValue().ToStdString()); - wxGetApp().app_config->set("camera", "enable_custom_source", m_custom_camera_enabled); + auto config = wxGetApp().app_config->get_printer_camera(dev_id); + config.enabled = m_switch_custom_camera->GetValue(); + wxGetApp().app_config->set_printer_camera(config); wxCommandEvent evt(EVT_CAM_SOURCE_CHANGE); evt.SetEventObject(this); GetEventHandler()->ProcessEvent(evt); } -void CameraPopup::set_custom_cam_button_state(bool state) +void CameraPopup::update_custom_camera_switch() { - m_custom_camera_enabled = state; - auto stateColour = state ? wxColour(170, 0, 0) : wxColour(38, 166, 154); - auto stateText = state ? "Disable" : "Enable"; - m_custom_camera_input_confirm->SetBackgroundColor(stateColour); - m_custom_camera_input_confirm->SetBorderColor(stateColour); - m_custom_camera_input_confirm->SetLabel(_L(stateText)); + if (!m_obj) { + m_switch_custom_camera->SetValue(false); + m_switch_custom_camera->Enable(false); + return; + } + + std::string dev_id = m_obj->get_dev_id(); + if (wxGetApp().app_config->has_printer_camera(dev_id)) { + auto config = wxGetApp().app_config->get_printer_camera(dev_id); + bool has_url = !config.custom_source.empty(); + m_switch_custom_camera->Enable(has_url); + m_switch_custom_camera->SetValue(has_url && config.enabled); + } else { + m_switch_custom_camera->SetValue(false); + m_switch_custom_camera->Enable(false); + } +} + +void CameraPopup::on_manage_cameras_clicked(wxMouseEvent& event) +{ + Hide(); + + CameraManagementDialog dlg(wxGetApp().mainframe); + dlg.ShowModal(); + + update_custom_camera_switch(); + + wxCommandEvent evt(EVT_CAM_SOURCE_CHANGE); + evt.SetEventObject(this); + GetEventHandler()->ProcessEvent(evt); } void CameraPopup::on_switch_recording(wxCommandEvent& event) @@ -233,17 +236,6 @@ void CameraPopup::on_set_resolution() m_obj->command_ipcam_resolution_set(to_resolution_msg_string(curr_sel_resolution)); } -void CameraPopup::Popup(wxWindow *WXUNUSED(focus)) -{ - wxPoint curr_position = this->GetPosition(); - wxSize win_size = this->GetSize(); - curr_position.x -= win_size.x; - this->SetPosition(curr_position); - - if (!m_is_in_interval) - PopupWindow::Popup(); -} - wxWindow* CameraPopup::create_item_radiobox(wxString title, wxWindow* parent, wxString tooltip, int padding_left) { wxWindow *item = new wxWindow(parent, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(20))); @@ -341,6 +333,9 @@ void CameraPopup::check_func_supported(MachineObject *obj2) m_obj = obj2; if (m_obj == nullptr) return; + + update_custom_camera_switch(); + // function supported if (m_obj->has_ipcam) { m_text_recording->Show(); @@ -435,100 +430,15 @@ void CameraPopup::rescale() m_panel->Layout(); main_sizer->Fit(m_panel); SetClientSize(m_panel->GetSize()); - PopupWindow::Update(); + Layout(); } -void CameraPopup::OnLeftUp(wxMouseEvent &event) +void CameraPopup::on_dpi_changed(const wxRect& suggested_rect) { - auto mouse_pos = ClientToScreen(event.GetPosition()); - auto wxscroll_win_pos = m_panel->ClientToScreen(wxPoint(0, 0)); - - if (mouse_pos.x > wxscroll_win_pos.x && mouse_pos.y > wxscroll_win_pos.y && mouse_pos.x < (wxscroll_win_pos.x + m_panel->GetSize().x) && mouse_pos.y < (wxscroll_win_pos.y + m_panel->GetSize().y)) { - //recording - auto recording_rect = m_switch_recording->ClientToScreen(wxPoint(0, 0)); - if (mouse_pos.x > recording_rect.x && mouse_pos.y > recording_rect.y && mouse_pos.x < (recording_rect.x + m_switch_recording->GetSize().x) && mouse_pos.y < (recording_rect.y + m_switch_recording->GetSize().y)) { - wxMouseEvent recording_evt(wxEVT_LEFT_DOWN); - m_switch_recording->GetEventHandler()->ProcessEvent(recording_evt); - return; - } - //vcamera - auto vcamera_rect = m_switch_vcamera->ClientToScreen(wxPoint(0, 0)); - if (mouse_pos.x > vcamera_rect.x && mouse_pos.y > vcamera_rect.y && mouse_pos.x < (vcamera_rect.x + m_switch_vcamera->GetSize().x) && mouse_pos.y < (vcamera_rect.y + m_switch_vcamera->GetSize().y)) { - wxMouseEvent vcamera_evt(wxEVT_LEFT_DOWN); - m_switch_vcamera->GetEventHandler()->ProcessEvent(vcamera_evt); - return; - } - //resolution - for (int i = 0; i < (int)RESOLUTION_OPTIONS_NUM; ++i){ - auto resolution_rbtn = resolution_rbtns[i]; - auto rbtn_rect = resolution_rbtn->ClientToScreen(wxPoint(0, 0)); - if (mouse_pos.x > rbtn_rect.x && mouse_pos.y > rbtn_rect.y && mouse_pos.x < (rbtn_rect.x + resolution_rbtn->GetSize().x) && mouse_pos.y < (rbtn_rect.y + resolution_rbtn->GetSize().y)) { - wxMouseEvent resolution_evt(wxEVT_LEFT_DOWN); - resolution_rbtn->GetEventHandler()->ProcessEvent(resolution_evt); - return; - } - auto resolution_txt = resolution_texts[i]; - auto txt_rect = resolution_txt->ClientToScreen(wxPoint(0, 0)); - if (mouse_pos.x > txt_rect.x && mouse_pos.y > txt_rect.y && mouse_pos.x < (txt_rect.x + resolution_txt->GetSize().x) && mouse_pos.y < (txt_rect.y + resolution_txt->GetSize().y)) { - wxMouseEvent resolution_evt(wxEVT_LEFT_DOWN); - resolution_txt->GetEventHandler()->ProcessEvent(resolution_evt); - return; - } - } - //hyper link - auto h_rect = vcamera_guide_link->ClientToScreen(wxPoint(0, 0)); - if (mouse_pos.x > h_rect.x && mouse_pos.y > h_rect.y && mouse_pos.x < (h_rect.x + vcamera_guide_link->GetSize().x) && mouse_pos.y < (h_rect.y + vcamera_guide_link->GetSize().y)) { - auto url = wxString::Format(L"https://wiki.bambulab.com/%s/software/bambu-studio/virtual-camera", L"en"); - wxLaunchDefaultBrowser(url); - } - } -} - -void CameraPopup::start_interval() -{ - m_interval_timer->Start(CAMERAPOPUP_CLICK_INTERVAL); - m_is_in_interval = true; -} - -void CameraPopup::stop_interval(wxTimerEvent& event) -{ - m_is_in_interval = false; - m_interval_timer->Stop(); -} - -void CameraPopup::OnDismiss() { - PopupWindow::OnDismiss(); - this->start_interval(); -} - -bool CameraPopup::ProcessLeftDown(wxMouseEvent &event) -{ - return PopupWindow::ProcessLeftDown(event); -} - -bool CameraPopup::Show(bool show) -{ - return PopupWindow::Show(show); -} - -void CameraPopup::OnSize(wxSizeEvent &event) -{ - event.Skip(); -} - -void CameraPopup::OnSetFocus(wxFocusEvent &event) -{ - event.Skip(); -} - -void CameraPopup::OnKillFocus(wxFocusEvent &event) -{ - event.Skip(); -} - -void CameraPopup::OnMouse(wxMouseEvent &event) -{ - event.Skip(); + m_panel->Layout(); + main_sizer->Fit(m_panel); + SetClientSize(m_panel->GetSize()); + Layout(); } CameraItem::CameraItem(wxWindow *parent, std::string normal, std::string hover) diff --git a/src/slic3r/GUI/CameraPopup.hpp b/src/slic3r/GUI/CameraPopup.hpp index dbdb81a9cb..af3961e9b4 100644 --- a/src/slic3r/GUI/CameraPopup.hpp +++ b/src/slic3r/GUI/CameraPopup.hpp @@ -14,8 +14,9 @@ #include #include "Widgets/SwitchButton.hpp" #include "Widgets/RadioBox.hpp" -#include "Widgets/PopupWindow.hpp" -#include "Widgets/TextInput.hpp" +#include "GUI_Utils.hpp" + +class Label; namespace Slic3r { namespace GUI { @@ -24,18 +25,12 @@ wxDECLARE_EVENT(EVT_VCAMERA_SWITCH, wxMouseEvent); wxDECLARE_EVENT(EVT_SDCARD_ABSENT_HINT, wxCommandEvent); wxDECLARE_EVENT(EVT_CAM_SOURCE_CHANGE, wxCommandEvent); -class CameraPopup : public PopupWindow +class CameraPopup : public DPIDialog { public: CameraPopup(wxWindow *parent); virtual ~CameraPopup() {} - // PopupWindow virtual methods are all overridden to log them - virtual void Popup(wxWindow *focus = NULL) wxOVERRIDE; - virtual void OnDismiss() wxOVERRIDE; - virtual bool ProcessLeftDown(wxMouseEvent &event) wxOVERRIDE; - virtual bool Show(bool show = true) wxOVERRIDE; - void sync_vcamera_state(bool show_vcamera); void check_func_supported(MachineObject* obj); void update(bool vcamera_streaming); @@ -50,12 +45,13 @@ public: void rescale(); protected: + void on_dpi_changed(const wxRect& suggested_rect) override; void on_switch_recording(wxCommandEvent& event); void on_set_resolution(); void sdcard_absent_hint(); - void on_camera_source_changed(wxCommandEvent& event); - void handle_camera_source_change(); - void set_custom_cam_button_state(bool state); + void on_custom_camera_switch_toggled(wxCommandEvent& event); + void update_custom_camera_switch(); + void on_manage_cameras_clicked(wxMouseEvent& event); wxWindow * create_item_radiobox(wxString title, wxWindow *parent, wxString tooltip, int padding_left); void select_curr_radiobox(int btn_idx); @@ -66,8 +62,6 @@ protected: private: MachineObject* m_obj { nullptr }; - wxTimer* m_interval_timer{nullptr}; - bool m_is_in_interval{ false }; wxStaticText* m_text_recording; SwitchButton* m_switch_recording; wxStaticText* m_text_vcamera; @@ -75,9 +69,8 @@ private: wxStaticText* m_text_liveview_retry; SwitchButton* m_switch_liveview_retry; wxStaticText* m_custom_camera_hint; - TextInput* m_custom_camera_input; - Button* m_custom_camera_input_confirm; - bool m_custom_camera_enabled{ false }; + SwitchButton* m_switch_custom_camera; + Label* m_manage_cameras_link{nullptr}; wxStaticText* m_text_resolution; wxWindow* m_resolution_options[RESOLUTION_OPTIONS_NUM]; wxScrolledWindow *m_panel; @@ -89,18 +82,6 @@ private: wxPanel* link_underline{ nullptr }; bool is_vcamera_show = false; bool allow_alter_resolution = false; - - void start_interval(); - void stop_interval(wxTimerEvent& event); - void OnMouse(wxMouseEvent &event); - void OnSize(wxSizeEvent &event); - void OnSetFocus(wxFocusEvent &event); - void OnKillFocus(wxFocusEvent &event); - void OnLeftUp(wxMouseEvent& event); - -private: - wxDECLARE_ABSTRACT_CLASS(CameraPopup); - wxDECLARE_EVENT_TABLE(); }; diff --git a/src/slic3r/GUI/NativeMediaCtrl.cpp b/src/slic3r/GUI/NativeMediaCtrl.cpp new file mode 100644 index 0000000000..a3b7cf11cb --- /dev/null +++ b/src/slic3r/GUI/NativeMediaCtrl.cpp @@ -0,0 +1,177 @@ +#include "wx/wxprec.h" +#ifndef WX_PRECOMP +#include "wx/wx.h" +#endif + +#include "NativeMediaCtrl.h" +#include +#include +#include + +wxDEFINE_EVENT(EVT_NATIVE_MEDIA_STATE_CHANGED, wxCommandEvent); +wxDEFINE_EVENT(EVT_NATIVE_MEDIA_ERROR, wxCommandEvent); +wxDEFINE_EVENT(EVT_NATIVE_MEDIA_SIZE_CHANGED, wxCommandEvent); + +namespace Slic3r { namespace GUI { + +StreamCredentials StreamCredentials::Parse(const std::string& url) +{ + StreamCredentials creds; + + std::regex url_regex(R"(^(\w+)://(?:([^:@]+)(?::([^@]*))?@)?([^/:]+)(?::(\d+))?(/.*)?$)"); + std::smatch match; + + if (std::regex_match(url, match, url_regex)) { + creds.scheme = match[1].str(); + creds.username = match[2].str(); + creds.password = match[3].str(); + creds.host = match[4].str(); + if (match[5].matched) { + creds.port = std::stoi(match[5].str()); + } + creds.path = match[6].matched ? match[6].str() : "/"; + } + + return creds; +} + +std::string StreamCredentials::BuildUrlWithoutCredentials() const +{ + std::string result = scheme + "://" + host; + if (port > 0) { + result += ":" + std::to_string(port); + } + result += path; + return result; +} + +StreamType NativeMediaCtrl::DetectStreamType(const wxString& url) +{ + std::string url_lower = url.Lower().ToStdString(); + + if (url_lower.find("rtsp://") == 0) { + return StreamType::RTSP; + } + if (url_lower.find("rtsps://") == 0) { + return StreamType::RTSPS; + } + + bool is_https = url_lower.find("https://") == 0; + bool is_http = url_lower.find("http://") == 0; + + if (!is_http && !is_https) { + return StreamType::Unknown; + } + + size_t path_start = url_lower.find('/', is_https ? 8 : 7); + std::string path = (path_start != std::string::npos) ? url_lower.substr(path_start) : "/"; + + size_t query_pos = path.find('?'); + if (query_pos != std::string::npos) { + path = path.substr(0, query_pos); + } + + if (path.length() >= 5) { + std::string ext = path.substr(path.length() - 5); + if (ext == ".mjpg" || (path.length() >= 6 && path.substr(path.length() - 6) == ".mjpeg")) { + return is_https ? StreamType::MJPEG_HTTPS : StreamType::MJPEG_HTTP; + } + } + + if (path.find("/axis-cgi/mjpg") != std::string::npos || + path.find("/cgi-bin/mjpg") != std::string::npos || + path.find("/mjpg/") != std::string::npos || + path.find("/mjpeg/") != std::string::npos) { + return is_https ? StreamType::MJPEG_HTTPS : StreamType::MJPEG_HTTP; + } + + return is_https ? StreamType::HTTPS_VIDEO : StreamType::HTTP_VIDEO; +} + +bool NativeMediaCtrl::IsSupported(const wxString& url) +{ + StreamType type = DetectStreamType(url); + return type != StreamType::Unknown; +} + +wxString NativeMediaCtrl::StreamTypeToString(StreamType type) +{ + switch (type) { + case StreamType::RTSP: return "RTSP"; + case StreamType::RTSPS: return "RTSPS (TLS)"; + case StreamType::MJPEG_HTTP: return "MJPEG (HTTP)"; + case StreamType::MJPEG_HTTPS: return "MJPEG (HTTPS)"; + case StreamType::HTTP_VIDEO: return "HTTP Video"; + case StreamType::HTTPS_VIDEO: return "HTTPS Video"; + default: return "Unknown"; + } +} + +wxString NativeMediaCtrl::GetLastErrorMessage() const +{ + NativeMediaError err = GetLastError(); + switch (err) { + case NativeMediaError::None: return ""; + case NativeMediaError::NetworkUnreachable: return "Network unreachable"; + case NativeMediaError::AuthenticationFailed: return "Authentication failed"; + case NativeMediaError::StreamNotFound: return "Stream not found"; + case NativeMediaError::UnsupportedFormat: return "Unsupported format"; + case NativeMediaError::DecoderError: return "Decoder error"; + case NativeMediaError::ConnectionTimeout: return "Connection timeout"; + case NativeMediaError::TLSError: return "TLS/SSL error"; + case NativeMediaError::InternalError: return "Internal error"; + default: return "Unknown error"; + } +} + +void NativeMediaCtrl::SetRetryEnabled(bool enabled) +{ + m_retry_enabled = enabled; + if (!enabled) { + m_retry_timer.Stop(); + } +} + +bool NativeMediaCtrl::IsRetryEnabled() const +{ + return m_retry_enabled; +} + +void NativeMediaCtrl::OnRetryTimer(wxTimerEvent& event) +{ + if (m_retry_count >= MAX_RETRIES) { + BOOST_LOG_TRIVIAL(warning) << "NativeMediaCtrl: Max retries reached, giving up"; + wxCommandEvent error_event(EVT_NATIVE_MEDIA_ERROR); + error_event.SetEventObject(this); + error_event.SetInt(static_cast(NativeMediaError::ConnectionTimeout)); + wxPostEvent(this, error_event); + return; + } + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl: Retry attempt " << (m_retry_count + 1) << " of " << MAX_RETRIES; + m_retry_count++; + + if (!m_current_url.empty()) { + Load(m_current_url); + Play(); + } +} + +void NativeMediaCtrl::ScheduleRetry() +{ + if (!m_retry_enabled || m_retry_count >= MAX_RETRIES) { + return; + } + + int delay = BASE_RETRY_DELAY_MS * (1 << std::min(m_retry_count, 4)); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl: Scheduling retry in " << delay << "ms"; + m_retry_timer.StartOnce(delay); +} + +void NativeMediaCtrl::ResetRetryState() +{ + m_retry_count = 0; + m_retry_timer.Stop(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/NativeMediaCtrl.h b/src/slic3r/GUI/NativeMediaCtrl.h new file mode 100644 index 0000000000..6e1350288e --- /dev/null +++ b/src/slic3r/GUI/NativeMediaCtrl.h @@ -0,0 +1,106 @@ +#ifndef NativeMediaCtrl_h +#define NativeMediaCtrl_h + +#include +#include +#include +#include +#include +#include + +wxDECLARE_EVENT(EVT_NATIVE_MEDIA_STATE_CHANGED, wxCommandEvent); +wxDECLARE_EVENT(EVT_NATIVE_MEDIA_ERROR, wxCommandEvent); +wxDECLARE_EVENT(EVT_NATIVE_MEDIA_SIZE_CHANGED, wxCommandEvent); + +namespace Slic3r { namespace GUI { + +enum class NativeMediaState { + Stopped, + Loading, + Playing, + Paused, + Error +}; + +enum class StreamType { + Unknown, + RTSP, + RTSPS, + MJPEG_HTTP, + MJPEG_HTTPS, + HTTP_VIDEO, + HTTPS_VIDEO +}; + +enum class NativeMediaError { + None = 0, + NetworkUnreachable = 1, + AuthenticationFailed = 2, + StreamNotFound = 3, + UnsupportedFormat = 4, + DecoderError = 5, + ConnectionTimeout = 6, + TLSError = 7, + InternalError = 99 +}; + +struct StreamCredentials { + std::string username; + std::string password; + std::string host; + int port{0}; + std::string path; + std::string scheme; + + static StreamCredentials Parse(const std::string& url); + std::string BuildUrlWithoutCredentials() const; + bool HasCredentials() const { return !username.empty(); } +}; + +class NativeMediaCtrl : public wxWindow +{ +public: + NativeMediaCtrl(wxWindow* parent); + ~NativeMediaCtrl() override; + + bool Load(const wxString& url); + void Play(); + void Stop(); + void Pause(); + + NativeMediaState GetState() const; + wxSize GetVideoSize() const; + + NativeMediaError GetLastError() const; + wxString GetLastErrorMessage() const; + + void SetRetryEnabled(bool enabled); + bool IsRetryEnabled() const; + + static StreamType DetectStreamType(const wxString& url); + static bool IsSupported(const wxString& url); + static wxString StreamTypeToString(StreamType type); + +protected: + void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; + +private: + class Impl; + std::unique_ptr m_impl; + + void OnRetryTimer(wxTimerEvent& event); + void ScheduleRetry(); + void ResetRetryState(); + + wxTimer m_retry_timer; + int m_retry_count{0}; + bool m_retry_enabled{true}; + wxString m_current_url; + + static constexpr int MAX_RETRIES = 5; + static constexpr int BASE_RETRY_DELAY_MS = 2000; +}; + +}} // namespace Slic3r::GUI + +#endif // NativeMediaCtrl_h diff --git a/src/slic3r/GUI/NativeMediaCtrl_Linux.cpp b/src/slic3r/GUI/NativeMediaCtrl_Linux.cpp new file mode 100644 index 0000000000..2b0b5bf2cc --- /dev/null +++ b/src/slic3r/GUI/NativeMediaCtrl_Linux.cpp @@ -0,0 +1,471 @@ +#include "wx/wxprec.h" +#ifndef WX_PRECOMP +#include "wx/wx.h" +#endif + +#include "NativeMediaCtrl.h" +#include + +#ifdef __linux__ + +#include +#include +#include + +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#ifdef GDK_WINDOWING_WAYLAND +#include +#endif + +namespace Slic3r { namespace GUI { + +class NativeMediaCtrl::Impl +{ +public: + Impl(NativeMediaCtrl* owner, GtkWidget* widget); + ~Impl(); + + bool Load(const wxString& url); + void Play(); + void Stop(); + void Pause(); + + NativeMediaState GetState() const { return m_state; } + wxSize GetVideoSize() const { return m_video_size; } + NativeMediaError GetLastError() const { return m_error; } + + void UpdateLayout(int width, int height); + +private: + void SetupPipeline(const wxString& url); + void CleanupPipeline(); + static GstBusSyncReply BusSyncHandler(GstBus* bus, GstMessage* message, gpointer user_data); + static gboolean BusCallback(GstBus* bus, GstMessage* message, gpointer user_data); + void HandleBusMessage(GstMessage* message); + void NotifyStateChanged(); + NativeMediaError MapGstErrorToError(GError* error); + std::string BuildPipelineForRtsp(const std::string& url, const StreamCredentials& creds); + std::string BuildPipelineForMjpeg(const std::string& url, const StreamCredentials& creds); + + NativeMediaCtrl* m_owner; + GtkWidget* m_widget; + GstElement* m_pipeline; + gulong m_bus_watch_id; + + NativeMediaState m_state; + NativeMediaError m_error; + wxSize m_video_size; + wxString m_url; + guintptr m_window_handle; +}; + +NativeMediaCtrl::Impl::Impl(NativeMediaCtrl* owner, GtkWidget* widget) + : m_owner(owner) + , m_widget(widget) + , m_pipeline(nullptr) + , m_bus_watch_id(0) + , m_state(NativeMediaState::Stopped) + , m_error(NativeMediaError::None) + , m_video_size(1920, 1080) + , m_window_handle(0) +{ + if (!gst_is_initialized()) { + gst_init(nullptr, nullptr); + } + + GdkWindow* gdk_window = gtk_widget_get_window(m_widget); + if (gdk_window) { +#ifdef GDK_WINDOWING_X11 + if (GDK_IS_X11_WINDOW(gdk_window)) { + m_window_handle = GDK_WINDOW_XID(gdk_window); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: Using X11 window handle"; + } +#endif +#ifdef GDK_WINDOWING_WAYLAND + if (GDK_IS_WAYLAND_WINDOW(gdk_window)) { + struct wl_surface* wl_surface = gdk_wayland_window_get_wl_surface(gdk_window); + m_window_handle = reinterpret_cast(wl_surface); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: Using Wayland surface handle"; + } +#endif + } + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: GStreamer implementation initialized"; +} + +NativeMediaCtrl::Impl::~Impl() +{ + CleanupPipeline(); +} + +void NativeMediaCtrl::Impl::CleanupPipeline() +{ + if (m_bus_watch_id) { + g_source_remove(m_bus_watch_id); + m_bus_watch_id = 0; + } + + if (m_pipeline) { + gst_element_set_state(m_pipeline, GST_STATE_NULL); + gst_object_unref(m_pipeline); + m_pipeline = nullptr; + } +} + +bool NativeMediaCtrl::Impl::Load(const wxString& url) +{ + CleanupPipeline(); + m_url = url; + m_error = NativeMediaError::None; + m_state = NativeMediaState::Loading; + NotifyStateChanged(); + + SetupPipeline(url); + return m_pipeline != nullptr; +} + +std::string NativeMediaCtrl::Impl::BuildPipelineForRtsp(const std::string& url, const StreamCredentials& creds) +{ + std::string pipeline; + std::string clean_url = creds.HasCredentials() ? creds.BuildUrlWithoutCredentials() : url; + + pipeline = "rtspsrc location=\"" + clean_url + "\" latency=100 buffer-mode=auto "; + pipeline += "protocols=tcp+udp-mcast+udp "; + pipeline += "tcp-timeout=5000000 "; + + if (creds.HasCredentials()) { + pipeline += "user-id=\"" + creds.username + "\" "; + pipeline += "user-pw=\"" + creds.password + "\" "; + } + + pipeline += "! decodebin ! videoconvert ! videoscale ! "; + pipeline += "video/x-raw,format=BGRx ! "; + pipeline += "autovideosink name=sink sync=false"; + + return pipeline; +} + +std::string NativeMediaCtrl::Impl::BuildPipelineForMjpeg(const std::string& url, const StreamCredentials& creds) +{ + std::string pipeline; + std::string clean_url = creds.HasCredentials() ? creds.BuildUrlWithoutCredentials() : url; + + pipeline = "souphttpsrc location=\"" + clean_url + "\" "; + pipeline += "is-live=true do-timestamp=true "; + + if (creds.HasCredentials()) { + pipeline += "user-id=\"" + creds.username + "\" "; + pipeline += "user-pw=\"" + creds.password + "\" "; + } + + pipeline += "! multipartdemux ! jpegdec ! videoconvert ! "; + pipeline += "video/x-raw,format=BGRx ! "; + pipeline += "autovideosink name=sink sync=false"; + + return pipeline; +} + +void NativeMediaCtrl::Impl::SetupPipeline(const wxString& url) +{ + StreamType type = NativeMediaCtrl::DetectStreamType(url); + std::string url_str = url.ToStdString(); + StreamCredentials creds = StreamCredentials::Parse(url_str); + + std::string pipeline_desc; + + if (type == StreamType::RTSP || type == StreamType::RTSPS) { + pipeline_desc = BuildPipelineForRtsp(url_str, creds); + } else if (type == StreamType::MJPEG_HTTP || type == StreamType::MJPEG_HTTPS) { + pipeline_desc = BuildPipelineForMjpeg(url_str, creds); + } else { + pipeline_desc = "playbin uri=\"" + url_str + "\""; + } + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: Creating pipeline"; + + GError* error = nullptr; + m_pipeline = gst_parse_launch(pipeline_desc.c_str(), &error); + + if (error) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Linux: Pipeline creation failed: " << error->message; + m_error = NativeMediaError::InternalError; + g_error_free(error); + m_state = NativeMediaState::Error; + NotifyStateChanged(); + return; + } + + if (!m_pipeline) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Linux: Pipeline is null"; + m_error = NativeMediaError::InternalError; + m_state = NativeMediaState::Error; + NotifyStateChanged(); + return; + } + + GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(m_pipeline)); + gst_bus_set_sync_handler(bus, BusSyncHandler, this, nullptr); + m_bus_watch_id = gst_bus_add_watch(bus, BusCallback, this); + gst_object_unref(bus); + + GstElement* sink = gst_bin_get_by_name(GST_BIN(m_pipeline), "sink"); + if (sink) { + if (GST_IS_VIDEO_OVERLAY(sink) && m_window_handle != 0) { + gst_video_overlay_set_window_handle(GST_VIDEO_OVERLAY(sink), m_window_handle); + } + gst_object_unref(sink); + } +} + +GstBusSyncReply NativeMediaCtrl::Impl::BusSyncHandler(GstBus* bus, GstMessage* message, gpointer user_data) +{ + if (GST_MESSAGE_TYPE(message) != GST_MESSAGE_ELEMENT) + return GST_BUS_PASS; + + if (!gst_is_video_overlay_prepare_window_handle_message(message)) + return GST_BUS_PASS; + + Impl* self = static_cast(user_data); + + if (self->m_window_handle != 0) { + gst_video_overlay_set_window_handle(GST_VIDEO_OVERLAY(GST_MESSAGE_SRC(message)), self->m_window_handle); + } + + gst_message_unref(message); + return GST_BUS_DROP; +} + +gboolean NativeMediaCtrl::Impl::BusCallback(GstBus* bus, GstMessage* message, gpointer user_data) +{ + Impl* self = static_cast(user_data); + self->HandleBusMessage(message); + return TRUE; +} + +NativeMediaError NativeMediaCtrl::Impl::MapGstErrorToError(GError* error) +{ + if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_NOT_AUTHORIZED)) { + return NativeMediaError::AuthenticationFailed; + } else if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_NOT_FOUND)) { + return NativeMediaError::StreamNotFound; + } else if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_OPEN_READ) || + g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_OPEN_READ_WRITE)) { + return NativeMediaError::NetworkUnreachable; + } else if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_BUSY)) { + return NativeMediaError::ConnectionTimeout; + } else if (g_error_matches(error, GST_STREAM_ERROR, GST_STREAM_ERROR_CODEC_NOT_FOUND) || + g_error_matches(error, GST_STREAM_ERROR, GST_STREAM_ERROR_WRONG_TYPE)) { + return NativeMediaError::UnsupportedFormat; + } else if (g_error_matches(error, GST_STREAM_ERROR, GST_STREAM_ERROR_DECODE)) { + return NativeMediaError::DecoderError; + } + return NativeMediaError::InternalError; +} + +void NativeMediaCtrl::Impl::HandleBusMessage(GstMessage* message) +{ + switch (GST_MESSAGE_TYPE(message)) { + case GST_MESSAGE_ERROR: { + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(message, &err, &debug); + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Linux: Error: " << err->message; + if (debug) { + BOOST_LOG_TRIVIAL(debug) << "NativeMediaCtrl_Linux: Debug: " << debug; + g_free(debug); + } + + m_error = MapGstErrorToError(err); + g_error_free(err); + + m_state = NativeMediaState::Error; + NotifyStateChanged(); + + wxCommandEvent event(EVT_NATIVE_MEDIA_ERROR); + event.SetEventObject(m_owner); + event.SetInt(static_cast(m_error)); + wxPostEvent(m_owner, event); + + m_owner->ScheduleRetry(); + break; + } + + case GST_MESSAGE_EOS: + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: End of stream"; + m_state = NativeMediaState::Stopped; + NotifyStateChanged(); + break; + + case GST_MESSAGE_STATE_CHANGED: + if (GST_MESSAGE_SRC(message) == GST_OBJECT(m_pipeline)) { + GstState old_state, new_state; + gst_message_parse_state_changed(message, &old_state, &new_state, nullptr); + + if (new_state == GST_STATE_PLAYING && m_state != NativeMediaState::Playing) { + m_state = NativeMediaState::Playing; + m_owner->ResetRetryState(); + NotifyStateChanged(); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: Now playing"; + } else if (new_state == GST_STATE_PAUSED && m_state == NativeMediaState::Playing) { + m_state = NativeMediaState::Paused; + NotifyStateChanged(); + } + } + break; + + case GST_MESSAGE_STREAM_START: + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: Stream started"; + break; + + case GST_MESSAGE_BUFFERING: { + gint percent = 0; + gst_message_parse_buffering(message, &percent); + BOOST_LOG_TRIVIAL(debug) << "NativeMediaCtrl_Linux: Buffering " << percent << "%"; + break; + } + + default: + break; + } +} + +void NativeMediaCtrl::Impl::Play() +{ + if (m_pipeline) { + GstStateChangeReturn ret = gst_element_set_state(m_pipeline, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_FAILURE) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Linux: Failed to set pipeline to PLAYING"; + m_error = NativeMediaError::InternalError; + m_state = NativeMediaState::Error; + NotifyStateChanged(); + } else { + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: Play started"; + } + } +} + +void NativeMediaCtrl::Impl::Stop() +{ + if (m_pipeline) { + gst_element_set_state(m_pipeline, GST_STATE_NULL); + } + m_state = NativeMediaState::Stopped; + NotifyStateChanged(); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Linux: Stopped"; +} + +void NativeMediaCtrl::Impl::Pause() +{ + if (m_pipeline) { + gst_element_set_state(m_pipeline, GST_STATE_PAUSED); + } +} + +void NativeMediaCtrl::Impl::UpdateLayout(int width, int height) +{ + if (m_pipeline) { + GstElement* sink = gst_bin_get_by_name(GST_BIN(m_pipeline), "sink"); + if (sink) { + if (GST_IS_VIDEO_OVERLAY(sink)) { + gst_video_overlay_expose(GST_VIDEO_OVERLAY(sink)); + } + gst_object_unref(sink); + } + } +} + +void NativeMediaCtrl::Impl::NotifyStateChanged() +{ + wxCommandEvent event(EVT_NATIVE_MEDIA_STATE_CHANGED); + event.SetEventObject(m_owner); + event.SetInt(static_cast(m_state)); + wxPostEvent(m_owner, event); +} + +NativeMediaCtrl::NativeMediaCtrl(wxWindow* parent) + : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) + , m_retry_enabled(true) + , m_retry_count(0) +{ + SetBackgroundColour(*wxBLACK); + m_retry_timer.SetOwner(this); + Bind(wxEVT_TIMER, &NativeMediaCtrl::OnRetryTimer, this, m_retry_timer.GetId()); + + GtkWidget* widget = (GtkWidget*)GetHandle(); + m_impl = std::make_unique(this, widget); +} + +NativeMediaCtrl::~NativeMediaCtrl() +{ + m_retry_timer.Stop(); +} + +bool NativeMediaCtrl::Load(const wxString& url) +{ + if (!IsSupported(url)) { + BOOST_LOG_TRIVIAL(warning) << "NativeMediaCtrl: Unsupported URL format: " << url.ToStdString(); + return false; + } + + m_current_url = url; + ResetRetryState(); + + StreamType type = DetectStreamType(url); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl: Loading " << StreamTypeToString(type).ToStdString() + << " stream: " << url.ToStdString(); + + return m_impl->Load(url); +} + +void NativeMediaCtrl::Play() +{ + if (m_impl) { + m_impl->Play(); + } +} + +void NativeMediaCtrl::Stop() +{ + m_retry_timer.Stop(); + ResetRetryState(); + if (m_impl) { + m_impl->Stop(); + } +} + +void NativeMediaCtrl::Pause() +{ + if (m_impl) { + m_impl->Pause(); + } +} + +NativeMediaState NativeMediaCtrl::GetState() const +{ + return m_impl ? m_impl->GetState() : NativeMediaState::Stopped; +} + +wxSize NativeMediaCtrl::GetVideoSize() const +{ + return m_impl ? m_impl->GetVideoSize() : wxSize(1920, 1080); +} + +NativeMediaError NativeMediaCtrl::GetLastError() const +{ + return m_impl ? m_impl->GetLastError() : NativeMediaError::None; +} + +void NativeMediaCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) +{ + wxWindow::DoSetSize(x, y, width, height, sizeFlags); + if (m_impl && width > 0 && height > 0) { + m_impl->UpdateLayout(width, height); + } +} + +}} // namespace Slic3r::GUI + +#endif // __linux__ diff --git a/src/slic3r/GUI/NativeMediaCtrl_Mac.mm b/src/slic3r/GUI/NativeMediaCtrl_Mac.mm new file mode 100644 index 0000000000..10288ef31b --- /dev/null +++ b/src/slic3r/GUI/NativeMediaCtrl_Mac.mm @@ -0,0 +1,619 @@ +#include "wx/wxprec.h" +#ifndef WX_PRECOMP +#include "wx/wx.h" +#endif + +#import "NativeMediaCtrl.h" +#include + +#import +#import +#import + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +class NativeMediaCtrl::Impl +{ +public: + Impl(NativeMediaCtrl* owner, NSView* view); + ~Impl(); + + bool Load(const wxString& url); + void Play(); + void Stop(); + void Pause(); + + NativeMediaState GetState() const { return m_state; } + wxSize GetVideoSize() const { return m_video_size; } + NativeMediaError GetLastError() const { return m_error; } + + void UpdateLayout(int width, int height); + +private: + void SetupPipeline(const wxString& url); + void CleanupPipeline(); + static GstFlowReturn OnNewSample(GstAppSink* sink, gpointer user_data); + static gboolean BusCallback(GstBus* bus, GstMessage* message, gpointer user_data); + void HandleBusMessage(GstMessage* message); + void HandleNewSample(GstSample* sample); + void NotifyStateChanged(); + void NotifySizeChanged(); + NativeMediaError MapGstErrorToError(GError* error); + std::string BuildPipelineForRtsp(const std::string& url, const StreamCredentials& creds); + std::string BuildPipelineForMjpeg(const std::string& url, const StreamCredentials& creds); + + void UpdateStatusText(const std::string& text); + + NativeMediaCtrl* m_owner; + NSView* m_view; + CALayer* m_image_layer; + CATextLayer* m_status_layer; + GstElement* m_pipeline; + GstElement* m_appsink; + gulong m_bus_watch_id; + + NativeMediaState m_state; + NativeMediaError m_error; + wxSize m_video_size; + wxString m_url; +}; + +NativeMediaCtrl::Impl::Impl(NativeMediaCtrl* owner, NSView* view) + : m_owner(owner) + , m_view(view) + , m_image_layer(nil) + , m_status_layer(nil) + , m_pipeline(nullptr) + , m_appsink(nullptr) + , m_bus_watch_id(0) + , m_state(NativeMediaState::Stopped) + , m_error(NativeMediaError::None) + , m_video_size(1920, 1080) +{ + if (!gst_is_initialized()) { + gst_init(nullptr, nullptr); + } + + m_view.wantsLayer = YES; + m_view.layer = [[CALayer alloc] init]; + m_view.layer.backgroundColor = [[NSColor blackColor] CGColor]; + m_view.layer.masksToBounds = YES; + + m_image_layer = [[CALayer alloc] init]; + m_image_layer.contentsGravity = kCAGravityResizeAspect; + m_image_layer.backgroundColor = [[NSColor blackColor] CGColor]; + m_image_layer.frame = m_view.layer.bounds; + m_image_layer.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable; + [m_view.layer addSublayer:m_image_layer]; + + m_status_layer = [[CATextLayer alloc] init]; + m_status_layer.alignmentMode = kCAAlignmentCenter; + m_status_layer.foregroundColor = [[NSColor whiteColor] CGColor]; + m_status_layer.backgroundColor = [[NSColor colorWithWhite:0.0 alpha:0.7] CGColor]; + m_status_layer.cornerRadius = 8.0; + m_status_layer.fontSize = 14.0; + m_status_layer.contentsScale = [[NSScreen mainScreen] backingScaleFactor]; + m_status_layer.wrapped = YES; + m_status_layer.hidden = YES; + [m_view.layer addSublayer:m_status_layer]; + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: GStreamer implementation initialized"; +} + +NativeMediaCtrl::Impl::~Impl() +{ + CleanupPipeline(); +} + +void NativeMediaCtrl::Impl::CleanupPipeline() +{ + if (m_bus_watch_id) { + g_source_remove(m_bus_watch_id); + m_bus_watch_id = 0; + } + + if (m_pipeline) { + gst_element_set_state(m_pipeline, GST_STATE_NULL); + gst_object_unref(m_pipeline); + m_pipeline = nullptr; + } + m_appsink = nullptr; +} + +bool NativeMediaCtrl::Impl::Load(const wxString& url) +{ + CleanupPipeline(); + m_url = url; + m_error = NativeMediaError::None; + m_state = NativeMediaState::Loading; + UpdateStatusText("Connecting..."); + NotifyStateChanged(); + + SetupPipeline(url); + return m_pipeline != nullptr; +} + +std::string NativeMediaCtrl::Impl::BuildPipelineForRtsp(const std::string& url, const StreamCredentials& creds) +{ + std::string pipeline; + std::string clean_url = creds.HasCredentials() ? creds.BuildUrlWithoutCredentials() : url; + + pipeline = "rtspsrc location=\"" + clean_url + "\" latency=200 buffer-mode=auto "; + pipeline += "protocols=tcp+udp-mcast+udp "; + pipeline += "tcp-timeout=5000000 "; + + if (creds.HasCredentials()) { + pipeline += "user-id=\"" + creds.username + "\" "; + pipeline += "user-pw=\"" + creds.password + "\" "; + } + + pipeline += "! decodebin ! videoconvert ! videoscale ! "; + pipeline += "video/x-raw,format=BGRA ! "; + pipeline += "appsink name=sink emit-signals=true sync=false max-buffers=2 drop=true"; + + return pipeline; +} + +std::string NativeMediaCtrl::Impl::BuildPipelineForMjpeg(const std::string& url, const StreamCredentials& creds) +{ + std::string pipeline; + std::string clean_url = creds.HasCredentials() ? creds.BuildUrlWithoutCredentials() : url; + + pipeline = "souphttpsrc location=\"" + clean_url + "\" "; + pipeline += "is-live=true do-timestamp=true timeout=10 "; + + if (creds.HasCredentials()) { + pipeline += "user-id=\"" + creds.username + "\" "; + pipeline += "user-pw=\"" + creds.password + "\" "; + } + + pipeline += "! multipartdemux ! jpegdec ! videoconvert ! "; + pipeline += "video/x-raw,format=BGRA ! "; + pipeline += "appsink name=sink emit-signals=true sync=false max-buffers=2 drop=true"; + + return pipeline; +} + +void NativeMediaCtrl::Impl::SetupPipeline(const wxString& url) +{ + StreamType type = NativeMediaCtrl::DetectStreamType(url); + std::string url_str = url.ToStdString(); + StreamCredentials creds = StreamCredentials::Parse(url_str); + + std::string pipeline_desc; + + if (type == StreamType::RTSP || type == StreamType::RTSPS) { + pipeline_desc = BuildPipelineForRtsp(url_str, creds); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Setting up RTSP pipeline"; + } else if (type == StreamType::MJPEG_HTTP || type == StreamType::MJPEG_HTTPS) { + pipeline_desc = BuildPipelineForMjpeg(url_str, creds); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Setting up MJPEG pipeline"; + } else { + pipeline_desc = "playbin uri=\"" + url_str + "\" ! videoconvert ! video/x-raw,format=BGRA ! appsink name=sink emit-signals=true sync=false"; + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Setting up generic pipeline"; + } + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Pipeline: " << pipeline_desc; + + GError* error = nullptr; + m_pipeline = gst_parse_launch(pipeline_desc.c_str(), &error); + + if (error) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Mac: Pipeline creation failed: " << error->message; + m_error = NativeMediaError::InternalError; + g_error_free(error); + m_state = NativeMediaState::Error; + NotifyStateChanged(); + return; + } + + if (!m_pipeline) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Mac: Pipeline is null"; + m_error = NativeMediaError::InternalError; + m_state = NativeMediaState::Error; + NotifyStateChanged(); + return; + } + + m_appsink = gst_bin_get_by_name(GST_BIN(m_pipeline), "sink"); + if (m_appsink) { + GstAppSinkCallbacks callbacks = {nullptr, nullptr, OnNewSample, nullptr}; + gst_app_sink_set_callbacks(GST_APP_SINK(m_appsink), &callbacks, this, nullptr); + gst_object_unref(m_appsink); + } + + GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(m_pipeline)); + m_bus_watch_id = gst_bus_add_watch(bus, BusCallback, this); + gst_object_unref(bus); + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Pipeline created successfully"; +} + +GstFlowReturn NativeMediaCtrl::Impl::OnNewSample(GstAppSink* sink, gpointer user_data) +{ + Impl* impl = static_cast(user_data); + + GstSample* sample = gst_app_sink_pull_sample(sink); + if (sample) { + impl->HandleNewSample(sample); + gst_sample_unref(sample); + } + + return GST_FLOW_OK; +} + +void NativeMediaCtrl::Impl::HandleNewSample(GstSample* sample) +{ + GstCaps* caps = gst_sample_get_caps(sample); + if (!caps) return; + + GstStructure* structure = gst_caps_get_structure(caps, 0); + int width = 0, height = 0; + gst_structure_get_int(structure, "width", &width); + gst_structure_get_int(structure, "height", &height); + + if (width <= 0 || height <= 0) return; + + bool size_changed = (m_video_size.GetWidth() != width || m_video_size.GetHeight() != height); + + if (m_state == NativeMediaState::Loading) { + m_state = NativeMediaState::Playing; + m_video_size = wxSize(width, height); + m_owner->ResetRetryState(); + UpdateStatusText(""); + NotifyStateChanged(); + NotifySizeChanged(); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Now playing " << width << "x" << height; + } else if (size_changed) { + m_video_size = wxSize(width, height); + NotifySizeChanged(); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Video size changed to " << width << "x" << height; + } + + GstBuffer* buffer = gst_sample_get_buffer(sample); + if (!buffer) return; + + GstMapInfo map; + if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) return; + + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGContextRef context = CGBitmapContextCreate( + (void*)map.data, + width, height, + 8, + width * 4, + colorSpace, + kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little + ); + + if (context) { + CGImageRef image = CGBitmapContextCreateImage(context); + if (image) { + CALayer* layer = [m_image_layer retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (layer.superlayer != nil) { + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + layer.contents = (__bridge id)image; + [CATransaction commit]; + } + CGImageRelease(image); + [layer release]; + }); + } + CGContextRelease(context); + } + + CGColorSpaceRelease(colorSpace); + gst_buffer_unmap(buffer, &map); +} + +gboolean NativeMediaCtrl::Impl::BusCallback(GstBus* bus, GstMessage* message, gpointer user_data) +{ + Impl* impl = static_cast(user_data); + impl->HandleBusMessage(message); + return TRUE; +} + +NativeMediaError NativeMediaCtrl::Impl::MapGstErrorToError(GError* error) +{ + if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_NOT_AUTHORIZED)) { + return NativeMediaError::AuthenticationFailed; + } else if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_NOT_FOUND)) { + return NativeMediaError::StreamNotFound; + } else if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_OPEN_READ) || + g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_OPEN_READ_WRITE)) { + return NativeMediaError::NetworkUnreachable; + } else if (g_error_matches(error, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_BUSY)) { + return NativeMediaError::ConnectionTimeout; + } else if (g_error_matches(error, GST_STREAM_ERROR, GST_STREAM_ERROR_CODEC_NOT_FOUND) || + g_error_matches(error, GST_STREAM_ERROR, GST_STREAM_ERROR_WRONG_TYPE)) { + return NativeMediaError::UnsupportedFormat; + } else if (g_error_matches(error, GST_STREAM_ERROR, GST_STREAM_ERROR_DECODE)) { + return NativeMediaError::DecoderError; + } + return NativeMediaError::InternalError; +} + +void NativeMediaCtrl::Impl::HandleBusMessage(GstMessage* message) +{ + switch (GST_MESSAGE_TYPE(message)) { + case GST_MESSAGE_ERROR: { + GError* err = nullptr; + gchar* debug = nullptr; + gst_message_parse_error(message, &err, &debug); + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Mac: Error: " << err->message; + if (debug) { + BOOST_LOG_TRIVIAL(debug) << "NativeMediaCtrl_Mac: Debug: " << debug; + g_free(debug); + } + + m_error = MapGstErrorToError(err); + g_error_free(err); + + std::string error_msg; + switch (m_error) { + case NativeMediaError::NetworkUnreachable: error_msg = "Network unreachable"; break; + case NativeMediaError::AuthenticationFailed: error_msg = "Authentication failed"; break; + case NativeMediaError::StreamNotFound: error_msg = "Stream not found"; break; + case NativeMediaError::UnsupportedFormat: error_msg = "Unsupported format"; break; + case NativeMediaError::DecoderError: error_msg = "Decoder error"; break; + case NativeMediaError::ConnectionTimeout: error_msg = "Connection timeout"; break; + case NativeMediaError::TLSError: error_msg = "TLS/SSL error"; break; + default: error_msg = "Connection error"; break; + } + UpdateStatusText(error_msg + " - Retrying..."); + + m_state = NativeMediaState::Error; + NotifyStateChanged(); + + wxCommandEvent event(EVT_NATIVE_MEDIA_ERROR); + event.SetEventObject(m_owner); + event.SetInt(static_cast(m_error)); + wxPostEvent(m_owner, event); + + m_owner->ScheduleRetry(); + break; + } + + case GST_MESSAGE_EOS: + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: End of stream"; + m_state = NativeMediaState::Stopped; + NotifyStateChanged(); + break; + + case GST_MESSAGE_STATE_CHANGED: + if (GST_MESSAGE_SRC(message) == GST_OBJECT(m_pipeline)) { + GstState old_state, new_state; + gst_message_parse_state_changed(message, &old_state, &new_state, nullptr); + BOOST_LOG_TRIVIAL(debug) << "NativeMediaCtrl_Mac: State changed from " + << gst_element_state_get_name(old_state) << " to " + << gst_element_state_get_name(new_state); + } + break; + + case GST_MESSAGE_STREAM_START: + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Stream started"; + break; + + case GST_MESSAGE_BUFFERING: { + gint percent = 0; + gst_message_parse_buffering(message, &percent); + BOOST_LOG_TRIVIAL(debug) << "NativeMediaCtrl_Mac: Buffering " << percent << "%"; + break; + } + + default: + break; + } +} + +void NativeMediaCtrl::Impl::Play() +{ + if (m_pipeline) { + GstStateChangeReturn ret = gst_element_set_state(m_pipeline, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_FAILURE) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Mac: Failed to set pipeline to PLAYING"; + m_error = NativeMediaError::InternalError; + m_state = NativeMediaState::Error; + NotifyStateChanged(); + } else { + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Play started"; + } + } +} + +void NativeMediaCtrl::Impl::Stop() +{ + if (m_pipeline) { + gst_element_set_state(m_pipeline, GST_STATE_NULL); + } + m_state = NativeMediaState::Stopped; + NotifyStateChanged(); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Stopped"; +} + +void NativeMediaCtrl::Impl::Pause() +{ + if (m_pipeline) { + gst_element_set_state(m_pipeline, GST_STATE_PAUSED); + } +} + +void NativeMediaCtrl::Impl::UpdateLayout(int width, int height) +{ + CALayer* imageLayer = [m_image_layer retain]; + CATextLayer* statusLayer = [m_status_layer retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (imageLayer.superlayer == nil || statusLayer.superlayer == nil) { + [imageLayer release]; + [statusLayer release]; + return; + } + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + + CGRect bounds = CGRectMake(0, 0, width, height); + imageLayer.frame = bounds; + + if (!statusLayer.hidden) { + CGFloat labelWidth = fmin(width - 40, 300); + CGFloat labelHeight = 36; + CGFloat x = (width - labelWidth) / 2.0; + CGFloat y = (height - labelHeight) / 2.0; + statusLayer.frame = CGRectMake(x, y, labelWidth, labelHeight); + } + + [CATransaction commit]; + [imageLayer release]; + [statusLayer release]; + }); +} + +void NativeMediaCtrl::Impl::NotifyStateChanged() +{ + wxCommandEvent event(EVT_NATIVE_MEDIA_STATE_CHANGED); + event.SetEventObject(m_owner); + event.SetInt(static_cast(m_state)); + wxPostEvent(m_owner, event); +} + +void NativeMediaCtrl::Impl::NotifySizeChanged() +{ + wxCommandEvent event(EVT_NATIVE_MEDIA_SIZE_CHANGED); + event.SetEventObject(m_owner); + wxPostEvent(m_owner, event); +} + +void NativeMediaCtrl::Impl::UpdateStatusText(const std::string& text) +{ + std::string captured_text = text; + CATextLayer* statusLayer = [m_status_layer retain]; + NSView* view = [m_view retain]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (statusLayer.superlayer == nil) { + [statusLayer release]; + [view release]; + return; + } + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + if (captured_text.empty()) { + statusLayer.string = @""; + statusLayer.hidden = YES; + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Status text cleared"; + } else { + NSString* nsText = [NSString stringWithUTF8String:captured_text.c_str()]; + statusLayer.string = nsText; + statusLayer.hidden = NO; + + CGRect bounds = view.layer.bounds; + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: View bounds: " << bounds.size.width << "x" << bounds.size.height; + + CGFloat labelWidth = 250; + CGFloat labelHeight = 36; + if (bounds.size.width > 40 && bounds.size.height > 40) { + labelWidth = fmin(bounds.size.width - 40, 300); + CGFloat x = (bounds.size.width - labelWidth) / 2.0; + CGFloat y = (bounds.size.height - labelHeight) / 2.0; + statusLayer.frame = CGRectMake(x, y, labelWidth, labelHeight); + } else { + statusLayer.frame = CGRectMake(20, 20, labelWidth, labelHeight); + } + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Mac: Status text set to '" << captured_text << "'"; + } + [CATransaction commit]; + [statusLayer release]; + [view release]; + }); +} + +NativeMediaCtrl::NativeMediaCtrl(wxWindow* parent) + : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) + , m_retry_enabled(true) + , m_retry_count(0) +{ + SetBackgroundColour(*wxBLACK); + m_retry_timer.SetOwner(this); + Bind(wxEVT_TIMER, &NativeMediaCtrl::OnRetryTimer, this, m_retry_timer.GetId()); + + NSView* view = (NSView*)GetHandle(); + m_impl = std::make_unique(this, view); +} + +NativeMediaCtrl::~NativeMediaCtrl() +{ + m_retry_timer.Stop(); +} + +bool NativeMediaCtrl::Load(const wxString& url) +{ + if (!IsSupported(url)) { + BOOST_LOG_TRIVIAL(warning) << "NativeMediaCtrl: Unsupported URL format: " << url.ToStdString(); + return false; + } + + m_current_url = url; + ResetRetryState(); + + StreamType type = DetectStreamType(url); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl: Loading " << StreamTypeToString(type).ToStdString() + << " stream: " << url.ToStdString(); + + return m_impl->Load(url); +} + +void NativeMediaCtrl::Play() +{ + if (m_impl) { + m_impl->Play(); + } +} + +void NativeMediaCtrl::Stop() +{ + m_retry_timer.Stop(); + ResetRetryState(); + if (m_impl) { + m_impl->Stop(); + } +} + +void NativeMediaCtrl::Pause() +{ + if (m_impl) { + m_impl->Pause(); + } +} + +NativeMediaState NativeMediaCtrl::GetState() const +{ + return m_impl ? m_impl->GetState() : NativeMediaState::Stopped; +} + +wxSize NativeMediaCtrl::GetVideoSize() const +{ + return m_impl ? m_impl->GetVideoSize() : wxSize(1920, 1080); +} + +NativeMediaError NativeMediaCtrl::GetLastError() const +{ + return m_impl ? m_impl->GetLastError() : NativeMediaError::None; +} + +void NativeMediaCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) +{ + wxWindow::DoSetSize(x, y, width, height, sizeFlags); + if (m_impl && width > 0 && height > 0) { + m_impl->UpdateLayout(width, height); + } +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/NativeMediaCtrl_Win.cpp b/src/slic3r/GUI/NativeMediaCtrl_Win.cpp new file mode 100644 index 0000000000..8c51a9a6ad --- /dev/null +++ b/src/slic3r/GUI/NativeMediaCtrl_Win.cpp @@ -0,0 +1,876 @@ +#include "wx/wxprec.h" +#ifndef WX_PRECOMP +#include "wx/wx.h" +#endif + +#include "NativeMediaCtrl.h" +#include + +#ifdef _WIN32 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "mfplat.lib") +#pragma comment(lib, "mf.lib") +#pragma comment(lib, "mfreadwrite.lib") +#pragma comment(lib, "mfuuid.lib") +#pragma comment(lib, "d3d11.lib") +#pragma comment(lib, "dxgi.lib") +#pragma comment(lib, "d3dcompiler.lib") + +using Microsoft::WRL::ComPtr; + +namespace Slic3r { namespace GUI { + +class NativeMediaCtrl::Impl +{ +public: + Impl(NativeMediaCtrl* owner, HWND hwnd); + ~Impl(); + + bool Load(const wxString& url); + void Play(); + void Stop(); + void Pause(); + + NativeMediaState GetState() const { return m_state.load(); } + wxSize GetVideoSize() const { return m_video_size; } + NativeMediaError GetLastError() const { return m_error; } + + void UpdateLayout(int width, int height); + +private: + bool InitializeMediaFoundation(); + void ShutdownMediaFoundation(); + bool CreateMediaSource(const wxString& url); + bool CreateSourceReader(); + bool CreateRenderingResources(); + bool CreateVideoTexture(UINT width, UINT height, DXGI_FORMAT format); + void RenderVideoFrame(const BYTE* data, DWORD length); + void RenderLoop(); + void CleanupResources(); + void NotifyStateChanged(NativeMediaState state); + void NotifyError(NativeMediaError error); + NativeMediaError MapHResultToError(HRESULT hr); + + NativeMediaCtrl* m_owner; + HWND m_hwnd; + std::atomic m_state; + NativeMediaError m_error; + wxSize m_video_size; + wxString m_url; + + ComPtr m_media_source; + ComPtr m_source_reader; + ComPtr m_d3d_device; + ComPtr m_d3d_context; + ComPtr m_swap_chain; + ComPtr m_render_target; + + ComPtr m_video_texture; + ComPtr m_video_srv; + ComPtr m_vertex_shader; + ComPtr m_pixel_shader; + ComPtr m_vertex_buffer; + ComPtr m_sampler_state; + ComPtr m_input_layout; + + std::thread m_render_thread; + std::atomic m_running; + std::atomic m_paused; + std::mutex m_mutex; + bool m_mf_initialized; + + int m_width; + int m_height; + int m_video_width; + int m_video_height; + GUID m_video_format; +}; + +NativeMediaCtrl::Impl::Impl(NativeMediaCtrl* owner, HWND hwnd) + : m_owner(owner) + , m_hwnd(hwnd) + , m_state(NativeMediaState::Stopped) + , m_error(NativeMediaError::None) + , m_video_size(1920, 1080) + , m_running(false) + , m_paused(false) + , m_mf_initialized(false) + , m_width(640) + , m_height(480) + , m_video_width(0) + , m_video_height(0) + , m_video_format(GUID_NULL) +{ + m_mf_initialized = InitializeMediaFoundation(); + if (m_mf_initialized) { + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Win: Media Foundation initialized"; + } else { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Failed to initialize Media Foundation"; + } +} + +NativeMediaCtrl::Impl::~Impl() +{ + Stop(); + ShutdownMediaFoundation(); +} + +bool NativeMediaCtrl::Impl::InitializeMediaFoundation() +{ + HRESULT hr = MFStartup(MF_VERSION); + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: MFStartup failed: " << std::hex << hr; + return false; + } + + D3D_FEATURE_LEVEL feature_levels[] = { + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, + D3D_FEATURE_LEVEL_10_0 + }; + + UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT; +#ifdef _DEBUG + flags |= D3D11_CREATE_DEVICE_DEBUG; +#endif + + hr = D3D11CreateDevice( + nullptr, + D3D_DRIVER_TYPE_HARDWARE, + nullptr, + flags, + feature_levels, + ARRAYSIZE(feature_levels), + D3D11_SDK_VERSION, + &m_d3d_device, + nullptr, + &m_d3d_context + ); + + if (FAILED(hr)) { + hr = D3D11CreateDevice( + nullptr, + D3D_DRIVER_TYPE_WARP, + nullptr, + flags, + feature_levels, + ARRAYSIZE(feature_levels), + D3D11_SDK_VERSION, + &m_d3d_device, + nullptr, + &m_d3d_context + ); + } + + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: D3D11CreateDevice failed: " << std::hex << hr; + return false; + } + + ComPtr dxgi_device; + hr = m_d3d_device.As(&dxgi_device); + if (FAILED(hr)) return false; + + ComPtr adapter; + hr = dxgi_device->GetAdapter(&adapter); + if (FAILED(hr)) return false; + + ComPtr factory; + hr = adapter->GetParent(IID_PPV_ARGS(&factory)); + if (FAILED(hr)) return false; + + RECT rect; + GetClientRect(m_hwnd, &rect); + m_width = std::max(1L, rect.right - rect.left); + m_height = std::max(1L, rect.bottom - rect.top); + + DXGI_SWAP_CHAIN_DESC1 swap_chain_desc = {}; + swap_chain_desc.Width = m_width; + swap_chain_desc.Height = m_height; + swap_chain_desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + swap_chain_desc.SampleDesc.Count = 1; + swap_chain_desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + swap_chain_desc.BufferCount = 2; + swap_chain_desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL; + + hr = factory->CreateSwapChainForHwnd( + m_d3d_device.Get(), + m_hwnd, + &swap_chain_desc, + nullptr, + nullptr, + &m_swap_chain + ); + + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: CreateSwapChain failed: " << std::hex << hr; + return false; + } + + ComPtr back_buffer; + hr = m_swap_chain->GetBuffer(0, IID_PPV_ARGS(&back_buffer)); + if (FAILED(hr)) return false; + + hr = m_d3d_device->CreateRenderTargetView(back_buffer.Get(), nullptr, &m_render_target); + if (FAILED(hr)) return false; + + if (!CreateRenderingResources()) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Failed to create rendering resources"; + return false; + } + + return true; +} + +bool NativeMediaCtrl::Impl::CreateRenderingResources() +{ + const char* vertex_shader_code = R"( + struct VS_OUTPUT { + float4 position : SV_POSITION; + float2 texcoord : TEXCOORD0; + }; + + VS_OUTPUT main(uint id : SV_VertexID) { + VS_OUTPUT output; + float2 positions[4] = { + float2(-1.0, -1.0), + float2(-1.0, 1.0), + float2( 1.0, -1.0), + float2( 1.0, 1.0) + }; + float2 texcoords[4] = { + float2(0.0, 1.0), + float2(0.0, 0.0), + float2(1.0, 1.0), + float2(1.0, 0.0) + }; + output.position = float4(positions[id], 0.0, 1.0); + output.texcoord = texcoords[id]; + return output; + } + )"; + + const char* pixel_shader_code = R"( + Texture2D videoTexture : register(t0); + SamplerState videoSampler : register(s0); + + struct PS_INPUT { + float4 position : SV_POSITION; + float2 texcoord : TEXCOORD0; + }; + + float4 main(PS_INPUT input) : SV_TARGET { + float4 color = videoTexture.Sample(videoSampler, input.texcoord); + return float4(color.bgr, color.a); + } + )"; + + ComPtr vs_blob; + ComPtr error_blob; + HRESULT hr = D3DCompile( + vertex_shader_code, + strlen(vertex_shader_code), + nullptr, + nullptr, + nullptr, + "main", + "vs_5_0", + 0, + 0, + &vs_blob, + &error_blob + ); + + if (FAILED(hr)) { + if (error_blob) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Vertex shader compilation failed: " + << (char*)error_blob->GetBufferPointer(); + } + return false; + } + + hr = m_d3d_device->CreateVertexShader( + vs_blob->GetBufferPointer(), + vs_blob->GetBufferSize(), + nullptr, + &m_vertex_shader + ); + + if (FAILED(hr)) return false; + + ComPtr ps_blob; + hr = D3DCompile( + pixel_shader_code, + strlen(pixel_shader_code), + nullptr, + nullptr, + nullptr, + "main", + "ps_5_0", + 0, + 0, + &ps_blob, + &error_blob + ); + + if (FAILED(hr)) { + if (error_blob) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Pixel shader compilation failed: " + << (char*)error_blob->GetBufferPointer(); + } + return false; + } + + hr = m_d3d_device->CreatePixelShader( + ps_blob->GetBufferPointer(), + ps_blob->GetBufferSize(), + nullptr, + &m_pixel_shader + ); + + if (FAILED(hr)) return false; + + D3D11_SAMPLER_DESC sampler_desc = {}; + sampler_desc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + sampler_desc.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP; + sampler_desc.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP; + sampler_desc.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + sampler_desc.ComparisonFunc = D3D11_COMPARISON_NEVER; + sampler_desc.MinLOD = 0; + sampler_desc.MaxLOD = D3D11_FLOAT32_MAX; + + hr = m_d3d_device->CreateSamplerState(&sampler_desc, &m_sampler_state); + if (FAILED(hr)) return false; + + return true; +} + +bool NativeMediaCtrl::Impl::CreateVideoTexture(UINT width, UINT height, DXGI_FORMAT format) +{ + m_video_texture.Reset(); + m_video_srv.Reset(); + + D3D11_TEXTURE2D_DESC texture_desc = {}; + texture_desc.Width = width; + texture_desc.Height = height; + texture_desc.MipLevels = 1; + texture_desc.ArraySize = 1; + texture_desc.Format = format; + texture_desc.SampleDesc.Count = 1; + texture_desc.Usage = D3D11_USAGE_DYNAMIC; + texture_desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + texture_desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + + HRESULT hr = m_d3d_device->CreateTexture2D(&texture_desc, nullptr, &m_video_texture); + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Failed to create video texture: " << std::hex << hr; + return false; + } + + D3D11_SHADER_RESOURCE_VIEW_DESC srv_desc = {}; + srv_desc.Format = format; + srv_desc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + srv_desc.Texture2D.MipLevels = 1; + + hr = m_d3d_device->CreateShaderResourceView(m_video_texture.Get(), &srv_desc, &m_video_srv); + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Failed to create shader resource view: " << std::hex << hr; + return false; + } + + m_video_width = width; + m_video_height = height; + + return true; +} + +void NativeMediaCtrl::Impl::RenderVideoFrame(const BYTE* data, DWORD length) +{ + if (!m_video_texture || !m_video_srv || !m_d3d_context || !m_render_target) { + return; + } + + D3D11_MAPPED_SUBRESOURCE mapped; + HRESULT hr = m_d3d_context->Map(m_video_texture.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped); + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Failed to map video texture: " << std::hex << hr; + return; + } + + UINT bytes_per_pixel = 4; + UINT row_bytes = m_video_width * bytes_per_pixel; + + if (mapped.RowPitch == row_bytes) { + memcpy(mapped.pData, data, std::min(length, row_bytes * m_video_height)); + } else { + const BYTE* src = data; + BYTE* dst = static_cast(mapped.pData); + for (UINT y = 0; y < m_video_height && src < data + length; ++y) { + memcpy(dst, src, row_bytes); + src += row_bytes; + dst += mapped.RowPitch; + } + } + + m_d3d_context->Unmap(m_video_texture.Get(), 0); + + float clear_color[] = { 0.0f, 0.0f, 0.0f, 1.0f }; + m_d3d_context->ClearRenderTargetView(m_render_target.Get(), clear_color); + + D3D11_VIEWPORT viewport = {}; + viewport.Width = static_cast(m_width); + viewport.Height = static_cast(m_height); + viewport.MinDepth = 0.0f; + viewport.MaxDepth = 1.0f; + m_d3d_context->RSSetViewports(1, &viewport); + + m_d3d_context->OMSetRenderTargets(1, m_render_target.GetAddressOf(), nullptr); + + m_d3d_context->VSSetShader(m_vertex_shader.Get(), nullptr, 0); + m_d3d_context->PSSetShader(m_pixel_shader.Get(), nullptr, 0); + m_d3d_context->PSSetShaderResources(0, 1, m_video_srv.GetAddressOf()); + m_d3d_context->PSSetSamplers(0, 1, m_sampler_state.GetAddressOf()); + + m_d3d_context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); + m_d3d_context->Draw(4, 0); + + m_swap_chain->Present(1, 0); +} + +void NativeMediaCtrl::Impl::ShutdownMediaFoundation() +{ + CleanupResources(); + + m_video_srv.Reset(); + m_video_texture.Reset(); + m_sampler_state.Reset(); + m_input_layout.Reset(); + m_pixel_shader.Reset(); + m_vertex_shader.Reset(); + m_vertex_buffer.Reset(); + m_render_target.Reset(); + m_swap_chain.Reset(); + m_d3d_context.Reset(); + m_d3d_device.Reset(); + + if (m_mf_initialized) { + MFShutdown(); + m_mf_initialized = false; + } +} + +void NativeMediaCtrl::Impl::CleanupResources() +{ + m_running = false; + + if (m_render_thread.joinable()) { + m_render_thread.join(); + } + + std::lock_guard lock(m_mutex); + m_source_reader.Reset(); + m_media_source.Reset(); +} + +bool NativeMediaCtrl::Impl::Load(const wxString& url) +{ + CleanupResources(); + + m_url = url; + m_error = NativeMediaError::None; + NotifyStateChanged(NativeMediaState::Loading); + + if (!m_mf_initialized) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Media Foundation not initialized"; + m_error = NativeMediaError::InternalError; + NotifyStateChanged(NativeMediaState::Error); + return false; + } + + if (!CreateMediaSource(url)) { + NotifyError(m_error); + return false; + } + + if (!CreateSourceReader()) { + NotifyError(m_error); + return false; + } + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Win: Loaded URL: " << url.ToStdString(); + return true; +} + +bool NativeMediaCtrl::Impl::CreateMediaSource(const wxString& url) +{ + std::wstring wide_url = url.ToStdWstring(); + + ComPtr resolver; + HRESULT hr = MFCreateSourceResolver(&resolver); + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: MFCreateSourceResolver failed: " << std::hex << hr; + m_error = NativeMediaError::InternalError; + return false; + } + + MF_OBJECT_TYPE object_type = MF_OBJECT_INVALID; + ComPtr source; + + hr = resolver->CreateObjectFromURL( + wide_url.c_str(), + MF_RESOLUTION_MEDIASOURCE | MF_RESOLUTION_READ, + nullptr, + &object_type, + &source + ); + + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: CreateObjectFromURL failed: " << std::hex << hr; + m_error = MapHResultToError(hr); + return false; + } + + hr = source.As(&m_media_source); + if (FAILED(hr)) { + m_error = NativeMediaError::InternalError; + return false; + } + + return true; +} + +bool NativeMediaCtrl::Impl::CreateSourceReader() +{ + ComPtr attributes; + HRESULT hr = MFCreateAttributes(&attributes, 3); + if (FAILED(hr)) return false; + + hr = attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE); + if (FAILED(hr)) return false; + + hr = attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE); + if (FAILED(hr)) return false; + + if (m_d3d_device) { + ComPtr dxgi_manager; + UINT reset_token = 0; + hr = MFCreateDXGIDeviceManager(&reset_token, &dxgi_manager); + if (SUCCEEDED(hr)) { + hr = dxgi_manager->ResetDevice(m_d3d_device.Get(), reset_token); + if (SUCCEEDED(hr)) { + attributes->SetUnknown(MF_SOURCE_READER_D3D_MANAGER, dxgi_manager.Get()); + } + } + } + + hr = MFCreateSourceReaderFromMediaSource(m_media_source.Get(), attributes.Get(), &m_source_reader); + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: MFCreateSourceReaderFromMediaSource failed: " << std::hex << hr; + m_error = NativeMediaError::InternalError; + return false; + } + + ComPtr video_type; + hr = MFCreateMediaType(&video_type); + if (FAILED(hr)) return false; + + video_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); + video_type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32); + + hr = m_source_reader->SetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, nullptr, video_type.Get()); + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: SetCurrentMediaType failed for RGB32: " << std::hex << hr; + m_error = NativeMediaError::UnsupportedFormat; + return false; + } + + ComPtr actual_type; + hr = m_source_reader->GetCurrentMediaType(MF_SOURCE_READER_FIRST_VIDEO_STREAM, &actual_type); + if (SUCCEEDED(hr)) { + UINT32 width = 0, height = 0; + MFGetAttributeSize(actual_type.Get(), MF_MT_FRAME_SIZE, &width, &height); + if (width > 0 && height > 0) { + m_video_size = wxSize(width, height); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Win: Video size: " << width << "x" << height; + + GUID subtype = GUID_NULL; + actual_type->GetGUID(MF_MT_SUBTYPE, &subtype); + m_video_format = subtype; + + if (!CreateVideoTexture(width, height, DXGI_FORMAT_B8G8R8A8_UNORM)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Failed to create video texture"; + return false; + } + } + } + + return true; +} + +void NativeMediaCtrl::Impl::Play() +{ + if (!m_source_reader) { + BOOST_LOG_TRIVIAL(warning) << "NativeMediaCtrl_Win: Cannot play - no source reader"; + return; + } + + m_paused = false; + + if (m_running) { + NotifyStateChanged(NativeMediaState::Playing); + return; + } + + m_running = true; + m_render_thread = std::thread(&Impl::RenderLoop, this); + + NotifyStateChanged(NativeMediaState::Playing); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Win: Playback started"; +} + +void NativeMediaCtrl::Impl::Stop() +{ + m_running = false; + m_paused = false; + + if (m_render_thread.joinable()) { + m_render_thread.join(); + } + + NotifyStateChanged(NativeMediaState::Stopped); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Win: Playback stopped"; +} + +void NativeMediaCtrl::Impl::Pause() +{ + m_paused = true; + NotifyStateChanged(NativeMediaState::Paused); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Win: Playback paused"; +} + +void NativeMediaCtrl::Impl::RenderLoop() +{ + while (m_running) { + if (m_paused) { + std::this_thread::sleep_for(std::chrono::milliseconds(16)); + continue; + } + + ComPtr sample; + DWORD flags = 0; + LONGLONG timestamp = 0; + HRESULT hr; + + { + std::lock_guard lock(m_mutex); + if (!m_source_reader) break; + + hr = m_source_reader->ReadSample( + MF_SOURCE_READER_FIRST_VIDEO_STREAM, + 0, + nullptr, + &flags, + ×tamp, + &sample + ); + } + + if (FAILED(hr)) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: ReadSample failed: " << std::hex << hr; + m_error = MapHResultToError(hr); + NotifyError(m_error); + m_owner->ScheduleRetry(); + break; + } + + if (flags & MF_SOURCE_READERF_ENDOFSTREAM) { + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl_Win: End of stream"; + NotifyStateChanged(NativeMediaState::Stopped); + break; + } + + if (flags & MF_SOURCE_READERF_ERROR) { + BOOST_LOG_TRIVIAL(error) << "NativeMediaCtrl_Win: Stream error"; + m_error = NativeMediaError::DecoderError; + NotifyError(m_error); + m_owner->ScheduleRetry(); + break; + } + + if (sample) { + ComPtr buffer; + hr = sample->GetBufferByIndex(0, &buffer); + if (SUCCEEDED(hr)) { + BYTE* data = nullptr; + DWORD length = 0; + hr = buffer->Lock(&data, nullptr, &length); + if (SUCCEEDED(hr) && data) { + RenderVideoFrame(data, length); + buffer->Unlock(); + } + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} + +void NativeMediaCtrl::Impl::UpdateLayout(int width, int height) +{ + if (width <= 0 || height <= 0) return; + + std::lock_guard lock(m_mutex); + + m_width = width; + m_height = height; + + if (m_swap_chain && m_d3d_device) { + m_render_target.Reset(); + + HRESULT hr = m_swap_chain->ResizeBuffers(2, width, height, DXGI_FORMAT_B8G8R8A8_UNORM, 0); + if (SUCCEEDED(hr)) { + ComPtr back_buffer; + hr = m_swap_chain->GetBuffer(0, IID_PPV_ARGS(&back_buffer)); + if (SUCCEEDED(hr)) { + m_d3d_device->CreateRenderTargetView(back_buffer.Get(), nullptr, &m_render_target); + } + } + } +} + +NativeMediaError NativeMediaCtrl::Impl::MapHResultToError(HRESULT hr) +{ + switch (hr) { + case MF_E_NET_NOCONNECTION: + case E_ACCESSDENIED: + return NativeMediaError::NetworkUnreachable; + case MF_E_NET_TIMEOUT: + return NativeMediaError::ConnectionTimeout; + case MF_E_UNSUPPORTED_FORMAT: + case MF_E_INVALIDMEDIATYPE: + return NativeMediaError::UnsupportedFormat; + case MF_E_SOURCERESOLVER_MUTUALLY_EXCLUSIVE_FLAGS: + case MF_E_UNSUPPORTED_SCHEME: + return NativeMediaError::StreamNotFound; + default: + return NativeMediaError::InternalError; + } +} + +void NativeMediaCtrl::Impl::NotifyStateChanged(NativeMediaState state) +{ + m_state = state; + wxCommandEvent event(EVT_NATIVE_MEDIA_STATE_CHANGED); + event.SetEventObject(m_owner); + event.SetInt(static_cast(state)); + wxPostEvent(m_owner, event); +} + +void NativeMediaCtrl::Impl::NotifyError(NativeMediaError error) +{ + m_state = NativeMediaState::Error; + wxCommandEvent event(EVT_NATIVE_MEDIA_ERROR); + event.SetEventObject(m_owner); + event.SetInt(static_cast(error)); + wxPostEvent(m_owner, event); +} + +NativeMediaCtrl::NativeMediaCtrl(wxWindow* parent) + : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) + , m_retry_enabled(true) + , m_retry_count(0) +{ + SetBackgroundColour(*wxBLACK); + m_retry_timer.SetOwner(this); + Bind(wxEVT_TIMER, &NativeMediaCtrl::OnRetryTimer, this, m_retry_timer.GetId()); + + HWND hwnd = (HWND)GetHandle(); + m_impl = std::make_unique(this, hwnd); +} + +NativeMediaCtrl::~NativeMediaCtrl() +{ + m_retry_timer.Stop(); +} + +bool NativeMediaCtrl::Load(const wxString& url) +{ + if (!IsSupported(url)) { + BOOST_LOG_TRIVIAL(warning) << "NativeMediaCtrl: Unsupported URL format: " << url.ToStdString(); + return false; + } + + m_current_url = url; + ResetRetryState(); + + StreamType type = DetectStreamType(url); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl: Loading " << StreamTypeToString(type).ToStdString() + << " stream: " << url.ToStdString(); + + return m_impl->Load(url); +} + +void NativeMediaCtrl::Play() +{ + if (m_impl) { + m_impl->Play(); + } +} + +void NativeMediaCtrl::Stop() +{ + m_retry_timer.Stop(); + ResetRetryState(); + if (m_impl) { + m_impl->Stop(); + } +} + +void NativeMediaCtrl::Pause() +{ + if (m_impl) { + m_impl->Pause(); + } +} + +NativeMediaState NativeMediaCtrl::GetState() const +{ + return m_impl ? m_impl->GetState() : NativeMediaState::Stopped; +} + +wxSize NativeMediaCtrl::GetVideoSize() const +{ + return m_impl ? m_impl->GetVideoSize() : wxSize(1920, 1080); +} + +NativeMediaError NativeMediaCtrl::GetLastError() const +{ + return m_impl ? m_impl->GetLastError() : NativeMediaError::None; +} + +void NativeMediaCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) +{ + wxWindow::DoSetSize(x, y, width, height, sizeFlags); + if (m_impl && width > 0 && height > 0) { + m_impl->UpdateLayout(width, height); + } +} + +}} // namespace Slic3r::GUI + +#endif // _WIN32 diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index dc717aca39..58c0bad588 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -13,6 +13,7 @@ #include "MsgDialog.hpp" #include "slic3r/Utils/Http.hpp" #include "libslic3r/Thread.hpp" +#include "libslic3r/AppConfig.hpp" #include "DeviceErrorDialog.hpp" #include "RecenterDialog.hpp" @@ -22,6 +23,7 @@ #include #include #include +#include #include "DeviceCore/DevBed.h" #include "DeviceCore/DevCtrl.h" @@ -1383,6 +1385,12 @@ StatusBasePanel::~StatusBasePanel() delete m_custom_camera_view; m_custom_camera_view = nullptr; } + + if (m_native_camera_ctrl) { + m_native_camera_ctrl->Stop(); + delete m_native_camera_ctrl; + m_native_camera_ctrl = nullptr; + } } void StatusBasePanel::init_bitmaps() @@ -1495,10 +1503,12 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() m_camera_switch_button->SetBitmap(m_bitmap_switch_camera.bmp()); m_camera_switch_button->Bind(wxEVT_LEFT_DOWN, &StatusBasePanel::on_camera_switch_toggled, this); m_camera_switch_button->Bind(wxEVT_RIGHT_DOWN, [this](auto& e) { - const std::string js_request_pip = R"( - document.querySelector('video').requestPictureInPicture(); - )"; - m_custom_camera_view->RunScript(js_request_pip); + if (m_custom_camera_view && m_custom_camera_view->IsShown()) { + const std::string js_request_pip = R"( + document.querySelector('video').requestPictureInPicture(); + )"; + m_custom_camera_view->RunScript(js_request_pip); + } }); m_camera_switch_button->Hide(); @@ -1532,13 +1542,9 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() m_custom_camera_view = WebView::CreateWebView(this, wxEmptyString); m_custom_camera_view->EnableContextMenu(false); Bind(wxEVT_WEBVIEW_NAVIGATING, &StatusBasePanel::on_webview_navigating, this, m_custom_camera_view->GetId()); - - m_media_play_ctrl = new MediaPlayCtrl(this, m_media_ctrl, wxDefaultPosition, wxSize(-1, FromDIP(40))); m_custom_camera_view->Hide(); m_custom_camera_view->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, [this](wxWebViewEvent& evt) { if (evt.GetString() == "leavepictureinpicture") { - // When leaving PiP, video gets paused in some cases and toggling play - // programmatically does not work. m_custom_camera_view->Reload(); } else if (evt.GetString() == "enterpictureinpicture") { @@ -1546,27 +1552,90 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() } }); + m_native_camera_ctrl = new NativeMediaCtrl(this); + m_native_camera_ctrl->SetMinSize(wxSize(PAGE_MIN_WIDTH, FromDIP(288))); + m_native_camera_ctrl->Hide(); + m_native_camera_ctrl->Bind(EVT_NATIVE_MEDIA_STATE_CHANGED, &StatusBasePanel::on_native_camera_state_changed, this); + m_native_camera_ctrl->Bind(EVT_NATIVE_MEDIA_ERROR, &StatusBasePanel::on_native_camera_error, this); + m_native_camera_ctrl->Bind(EVT_NATIVE_MEDIA_SIZE_CHANGED, &StatusBasePanel::on_native_camera_size_changed, this); + + m_media_play_ctrl = new MediaPlayCtrl(this, m_media_ctrl, wxDefaultPosition, wxSize(-1, FromDIP(40))); + sizer->Add(m_media_ctrl, 1, wxEXPAND | wxALL, 0); sizer->Add(m_custom_camera_view, 1, wxEXPAND | wxALL, 0); + sizer->Add(m_native_camera_ctrl, 0, wxEXPAND | wxALL, 0); sizer->Add(m_media_play_ctrl, 0, wxEXPAND | wxALL, 0); -// media_ctrl_panel->SetSizer(bSizer_monitoring); -// media_ctrl_panel->Layout(); -// -// sizer->Add(media_ctrl_panel, 1, wxEXPAND | wxALL, 1); - - if (wxGetApp().app_config->get("camera", "enable_custom_source") == "true") { - handle_camera_source_change(); - } return sizer; } void StatusBasePanel::on_webview_navigating(wxWebViewEvent& evt) { wxGetApp().CallAfter([this] { - remove_controls(); + remove_webview_controls(); }); } +void StatusBasePanel::remove_webview_controls() +{ + const std::string js_cleanup_video_element = R"( + document.body.style.overflow='hidden'; + const video = document.querySelector('video'); + if (video) { + video.setAttribute('style', 'width: 100% !important;'); + video.removeAttribute('controls'); + video.addEventListener('leavepictureinpicture', () => { + window.wx.postMessage('leavepictureinpicture'); + }); + video.addEventListener('enterpictureinpicture', () => { + window.wx.postMessage('enterpictureinpicture'); + }); + } + )"; + m_custom_camera_view->RunScript(js_cleanup_video_element); +} + +void StatusBasePanel::on_native_camera_state_changed(wxCommandEvent& event) +{ + NativeMediaState state = static_cast(event.GetInt()); + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl state changed: " << static_cast(state); +} + +void StatusBasePanel::on_native_camera_error(wxCommandEvent& event) +{ + NativeMediaError error = static_cast(event.GetInt()); + BOOST_LOG_TRIVIAL(warning) << "NativeMediaCtrl error: " << static_cast(error); +} + +void StatusBasePanel::on_native_camera_size_changed(wxCommandEvent& event) +{ + if (!m_native_camera_ctrl) return; + + wxSize video_size = m_native_camera_ctrl->GetVideoSize(); + if (video_size.GetWidth() <= 0 || video_size.GetHeight() <= 0) return; + + int container_width = m_native_camera_ctrl->GetSize().GetWidth(); + if (container_width <= 0) { + container_width = m_media_ctrl ? m_media_ctrl->GetSize().GetWidth() : 0; + } + if (container_width <= 0) container_width = PAGE_MIN_WIDTH; + + double aspect = (double)video_size.GetHeight() / (double)video_size.GetWidth(); + int new_height = (int)(container_width * aspect); + + m_native_camera_ctrl->SetMinSize(wxSize(-1, new_height)); + m_native_camera_ctrl->SetMaxSize(wxSize(-1, new_height)); + m_native_camera_ctrl->SetSize(-1, new_height); + + wxWindow* parent = m_native_camera_ctrl->GetParent(); + if (parent) { + parent->Layout(); + parent->Refresh(); + } + + BOOST_LOG_TRIVIAL(info) << "NativeMediaCtrl resized to fit " << video_size.GetWidth() << "x" << video_size.GetHeight() + << " -> container width: " << container_width << ", height: " << new_height; +} + wxBoxSizer *StatusBasePanel::create_machine_control_page(wxWindow *parent) { wxBoxSizer *bSizer_right = new wxBoxSizer(wxVERTICAL); @@ -2241,6 +2310,12 @@ void StatusPanel::update_camera_state(MachineObject* obj) { if (!obj) return; + std::string current_dev_id = obj->get_dev_id(); + if (m_last_dev_id != current_dev_id) { + m_last_dev_id = current_dev_id; + handle_camera_source_change(); + } + //sdcard auto sdcard_state = obj->GetStorage()->get_sdcard_state(); if (m_last_sdcard != sdcard_state) { @@ -4842,7 +4917,7 @@ void StatusPanel::on_camera_enter(wxMouseEvent& event) pos.y += sz.y; m_camera_popup->SetPosition(pos); m_camera_popup->update(m_media_play_ctrl->IsStreaming()); - m_camera_popup->Popup(); + m_camera_popup->Show(); } } @@ -4853,14 +4928,55 @@ void StatusBasePanel::on_camera_source_change(wxCommandEvent& event) void StatusBasePanel::handle_camera_source_change() { - const auto new_cam_url = wxGetApp().app_config->get("camera", "custom_source"); - const auto enabled = wxGetApp().app_config->get("camera", "enable_custom_source") == "true"; + std::string new_cam_url; + CameraSourceType source_type = CameraSourceType::Builtin; + bool enabled = false; - if (enabled && !new_cam_url.empty()) { - m_custom_camera_view->LoadURL(new_cam_url); - toggle_custom_camera(); + auto* dev_manager = wxGetApp().getDeviceManager(); + MachineObject* machine = dev_manager ? dev_manager->get_selected_machine() : nullptr; + if (machine) { + std::string dev_id = machine->get_dev_id(); + if (wxGetApp().app_config->has_printer_camera(dev_id)) { + auto config = wxGetApp().app_config->get_printer_camera(dev_id); + new_cam_url = config.custom_source; + source_type = config.source_type; + enabled = config.enabled; + } + } + + wxSizer* sizer = m_media_ctrl->GetContainingSizer(); + + if (enabled && source_type != CameraSourceType::Builtin) { + if (source_type == CameraSourceType::RTSP || source_type == CameraSourceType::MJPEG) { + if (sizer) { + sizer->Show(m_custom_camera_view, false); + sizer->Show(m_media_ctrl, false); + sizer->Show(m_native_camera_ctrl, true); + } + m_custom_camera_view->Hide(); + m_media_ctrl->Hide(); + m_native_camera_ctrl->Load(new_cam_url); + m_native_camera_ctrl->Play(); + m_native_camera_ctrl->Show(); + } else { + if (sizer) { + sizer->Show(m_native_camera_ctrl, false); + sizer->Show(m_media_ctrl, false); + sizer->Show(m_custom_camera_view, true); + } + m_native_camera_ctrl->Stop(); + m_native_camera_ctrl->Hide(); + m_media_ctrl->Hide(); + m_custom_camera_view->LoadURL(new_cam_url); + m_custom_camera_view->Show(); + } + m_media_play_ctrl->Hide(); m_camera_switch_button->Show(); + if (sizer) sizer->Layout(); } else { + m_native_camera_ctrl->Stop(); + m_native_camera_ctrl->Hide(); + m_custom_camera_view->Hide(); toggle_builtin_camera(); m_camera_switch_button->Hide(); } @@ -4868,25 +4984,80 @@ void StatusBasePanel::handle_camera_source_change() void StatusBasePanel::toggle_builtin_camera() { + wxSizer* sizer = m_media_ctrl->GetContainingSizer(); + + m_native_camera_ctrl->Stop(); + m_native_camera_ctrl->Hide(); m_custom_camera_view->Hide(); m_media_ctrl->Show(); m_media_play_ctrl->Show(); + + if (sizer) { + sizer->Show(m_native_camera_ctrl, false); + sizer->Show(m_custom_camera_view, false); + sizer->Show(m_media_ctrl, true); + sizer->Layout(); + } } void StatusBasePanel::toggle_custom_camera() { - const auto enabled = wxGetApp().app_config->get("camera", "enable_custom_source") == "true"; + std::string cam_url; + CameraSourceType source_type = CameraSourceType::Builtin; + bool enabled = false; + auto* dev_manager = wxGetApp().getDeviceManager(); + MachineObject* machine = dev_manager ? dev_manager->get_selected_machine() : nullptr; + if (machine) { + std::string dev_id = machine->get_dev_id(); + if (wxGetApp().app_config->has_printer_camera(dev_id)) { + auto config = wxGetApp().app_config->get_printer_camera(dev_id); + cam_url = config.custom_source; + source_type = config.source_type; + enabled = config.enabled; + } + } - if (enabled) { - m_custom_camera_view->Show(); + wxSizer* sizer = m_media_ctrl->GetContainingSizer(); + + if (enabled && source_type != CameraSourceType::Builtin) { m_media_ctrl->Hide(); m_media_play_ctrl->Hide(); + + if (source_type == CameraSourceType::RTSP || source_type == CameraSourceType::MJPEG) { + m_custom_camera_view->Hide(); + m_native_camera_ctrl->Show(); + if (sizer) { + sizer->Show(m_media_ctrl, false); + sizer->Show(m_custom_camera_view, false); + sizer->Show(m_native_camera_ctrl, true); + sizer->Layout(); + } + } else { + m_native_camera_ctrl->Hide(); + m_custom_camera_view->Show(); + if (sizer) { + sizer->Show(m_media_ctrl, false); + sizer->Show(m_native_camera_ctrl, false); + sizer->Show(m_custom_camera_view, true); + sizer->Layout(); + } + } } } void StatusBasePanel::on_camera_switch_toggled(wxMouseEvent& event) { - const auto enabled = wxGetApp().app_config->get("camera", "enable_custom_source") == "true"; + bool enabled = false; + auto* dev_manager = wxGetApp().getDeviceManager(); + MachineObject* machine = dev_manager ? dev_manager->get_selected_machine() : nullptr; + if (machine) { + std::string dev_id = machine->get_dev_id(); + if (wxGetApp().app_config->has_printer_camera(dev_id)) { + auto config = wxGetApp().app_config->get_printer_camera(dev_id); + enabled = config.enabled; + } + } + if (enabled && m_media_ctrl->IsShown()) { toggle_custom_camera(); } else { @@ -4894,27 +5065,10 @@ void StatusBasePanel::on_camera_switch_toggled(wxMouseEvent& event) } } -void StatusBasePanel::remove_controls() -{ - const std::string js_cleanup_video_element = R"( - document.body.style.overflow='hidden'; - const video = document.querySelector('video'); - video.setAttribute('style', 'width: 100% !important;'); - video.removeAttribute('controls'); - video.addEventListener('leavepictureinpicture', () => { - window.wx.postMessage('leavepictureinpicture'); - }); - video.addEventListener('enterpictureinpicture', () => { - window.wx.postMessage('enterpictureinpicture'); - }); - )"; - m_custom_camera_view->RunScript(js_cleanup_video_element); -} - void StatusPanel::on_camera_leave(wxMouseEvent& event) { if (obj && m_camera_popup) { - m_camera_popup->Dismiss(); + m_camera_popup->Hide(); } } diff --git a/src/slic3r/GUI/StatusPanel.hpp b/src/slic3r/GUI/StatusPanel.hpp index 8065f198e4..31958abc3b 100644 --- a/src/slic3r/GUI/StatusPanel.hpp +++ b/src/slic3r/GUI/StatusPanel.hpp @@ -16,6 +16,7 @@ #include #include "wxMediaCtrl2.h" #include "MediaPlayCtrl.h" +#include "NativeMediaCtrl.h" #include "AMSSetting.hpp" #include "Calibration.hpp" #include "CalibrationWizardPage.hpp" @@ -460,7 +461,8 @@ protected: ScalableButton *m_button_pause_resume; ScalableButton *m_button_abort; Button * m_button_clean; - wxWebView * m_custom_camera_view{nullptr}; + wxWebView* m_custom_camera_view{nullptr}; + NativeMediaCtrl* m_native_camera_ctrl{nullptr}; wxSimplebook* m_extruder_book; std::vector m_extruderImage; @@ -572,11 +574,14 @@ protected: virtual void on_nozzle_selected(wxCommandEvent &event) { event.Skip(); } void on_camera_source_change(wxCommandEvent& event); void handle_camera_source_change(); - void remove_controls(); - void on_webview_navigating(wxWebViewEvent& evt); void on_camera_switch_toggled(wxMouseEvent& event); void toggle_custom_camera(); void toggle_builtin_camera(); + void on_native_camera_state_changed(wxCommandEvent& event); + void on_native_camera_error(wxCommandEvent& event); + void on_native_camera_size_changed(wxCommandEvent& event); + void on_webview_navigating(wxWebViewEvent& evt); + void remove_webview_controls(); public: StatusBasePanel(wxWindow * parent, @@ -651,6 +656,7 @@ protected: int m_last_timelapse = -1; int m_last_extrusion = -1; int m_last_vcamera = -1; + std::string m_last_dev_id; int m_model_mall_request_count = 0; bool m_is_load_with_temp = false; json m_rating_result; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 265feb660f..78be58fb13 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(${_TEST_NAME}_tests test_timeutils.cpp test_voronoi.cpp test_optimizers.cpp + test_printer_camera_config.cpp # test_png_io.cpp test_indexed_triangle_set.cpp ../libnest2d/printer_parts.cpp diff --git a/tests/libslic3r/test_printer_camera_config.cpp b/tests/libslic3r/test_printer_camera_config.cpp new file mode 100644 index 0000000000..f740dcc248 --- /dev/null +++ b/tests/libslic3r/test_printer_camera_config.cpp @@ -0,0 +1,347 @@ +#include + +#include "libslic3r/AppConfig.hpp" + +using namespace Slic3r; + +SCENARIO("Printer camera configuration storage", "[Config][Camera]") { + GIVEN("An AppConfig instance") { + AppConfig config; + + WHEN("No printer camera is configured") { + THEN("has_printer_camera returns false") { + REQUIRE_FALSE(config.has_printer_camera("dev123")); + } + THEN("get_printer_camera returns empty config") { + auto cam = config.get_printer_camera("dev123"); + REQUIRE(cam.dev_id.empty()); + REQUIRE(cam.custom_source.empty()); + REQUIRE_FALSE(cam.enabled); + } + THEN("get_all_printer_cameras returns empty map") { + REQUIRE(config.get_all_printer_cameras().empty()); + } + } + + WHEN("A printer camera is configured") { + PrinterCameraConfig cam_config; + cam_config.dev_id = "ABC123"; + cam_config.custom_source = "http://camera.local/stream"; + cam_config.enabled = true; + config.set_printer_camera(cam_config); + + THEN("has_printer_camera returns true") { + REQUIRE(config.has_printer_camera("ABC123")); + } + THEN("get_printer_camera returns correct values") { + auto cam = config.get_printer_camera("ABC123"); + REQUIRE(cam.dev_id == "ABC123"); + REQUIRE(cam.custom_source == "http://camera.local/stream"); + REQUIRE(cam.enabled == true); + } + THEN("Other printers are unaffected") { + REQUIRE_FALSE(config.has_printer_camera("XYZ789")); + } + THEN("get_all_printer_cameras contains the config") { + auto all = config.get_all_printer_cameras(); + REQUIRE(all.size() == 1); + REQUIRE(all.count("ABC123") == 1); + } + } + + WHEN("A printer camera is configured with enabled=false") { + PrinterCameraConfig cam_config; + cam_config.dev_id = "DEV456"; + cam_config.custom_source = "rtsp://camera.local:554/stream"; + cam_config.enabled = false; + config.set_printer_camera(cam_config); + + THEN("has_printer_camera returns true") { + REQUIRE(config.has_printer_camera("DEV456")); + } + THEN("get_printer_camera returns enabled=false") { + auto cam = config.get_printer_camera("DEV456"); + REQUIRE(cam.enabled == false); + } + } + + WHEN("Multiple printer cameras are configured") { + PrinterCameraConfig cam1; + cam1.dev_id = "dev1"; + cam1.custom_source = "http://cam1"; + cam1.enabled = true; + + PrinterCameraConfig cam2; + cam2.dev_id = "dev2"; + cam2.custom_source = "http://cam2"; + cam2.enabled = false; + + PrinterCameraConfig cam3; + cam3.dev_id = "dev3"; + cam3.custom_source = "rtsp://cam3:554/live"; + cam3.enabled = true; + + config.set_printer_camera(cam1); + config.set_printer_camera(cam2); + config.set_printer_camera(cam3); + + THEN("get_all_printer_cameras returns all configs") { + auto all = config.get_all_printer_cameras(); + REQUIRE(all.size() == 3); + REQUIRE(all.count("dev1") == 1); + REQUIRE(all.count("dev2") == 1); + REQUIRE(all.count("dev3") == 1); + } + THEN("Each config can be retrieved independently") { + REQUIRE(config.get_printer_camera("dev1").custom_source == "http://cam1"); + REQUIRE(config.get_printer_camera("dev2").custom_source == "http://cam2"); + REQUIRE(config.get_printer_camera("dev3").custom_source == "rtsp://cam3:554/live"); + } + THEN("Each config preserves its enabled state") { + REQUIRE(config.get_printer_camera("dev1").enabled == true); + REQUIRE(config.get_printer_camera("dev2").enabled == false); + REQUIRE(config.get_printer_camera("dev3").enabled == true); + } + } + + WHEN("A printer camera is erased") { + PrinterCameraConfig cam; + cam.dev_id = "dev_to_delete"; + cam.custom_source = "http://will_be_deleted"; + cam.enabled = true; + config.set_printer_camera(cam); + + REQUIRE(config.has_printer_camera("dev_to_delete")); + config.erase_printer_camera("dev_to_delete"); + + THEN("has_printer_camera returns false") { + REQUIRE_FALSE(config.has_printer_camera("dev_to_delete")); + } + THEN("get_printer_camera returns empty config") { + auto result = config.get_printer_camera("dev_to_delete"); + REQUIRE(result.dev_id.empty()); + } + } + + WHEN("Erasing a non-existent printer camera") { + config.erase_printer_camera("nonexistent_dev"); + + THEN("No exception is thrown and state remains consistent") { + REQUIRE_FALSE(config.has_printer_camera("nonexistent_dev")); + REQUIRE(config.get_all_printer_cameras().empty()); + } + } + + WHEN("A printer camera config is updated") { + PrinterCameraConfig cam; + cam.dev_id = "dev_update"; + cam.custom_source = "http://old_url"; + cam.enabled = false; + config.set_printer_camera(cam); + + cam.custom_source = "http://new_url"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("New values are stored") { + auto result = config.get_printer_camera("dev_update"); + REQUIRE(result.custom_source == "http://new_url"); + REQUIRE(result.enabled == true); + } + THEN("Only one entry exists in the map") { + REQUIRE(config.get_all_printer_cameras().size() == 1); + } + } + + WHEN("One of multiple cameras is erased") { + PrinterCameraConfig cam1; + cam1.dev_id = "keep1"; + cam1.custom_source = "http://keep1"; + cam1.enabled = true; + + PrinterCameraConfig cam2; + cam2.dev_id = "delete_me"; + cam2.custom_source = "http://delete"; + cam2.enabled = true; + + PrinterCameraConfig cam3; + cam3.dev_id = "keep2"; + cam3.custom_source = "http://keep2"; + cam3.enabled = false; + + config.set_printer_camera(cam1); + config.set_printer_camera(cam2); + config.set_printer_camera(cam3); + + config.erase_printer_camera("delete_me"); + + THEN("Only the specified camera is removed") { + REQUIRE(config.has_printer_camera("keep1")); + REQUIRE_FALSE(config.has_printer_camera("delete_me")); + REQUIRE(config.has_printer_camera("keep2")); + } + THEN("Remaining cameras retain their values") { + REQUIRE(config.get_printer_camera("keep1").custom_source == "http://keep1"); + REQUIRE(config.get_printer_camera("keep2").custom_source == "http://keep2"); + } + THEN("Map size decreases by one") { + REQUIRE(config.get_all_printer_cameras().size() == 2); + } + } + } +} + +SCENARIO("PrinterCameraConfig struct equality", "[Config][Camera]") { + GIVEN("Two PrinterCameraConfig instances") { + PrinterCameraConfig config1; + config1.dev_id = "test_dev"; + config1.custom_source = "http://test"; + config1.enabled = true; + + PrinterCameraConfig config2; + config2.dev_id = "test_dev"; + config2.custom_source = "http://test"; + config2.enabled = true; + + WHEN("All fields are identical") { + THEN("They are equal") { + REQUIRE(config1 == config2); + REQUIRE_FALSE(config1 != config2); + } + } + + WHEN("dev_id differs") { + config2.dev_id = "different_dev"; + THEN("They are not equal") { + REQUIRE_FALSE(config1 == config2); + REQUIRE(config1 != config2); + } + } + + WHEN("custom_source differs") { + config2.custom_source = "http://different"; + THEN("They are not equal") { + REQUIRE_FALSE(config1 == config2); + REQUIRE(config1 != config2); + } + } + + WHEN("enabled differs") { + config2.enabled = false; + THEN("They are not equal") { + REQUIRE_FALSE(config1 == config2); + REQUIRE(config1 != config2); + } + } + } +} + +SCENARIO("PrinterCameraConfig handles various URL formats", "[Config][Camera]") { + GIVEN("An AppConfig instance") { + AppConfig config; + + WHEN("HTTP URL is stored") { + PrinterCameraConfig cam; + cam.dev_id = "http_test"; + cam.custom_source = "http://192.168.1.100:8080/stream"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("URL is preserved exactly") { + REQUIRE(config.get_printer_camera("http_test").custom_source == "http://192.168.1.100:8080/stream"); + } + } + + WHEN("HTTPS URL is stored") { + PrinterCameraConfig cam; + cam.dev_id = "https_test"; + cam.custom_source = "https://secure.camera.com/live"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("URL is preserved exactly") { + REQUIRE(config.get_printer_camera("https_test").custom_source == "https://secure.camera.com/live"); + } + } + + WHEN("RTSP URL is stored") { + PrinterCameraConfig cam; + cam.dev_id = "rtsp_test"; + cam.custom_source = "rtsp://user:pass@camera.local:554/stream1"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("URL is preserved exactly including credentials") { + REQUIRE(config.get_printer_camera("rtsp_test").custom_source == "rtsp://user:pass@camera.local:554/stream1"); + } + } + + WHEN("URL with special characters is stored") { + PrinterCameraConfig cam; + cam.dev_id = "special_test"; + cam.custom_source = "http://camera.local/stream?quality=high&fps=30"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("URL is preserved with query parameters") { + REQUIRE(config.get_printer_camera("special_test").custom_source == "http://camera.local/stream?quality=high&fps=30"); + } + } + + WHEN("Empty URL is stored") { + PrinterCameraConfig cam; + cam.dev_id = "empty_url_test"; + cam.custom_source = ""; + cam.enabled = false; + config.set_printer_camera(cam); + + THEN("Empty URL is preserved") { + auto result = config.get_printer_camera("empty_url_test"); + REQUIRE(result.custom_source.empty()); + REQUIRE(result.enabled == false); + } + } + } +} + +SCENARIO("PrinterCameraConfig handles various dev_id formats", "[Config][Camera]") { + GIVEN("An AppConfig instance") { + AppConfig config; + + WHEN("Standard serial number is used") { + PrinterCameraConfig cam; + cam.dev_id = "01P00A123456789"; + cam.custom_source = "http://test"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("Serial number is stored correctly") { + REQUIRE(config.has_printer_camera("01P00A123456789")); + } + } + + WHEN("UUID-style dev_id is used") { + PrinterCameraConfig cam; + cam.dev_id = "550e8400-e29b-41d4-a716-446655440000"; + cam.custom_source = "http://test"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("UUID is stored correctly") { + REQUIRE(config.has_printer_camera("550e8400-e29b-41d4-a716-446655440000")); + } + } + + WHEN("Short dev_id is used") { + PrinterCameraConfig cam; + cam.dev_id = "ABC"; + cam.custom_source = "http://test"; + cam.enabled = true; + config.set_printer_camera(cam); + + THEN("Short ID is stored correctly") { + REQUIRE(config.has_printer_camera("ABC")); + } + } + } +}