Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
## [Unreleased]

### Fixed
- Добавлена возможность отмены длительной остановки записи:
финализация записи теперь запускается в фоне, пользователь может
запросить отмену затянувшейся операции, а отменяемый FFmpeg pipeline
корректно прерывает долгий merge/encode без зависания GUI.
- Добавлена визуальная индикация активной области записи:
во время записи поверх выбранной области (`full`, `window`, `rect`)
показывается тонкая рамка с мягкой пульсацией, а при остановке записи
Expand All @@ -25,6 +29,8 @@
path без лишних повторных вызовов `ffmpeg -version`.

### Tests
- Добавлены unit-тесты на stop/cancel flow долгой остановки записи в
encoder, RecordingController и MainWindow.
- Добавлены unit-тесты на расчёт и lifecycle визуального индикатора
активной области записи.
- Добавлены unit-тесты на отказ старта без FFmpeg и на поведение
Expand Down
17 changes: 15 additions & 2 deletions gui/controllers/recording_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def state(self) -> RecordingState:
def elapsed_time(self) -> float:
"""Получить время записи."""
if self._video_recorder:
return self._video_recorder.elapsed_time
return float(self._video_recorder.elapsed_time)
return 0.0

def build_capture_area(self, capture: CaptureSettings) -> CaptureArea:
Expand Down Expand Up @@ -366,6 +366,19 @@ def stop_recording(self) -> Path | None:
logger.info(f"Запись остановлена: {output_path}")
return output_path

def request_stop_cancellation(self) -> bool:
"""
Запросить отмену долгой остановки/финализации записи.

Returns:
`True`, если запрос отмены отправлен.
"""
if self._encoder and self._encoder.is_finalizing:
self._encoder.cancel()
logger.info("Запрошена отмена финализации записи")
return True
return False

def cancel_recording(self) -> None:
"""Отмена записи без сохранения."""
self._cleanup()
Expand Down Expand Up @@ -410,5 +423,5 @@ def _on_audio_chunks_dropped(self, count: int) -> None:
def dropped_audio_chunks(self) -> int:
"""Количество потерянных аудио-чанков."""
if self._audio_recorder:
return self._audio_recorder.dropped_chunks
return int(self._audio_recorder.dropped_chunks)
return 0
89 changes: 82 additions & 7 deletions gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
import platform
import subprocess
import threading
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -69,6 +70,7 @@ class MainWindow(QMainWindow):
recording_resumed = pyqtSignal()
error_occurred = pyqtSignal(str)
close_requested = pyqtSignal(object)
stop_operation_finished = pyqtSignal(object, object)

def __init__(self, headless: bool = False):
"""
Expand All @@ -82,6 +84,8 @@ def __init__(self, headless: bool = False):
self._headless = headless
self._api_controls: dict[str, Callable[..., Any]] = {}
self._api_server: Any | None = None
self._stop_operation_thread: threading.Thread | None = None
self._stop_operation_in_progress = False

# Инициализация модели и контроллеров
self._state = RecordingState()
Expand Down Expand Up @@ -330,6 +334,7 @@ def _connect_signals(self) -> None:
self._output_view.output_path_changed.connect(
self._on_output_path_changed
)
self.stop_operation_finished.connect(self._on_stop_operation_finished)

# Сигналы ApiSettingsView
self._api_settings_view.apply_requested.connect(
Expand Down Expand Up @@ -491,25 +496,95 @@ def _start_recording(self) -> None:

def _stop_recording(self) -> None:
"""Остановка записи."""
if not self._state.is_recording() and not self._state.is_paused():
if self._stop_operation_in_progress:
self._cancel_stop_operation()
return

output_path = self._recording_controller.stop_recording()

if output_path:
self._on_recording_stopped(output_path)
else:
self._show_error("Не удалось сохранить запись")
if not self._state.is_recording() and not self._state.is_paused():
return
self._begin_stop_operation()

def _toggle_pause(self) -> None:
"""Переключение состояния паузы."""
if self._stop_operation_in_progress:
return
if self._state.is_paused():
self._recording_controller.resume_recording()
self._on_recording_resumed()
else:
self._recording_controller.pause_recording()
self._on_recording_paused()

def _begin_stop_operation(self) -> None:
"""Запустить остановку записи в фоне."""
self._stop_operation_in_progress = True
self.start_btn.setEnabled(False)
self.pause_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self.stop_btn.setText("Отменить остановку")
self.status_label.setText("Остановка...")
self.status_label.setStyleSheet("color: orange; font-weight: bold;")
self.status_bar.showMessage("Финализация записи...", 0)

self._stop_operation_thread = threading.Thread(
target=self._stop_recording_worker,
daemon=True,
)
self._stop_operation_thread.start()

def _stop_recording_worker(self) -> None:
"""Фоновый worker остановки записи."""
output_path = self._recording_controller.stop_recording()
error_message = (
None if output_path is not None else "Не удалось сохранить запись"
)
self.stop_operation_finished.emit(output_path, error_message)

def _cancel_stop_operation(self) -> None:
"""Запросить отмену долгой остановки записи."""
if not self._stop_operation_in_progress:
return

if self._recording_controller.request_stop_cancellation():
self.status_label.setText("Отмена остановки...")
self.status_bar.showMessage(
"Запрошена отмена остановки записи",
5000,
)
self.stop_btn.setEnabled(False)
return

self.status_bar.showMessage(
"Остановка ещё не дошла до стадии, которую можно отменить",
5000,
)

def _on_stop_operation_finished(
self,
output_path: Path | None,
error_message: str | None,
) -> None:
"""Завершить UI-часть операции остановки записи."""
self._stop_operation_in_progress = False
self._stop_operation_thread = None
self.stop_btn.setText("Стоп")

if output_path is not None:
self._on_recording_stopped(output_path)
return

self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self.pause_btn.setEnabled(False)
self.status_label.setText("Готов")
self.status_label.setStyleSheet("")
self.time_label.setText("00:00")
self._recording_indicator.hide_indicator()
self.status_bar.showMessage(
error_message or "Остановка записи не завершена",
5000,
)

# === Обработчики событий записи ===

def _on_recording_started(
Expand Down
Loading
Loading