|
19 | 19 |
|
20 | 20 | from ...cameras.factory import CameraFactory, DetectedCamera, apply_detected_identity, camera_identity_key |
21 | 21 | from ...config import CameraSettings, MultiCameraSettings |
22 | | -from .loaders import CameraLoadWorker, CameraProbeWorker, DetectCamerasWorker |
| 22 | +from .loaders import CameraLoadWorker, CameraProbeWorker, CameraScanState, DetectCamerasWorker |
23 | 23 | from .preview import PreviewSession, PreviewState, apply_crop, apply_rotation, resize_to_fit, to_display_pixmap |
24 | 24 | from .ui_blocks import setup_camera_config_dialog_ui |
25 | 25 |
|
@@ -64,6 +64,7 @@ def __init__( |
64 | 64 |
|
65 | 65 | # Camera detection worker |
66 | 66 | self._scan_worker: DetectCamerasWorker | None = None |
| 67 | + self._scan_state: CameraScanState = CameraScanState.IDLE |
67 | 68 |
|
68 | 69 | # UI elements for eventFilter (assigned in _setup_ui) |
69 | 70 | self._settings_scroll: QScrollArea | None = None |
@@ -171,7 +172,8 @@ def _on_close_cleanup(self) -> None: |
171 | 172 | pass |
172 | 173 | # Keep this short to reduce UI freeze |
173 | 174 | sw.wait(300) |
174 | | - self._scan_worker = None |
| 175 | + self._set_scan_state(CameraScanState.IDLE) |
| 176 | + self._cleanup_scan_worker() |
175 | 177 |
|
176 | 178 | # Cancel probe worker |
177 | 179 | pw = getattr(self, "_probe_worker", None) |
@@ -260,7 +262,7 @@ def _connect_signals(self) -> None: |
260 | 262 | self.cancel_btn.clicked.connect(self.reject) |
261 | 263 | self.scan_started.connect(lambda _: setattr(self, "_dialog_active", True)) |
262 | 264 | self.scan_finished.connect(lambda: setattr(self, "_dialog_active", False)) |
263 | | - self.scan_cancel_btn.clicked.connect(self._on_scan_cancel) |
| 265 | + self.scan_cancel_btn.clicked.connect(self.request_scan_cancel) |
264 | 266 |
|
265 | 267 | def _mark_dirty(*_args): |
266 | 268 | self.apply_settings_btn.setEnabled(True) |
@@ -312,29 +314,6 @@ def _update_button_states(self) -> None: |
312 | 314 | available_row = self.available_cameras_list.currentRow() |
313 | 315 | self.add_camera_btn.setEnabled(available_row >= 0 and not scan_running) |
314 | 316 |
|
315 | | - def _sync_scan_ui(self) -> None: |
316 | | - """ |
317 | | - Sync *scan-related* UI controls based on scan state. |
318 | | -
|
319 | | - Conservative policy during scan: |
320 | | - - Allow editing/previewing already configured cameras (Active list) |
321 | | - - Disallow structural changes (add/remove/reorder) and available-list actions |
322 | | - """ |
323 | | - scanning = self._is_scan_running() |
324 | | - |
325 | | - # Discovery controls |
326 | | - self.backend_combo.setEnabled(not scanning) |
327 | | - self.refresh_btn.setEnabled(not scanning) |
328 | | - |
329 | | - # Available camera list + add flow is blocked during scan |
330 | | - self.available_cameras_list.setEnabled(not scanning) |
331 | | - self.add_camera_btn.setEnabled(False if scanning else (self.available_cameras_list.currentRow() >= 0)) |
332 | | - |
333 | | - # Scan cancel button visibility is already managed in your scan start/finish, |
334 | | - # but keeping enabled state here makes it robust. |
335 | | - if hasattr(self, "scan_cancel_btn"): |
336 | | - self.scan_cancel_btn.setEnabled(scanning) |
337 | | - |
338 | 317 | def _sync_preview_ui(self) -> None: |
339 | 318 | """Update buttons/overlays based on preview state only.""" |
340 | 319 | st = self._preview.state |
@@ -478,93 +457,158 @@ def _on_backend_changed(self, _index: int) -> None: |
478 | 457 | self._refresh_available_cameras() |
479 | 458 |
|
480 | 459 | def _is_scan_running(self) -> bool: |
481 | | - return bool(self._scan_worker and self._scan_worker.isRunning()) |
| 460 | + return self._scan_state in (CameraScanState.RUNNING, CameraScanState.CANCELING) |
| 461 | + |
| 462 | + def _set_scan_state(self, state: CameraScanState, message: str | None = None) -> None: |
| 463 | + """Single source of truth for scan-related UI controls.""" |
| 464 | + self._scan_state = state |
| 465 | + |
| 466 | + scanning = state in (CameraScanState.RUNNING, CameraScanState.CANCELING) |
| 467 | + |
| 468 | + # Overlay message |
| 469 | + if scanning: |
| 470 | + self._show_scan_overlay( |
| 471 | + message or ("Canceling discovery…" if state == CameraScanState.CANCELING else "Discovering cameras…") |
| 472 | + ) |
| 473 | + else: |
| 474 | + self._hide_scan_overlay() |
| 475 | + |
| 476 | + # Progress + cancel controls |
| 477 | + self.scan_progress.setVisible(scanning) |
| 478 | + if scanning: |
| 479 | + self.scan_progress.setRange(0, 0) # indeterminate |
| 480 | + self.scan_cancel_btn.setVisible(scanning) |
| 481 | + self.scan_cancel_btn.setEnabled(state == CameraScanState.RUNNING) # disabled while canceling |
| 482 | + |
| 483 | + # Disable discovery inputs while scanning |
| 484 | + self.backend_combo.setEnabled(not scanning) |
| 485 | + self.refresh_btn.setEnabled(not scanning) |
| 486 | + |
| 487 | + # Available list + add flow blocked while scanning (structure edits disallowed) |
| 488 | + self.available_cameras_list.setEnabled(not scanning) |
| 489 | + self.add_camera_btn.setEnabled(False if scanning else (self.available_cameras_list.currentRow() >= 0)) |
| 490 | + |
| 491 | + self._update_button_states() |
| 492 | + |
| 493 | + def _cleanup_scan_worker(self) -> None: |
| 494 | + # worker is truly finished now |
| 495 | + w = self._scan_worker |
| 496 | + self._scan_worker = None |
| 497 | + if w is not None: |
| 498 | + w.deleteLater() |
| 499 | + |
| 500 | + def _finish_scan(self, reason: str) -> None: |
| 501 | + """Mark scan UX complete (idempotent) and emit scan_finished queued.""" |
| 502 | + if self._scan_state in (CameraScanState.DONE, CameraScanState.IDLE): |
| 503 | + return |
| 504 | + |
| 505 | + # Transition scan UX to DONE (UI controls restored) |
| 506 | + self._set_scan_state(CameraScanState.DONE) |
| 507 | + |
| 508 | + QTimer.singleShot(0, self.scan_finished.emit) |
| 509 | + |
| 510 | + LOGGER.debug("[Scan] finished reason=%s", reason) |
482 | 511 |
|
483 | 512 | def _refresh_available_cameras(self) -> None: |
484 | 513 | """Refresh the list of available cameras asynchronously.""" |
485 | | - backend = self.backend_combo.currentData() |
486 | | - if not backend: |
487 | | - backend = self.backend_combo.currentText().split()[0] |
| 514 | + backend = self.backend_combo.currentData() or self.backend_combo.currentText().split()[0] |
488 | 515 |
|
489 | | - # If already scanning, ignore new requests to avoid races |
490 | | - if getattr(self, "_scan_worker", None) and self._scan_worker.isRunning(): |
| 516 | + if self._is_scan_running(): |
491 | 517 | self._show_scan_overlay("Already discovering cameras…") |
492 | 518 | return |
493 | 519 |
|
494 | | - # Reset list UI and show progress |
| 520 | + # Reset UI/list |
495 | 521 | self.available_cameras_list.clear() |
496 | 522 | self._detected_cameras = [] |
497 | | - msg = f"Discovering {backend} cameras…" |
498 | | - self._show_scan_overlay(msg) |
499 | | - self.scan_progress.setRange(0, 0) |
500 | | - self.scan_progress.setVisible(True) |
501 | | - self.scan_cancel_btn.setVisible(True) |
502 | | - self.available_cameras_list.setEnabled(False) |
503 | | - self.add_camera_btn.setEnabled(False) |
504 | | - self.refresh_btn.setEnabled(False) |
505 | | - self.backend_combo.setEnabled(False) |
506 | | - |
507 | | - self._sync_scan_ui() |
508 | | - self._update_button_states() |
| 523 | + |
| 524 | + self._set_scan_state(CameraScanState.RUNNING, message=f"Discovering {backend} cameras…") |
509 | 525 |
|
510 | 526 | # Start worker |
511 | | - self._scan_worker = DetectCamerasWorker(backend, max_devices=10, parent=self) |
512 | | - self._scan_worker.progress.connect(self._on_scan_progress) |
513 | | - self._scan_worker.result.connect(self._on_scan_result) |
514 | | - self._scan_worker.error.connect(self._on_scan_error) |
515 | | - self._scan_worker.finished.connect(self._on_scan_finished) |
| 527 | + w = DetectCamerasWorker(backend, max_devices=10, parent=self) |
| 528 | + self._scan_worker = w |
| 529 | + |
| 530 | + w.progress.connect(self._on_scan_progress) |
| 531 | + w.result.connect(self._on_scan_result) |
| 532 | + w.error.connect(self._on_scan_error) |
| 533 | + w.canceled.connect(self._on_scan_canceled) |
| 534 | + |
| 535 | + # Cleanup only |
| 536 | + w.finished.connect(self._cleanup_scan_worker) |
| 537 | + |
516 | 538 | self.scan_started.emit(f"Scanning {backend} cameras…") |
517 | | - self._scan_worker.start() |
| 539 | + w.start() |
518 | 540 |
|
519 | 541 | def _on_scan_progress(self, msg: str) -> None: |
| 542 | + if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING): |
| 543 | + return |
520 | 544 | self._show_scan_overlay(msg or "Discovering cameras…") |
521 | 545 |
|
522 | 546 | def _on_scan_result(self, cams: list) -> None: |
| 547 | + if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING): |
| 548 | + return |
| 549 | + |
| 550 | + # Apply results to UI first (stability guarantee) |
523 | 551 | self._detected_cameras = cams or [] |
524 | | - self.available_cameras_list.clear() # replace list contents |
| 552 | + self.available_cameras_list.clear() |
525 | 553 |
|
526 | 554 | if not self._detected_cameras: |
527 | 555 | placeholder = QListWidgetItem("No cameras detected.") |
528 | 556 | placeholder.setFlags(Qt.ItemIsEnabled) |
529 | 557 | self.available_cameras_list.addItem(placeholder) |
530 | | - return |
531 | | - |
532 | | - for cam in self._detected_cameras: |
533 | | - item = QListWidgetItem(f"{cam.label} (index {cam.index})") |
534 | | - item.setData(Qt.ItemDataRole.UserRole, cam) |
535 | | - self.available_cameras_list.addItem(item) |
| 558 | + else: |
| 559 | + for cam in self._detected_cameras: |
| 560 | + item = QListWidgetItem(f"{cam.label} (index {cam.index})") |
| 561 | + item.setData(Qt.ItemDataRole.UserRole, cam) |
| 562 | + self.available_cameras_list.addItem(item) |
| 563 | + self.available_cameras_list.setCurrentRow(0) |
536 | 564 |
|
537 | | - self.available_cameras_list.setCurrentRow(0) |
| 565 | + # Now UI is stable: finish scan UX and emit scan_finished queued |
| 566 | + self._finish_scan("result") |
538 | 567 |
|
539 | 568 | def _on_scan_error(self, msg: str) -> None: |
| 569 | + if self._scan_state not in (CameraScanState.RUNNING, CameraScanState.CANCELING): |
| 570 | + return |
| 571 | + |
540 | 572 | QMessageBox.warning(self, "Camera Scan", f"Failed to detect cameras:\n{msg}") |
541 | 573 |
|
542 | | - def _on_scan_finished(self) -> None: |
543 | | - self._hide_scan_overlay() |
544 | | - self.scan_progress.setVisible(False) |
545 | | - self._scan_worker = None |
| 574 | + # Ensure UI is stable (list is stable even if empty) before finishing |
| 575 | + if self.available_cameras_list.count() == 0: |
| 576 | + placeholder = QListWidgetItem("Scan failed.") |
| 577 | + placeholder.setFlags(Qt.ItemIsEnabled) |
| 578 | + self.available_cameras_list.addItem(placeholder) |
546 | 579 |
|
547 | | - self.scan_cancel_btn.setVisible(False) |
548 | | - self.scan_cancel_btn.setEnabled(True) |
549 | | - self.available_cameras_list.setEnabled(True) |
550 | | - self.refresh_btn.setEnabled(True) |
551 | | - self.backend_combo.setEnabled(True) |
| 580 | + self._finish_scan("error") |
552 | 581 |
|
553 | | - self._sync_scan_ui() |
554 | | - self._update_button_states() |
555 | | - self.scan_finished.emit() |
| 582 | + def request_scan_cancel(self) -> None: |
| 583 | + if not self._is_scan_running(): |
| 584 | + return |
556 | 585 |
|
557 | | - def _on_scan_cancel(self) -> None: |
558 | | - """User requested to cancel discovery.""" |
559 | | - if self._scan_worker and self._scan_worker.isRunning(): |
| 586 | + self._set_scan_state(CameraScanState.CANCELING, message="Canceling discovery…") |
| 587 | + |
| 588 | + w = self._scan_worker |
| 589 | + if w is not None: |
560 | 590 | try: |
561 | | - self._scan_worker.requestInterruption() |
| 591 | + w.requestInterruption() |
562 | 592 | except Exception: |
563 | 593 | pass |
564 | | - # Keep the busy bar, update texts |
565 | | - self._show_scan_overlay("Canceling discovery…") |
566 | | - self.scan_progress.setVisible(True) # stay visible as indeterminate |
567 | | - self.scan_cancel_btn.setEnabled(False) |
| 594 | + |
| 595 | + # Guarantee UI stability before scan_finished: |
| 596 | + if self.available_cameras_list.count() == 0: |
| 597 | + placeholder = QListWidgetItem("Scan canceled.") |
| 598 | + placeholder.setFlags(Qt.ItemIsEnabled) |
| 599 | + self.available_cameras_list.addItem(placeholder) |
| 600 | + |
| 601 | + self._finish_scan("cancel") |
| 602 | + |
| 603 | + def _on_scan_canceled(self) -> None: |
| 604 | + self._set_scan_state(CameraScanState.CANCELING, message="Finalizing cancellation…") |
| 605 | + # If cancel is requested without clicking cancel (e.g., dialog closing), ensure UI finishes |
| 606 | + if self._scan_state in (CameraScanState.RUNNING, CameraScanState.CANCELING): |
| 607 | + if self.available_cameras_list.count() == 0: |
| 608 | + placeholder = QListWidgetItem("Scan canceled.") |
| 609 | + placeholder.setFlags(Qt.ItemIsEnabled) |
| 610 | + self.available_cameras_list.addItem(placeholder) |
| 611 | + self._finish_scan("canceled") |
568 | 612 |
|
569 | 613 | def _on_available_camera_selected(self, row: int) -> None: |
570 | 614 | if self._scan_worker and self._scan_worker.isRunning(): |
|
0 commit comments