refactor: OBS-only mode - remove broken screen recording

Simplified to just load OBS recordings:
- Removed all FFmpeg screen capture code
- Single 'Load OBS Recording' button
- Cleaner UI with progress indicator
- Handles videos without audio gracefully
- Remembers last project and video folder

Flow: Select Project → Load Video → Transcribe → Done

OBS handles recording, this tool handles processing.
This commit is contained in:
Mario Lavoie
2026-02-10 18:36:17 +00:00
parent 8c5e35c301
commit 7f8ef93247

View File

@@ -1,12 +1,14 @@
"""
KB Capture GUI
KB Capture GUI (OBS Mode)
Simple recording interface modeled after Voice Recorder.
Record → Pause → Resume → Stop → Transcribe → Done
Load OBS recordings → Transcribe → Process for KB
Simple flow: Load Video → Transcribe → Done
"""
import sys
import json
import shutil
import threading
from pathlib import Path
from typing import Optional
@@ -16,17 +18,16 @@ from datetime import datetime
try:
import customtkinter as ctk
from customtkinter import CTk, CTkFrame, CTkLabel, CTkButton, CTkEntry
from customtkinter import CTkOptionMenu, CTkScrollableFrame, CTkToplevel
from customtkinter import CTkOptionMenu, CTkToplevel, CTkProgressBar
HAS_CTK = True
except ImportError:
HAS_CTK = False
from .kb_capture import KBCaptureApp, AppState, AppStatus
from .session import SessionType
from .session import SessionManager, Session, SessionType, SessionStatus
# ============================================================================
# THEME (matching Voice Recorder)
# THEME
# ============================================================================
COLORS = {
@@ -115,7 +116,7 @@ Created: {datetime.now().strftime("%Y-%m-%d")}
# ============================================================================
class KBCaptureGUI:
"""Main application window."""
"""Main application window - OBS video loading only."""
def __init__(self):
if not HAS_CTK:
@@ -131,37 +132,33 @@ class KBCaptureGUI:
if saved.exists():
self.projects_root = saved
# App
self.app = None
self.recording_indicator_visible = True
# Session manager
self.session_manager = None
# Processing state
self.is_processing = False
# Window
ctk.set_appearance_mode("dark")
self.window = CTk()
self.window.title("KB Capture")
self.window.geometry("400x600")
self.window.minsize(380, 550)
self.window.geometry("420x520")
self.window.minsize(400, 480)
self.window.configure(fg_color=COLORS["bg"])
self._build_ui()
# Initialize app if we have a projects root
# Initialize if we have a projects root
if self.projects_root:
self._init_app()
self._init_session_manager()
self._refresh_projects()
# Start indicator animation
self._update_indicator()
self.window.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_app(self):
"""Initialize the capture app."""
self.app = KBCaptureApp(
projects_root=self.projects_root,
on_status_change=self._on_status_change,
)
def _init_session_manager(self):
"""Initialize session manager."""
self.session_manager = SessionManager(self.projects_root)
def _build_ui(self):
"""Build the interface."""
@@ -170,12 +167,12 @@ class KBCaptureGUI:
# Header
header = CTkFrame(main, fg_color="transparent")
header.pack(fill="x", pady=(0, 12))
header.pack(fill="x", pady=(0, 16))
CTkLabel(
header,
text="KB Capture",
font=("Segoe UI Semibold", 18),
font=("Segoe UI Semibold", 20),
text_color=COLORS["text"],
).pack(side="left")
@@ -190,93 +187,48 @@ class KBCaptureGUI:
command=self._browse_folder,
).pack(side="right")
# Load video button
CTkButton(
header,
text="📂 Load",
width=60,
height=32,
font=("", 10),
fg_color=COLORS["bg_card"],
hover_color=COLORS["bg_elevated"],
command=self._load_video,
).pack(side="right", padx=(0, 8))
# Info card
info_frame = CTkFrame(main, fg_color=COLORS["bg_card"], corner_radius=10)
info_frame.pack(fill="x", pady=(0, 16))
# Timer card
timer_frame = CTkFrame(main, fg_color=COLORS["bg_card"], corner_radius=10)
timer_frame.pack(fill="x", pady=(0, 12))
info_inner = CTkFrame(info_frame, fg_color="transparent")
info_inner.pack(pady=16, padx=16, fill="x")
timer_inner = CTkFrame(timer_frame, fg_color="transparent")
timer_inner.pack(pady=20)
CTkLabel(
info_inner,
text="📹 Record with OBS, then load here",
font=("Segoe UI Semibold", 13),
text_color=COLORS["text"],
).pack(anchor="w")
# Recording indicator + timer
timer_row = CTkFrame(timer_inner, fg_color="transparent")
timer_row.pack()
self.indicator = CTkLabel(
timer_row,
text="",
font=("", 10),
text_color=COLORS["red"],
)
self.indicator.pack(side="left", padx=(0, 4))
self.timer_label = CTkLabel(
timer_row,
text="00:00:00",
font=("Consolas", 36, "bold"),
text_color=COLORS["text_muted"],
)
self.timer_label.pack(side="left")
self.status_label = CTkLabel(
timer_inner,
text="Select a project to start",
CTkLabel(
info_inner,
text="Video → Transcribe → Ready for Clawdbot",
font=("", 11),
text_color=COLORS["text_secondary"],
)
self.status_label.pack(pady=(8, 0))
).pack(anchor="w", pady=(4, 0))
# Recording info (what's being captured)
self.capture_info_label = CTkLabel(
timer_inner,
# Status area
self.status_label = CTkLabel(
info_inner,
text="",
font=("", 9),
font=("", 11),
text_color=COLORS["text_muted"],
)
self.capture_info_label.pack(pady=(4, 0))
self.status_label.pack(anchor="w", pady=(8, 0))
# Recording controls
controls = CTkFrame(main, fg_color="transparent")
controls.pack(fill="x", pady=12)
controls.grid_columnconfigure((0, 1), weight=1)
self.record_btn = CTkButton(
controls,
text="Record",
height=45,
font=("Segoe UI Semibold", 12),
fg_color=COLORS["red"],
hover_color="#dc2626",
state="disabled",
command=self._toggle_recording,
)
self.record_btn.grid(row=0, column=0, padx=(0, 6), sticky="ew")
self.pause_btn = CTkButton(
controls,
text="Pause",
height=45,
font=("", 12),
# Progress bar (hidden by default)
self.progress = CTkProgressBar(
info_inner,
width=300,
height=6,
fg_color=COLORS["border"],
hover_color=COLORS["bg_elevated"],
state="disabled",
command=self._toggle_pause,
progress_color=COLORS["blue"],
)
self.pause_btn.grid(row=0, column=1, padx=(6, 0), sticky="ew")
# Don't pack yet - will show during processing
# Separator
CTkFrame(main, fg_color=COLORS["border"], height=1).pack(fill="x", pady=12)
CTkFrame(main, fg_color=COLORS["border"], height=1).pack(fill="x", pady=8)
# Project selector
proj_frame = CTkFrame(main, fg_color="transparent")
@@ -306,7 +258,7 @@ class KBCaptureGUI:
self.project_menu = CTkOptionMenu(
proj_frame,
values=["(Select folder first)"],
width=340,
width=360,
height=35,
fg_color=COLORS["bg_card"],
button_color=COLORS["bg_elevated"],
@@ -324,7 +276,7 @@ class KBCaptureGUI:
self.name_entry = CTkEntry(
main,
placeholder_text="What are you working on?",
placeholder_text="e.g., Vertical support walkthrough",
height=35,
fg_color=COLORS["bg_card"],
border_color=COLORS["border"],
@@ -332,15 +284,22 @@ class KBCaptureGUI:
self.name_entry.pack(fill="x")
# Session type
CTkLabel(
main,
text="Session Type",
font=("Segoe UI Semibold", 11),
text_color=COLORS["text_secondary"],
).pack(anchor="w", pady=(16, 8))
type_frame = CTkFrame(main, fg_color="transparent")
type_frame.pack(fill="x", pady=(12, 0))
type_frame.pack(fill="x")
type_frame.grid_columnconfigure((0, 1), weight=1)
self.type_var = ctk.StringVar(value="design")
self.design_btn = CTkButton(
type_frame,
text="🎨 Design",
text="🎨 Design → KB/Design/",
height=40,
font=("", 11),
fg_color=COLORS["blue"],
@@ -351,7 +310,7 @@ class KBCaptureGUI:
self.analysis_btn = CTkButton(
type_frame,
text="📊 Analysis",
text="📊 Analysis → KB/Analysis/",
height=40,
font=("", 11),
fg_color=COLORS["border"],
@@ -360,71 +319,42 @@ class KBCaptureGUI:
)
self.analysis_btn.grid(row=0, column=1, padx=(4, 0), sticky="ew")
# Screen selector (for multi-monitor setups)
screen_frame = CTkFrame(main, fg_color="transparent")
screen_frame.pack(fill="x", pady=(12, 0))
CTkLabel(
screen_frame,
text="Capture",
font=("Segoe UI Semibold", 11),
text_color=COLORS["text_secondary"],
).pack(side="left")
# Get available screens
self.screens = self._get_screens()
screen_values = ["All Screens"] + [f"Screen {i+1}" for i in range(len(self.screens))]
self.screen_menu = CTkOptionMenu(
screen_frame,
values=screen_values,
width=120,
height=28,
font=("", 10),
fg_color=COLORS["bg_card"],
button_color=COLORS["bg_elevated"],
button_hover_color=COLORS["border"],
)
self.screen_menu.pack(side="right")
self.screen_menu.set("All Screens")
# Spacer
CTkFrame(main, fg_color="transparent").pack(fill="both", expand=True)
# Load button (main action)
self.load_btn = CTkButton(
main,
text="📂 Load OBS Recording",
height=50,
font=("Segoe UI Semibold", 14),
fg_color=COLORS["blue"],
hover_color="#2563eb",
state="disabled",
command=self._load_video,
)
self.load_btn.pack(fill="x", pady=(16, 0))
# Recent sessions link
self.recent_label = CTkLabel(
main,
text="",
font=("", 10),
text_color=COLORS["text_muted"],
cursor="hand2",
)
self.recent_label.pack(pady=(12, 0))
# Folder path
self.folder_label = CTkLabel(
main,
text=str(self.projects_root) if self.projects_root else "No folder selected",
text=str(self.projects_root) if self.projects_root else "Click 📁 to select projects folder",
font=("", 9),
text_color=COLORS["text_muted"],
cursor="hand2",
)
self.folder_label.pack(pady=(12, 0))
self.folder_label.pack(pady=(8, 0))
self.folder_label.bind("<Button-1>", lambda e: self._browse_folder())
# Bottom row: hints + reset
bottom_row = CTkFrame(main, fg_color="transparent")
bottom_row.pack(fill="x", pady=(8, 0))
CTkLabel(
bottom_row,
text="Ctrl+Shift+R: Record/Stop",
font=("", 9),
text_color=COLORS["text_muted"],
).pack(side="left")
# Reset button (hidden until needed)
self.reset_btn = CTkButton(
bottom_row,
text="🔄 Reset",
width=60,
height=24,
font=("", 9),
fg_color=COLORS["red"],
hover_color="#dc2626",
command=self._force_reset,
)
# Don't pack initially - will show when stuck
def _set_type(self, type_id: str):
"""Set session type."""
@@ -432,72 +362,16 @@ class KBCaptureGUI:
if type_id == "design":
self.design_btn.configure(fg_color=COLORS["blue"])
self.analysis_btn.configure(fg_color=COLORS["border"])
self.status_label.configure(text="Design session → KB/Design/", text_color=COLORS["blue"])
else:
self.design_btn.configure(fg_color=COLORS["border"])
self.analysis_btn.configure(fg_color=COLORS["orange"])
self.status_label.configure(text="Analysis session → KB/Analysis/", text_color=COLORS["orange"])
def _get_screens(self) -> list:
"""Get list of available screens."""
screens = []
try:
if sys.platform == "win32":
import ctypes
user32 = ctypes.windll.user32
# EnumDisplayMonitors callback
def callback(hMonitor, hdcMonitor, lprcMonitor, dwData):
screens.append({
"handle": hMonitor,
"left": lprcMonitor.contents.left,
"top": lprcMonitor.contents.top,
"right": lprcMonitor.contents.right,
"bottom": lprcMonitor.contents.bottom,
})
return True
# Define RECT structure
class RECT(ctypes.Structure):
_fields_ = [
("left", ctypes.c_long),
("top", ctypes.c_long),
("right", ctypes.c_long),
("bottom", ctypes.c_long),
]
MONITORENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.c_ulong, ctypes.c_ulong,
ctypes.POINTER(RECT), ctypes.c_double
)
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(callback), 0)
except Exception as e:
print(f"Error getting screens: {e}")
return screens if screens else [{"name": "Primary"}]
def _update_indicator(self):
"""Animate recording indicator."""
if self.app and self.app.state == AppState.RECORDING:
self.recording_indicator_visible = not self.recording_indicator_visible
self.indicator.configure(
text="" if self.recording_indicator_visible else "",
text_color=COLORS["red"],
)
elif self.app and self.app.state == AppState.PAUSED:
self.indicator.configure(text="", text_color=COLORS["orange"])
else:
self.indicator.configure(text="")
self.window.after(500, self._update_indicator)
def _browse_folder(self):
"""Browse for projects folder."""
initial = str(self.projects_root) if self.projects_root else str(Path.home())
folder = filedialog.askdirectory(
title="Select Projects Folder",
title="Select Projects Folder (e.g., ATODrive/Projects)",
initialdir=initial,
)
@@ -508,25 +382,42 @@ class KBCaptureGUI:
self.config["projects_root"] = str(self.projects_root)
save_config(self.config)
self._init_app()
self._init_session_manager()
self._refresh_projects()
def _refresh_projects(self):
"""Refresh project list."""
if not self.app:
if not self.session_manager:
self.project_menu.configure(values=["(Select folder first)"])
return
projects = self.app.session_manager.list_projects()
projects = self.session_manager.list_projects()
if projects:
self.project_menu.configure(values=projects)
self.project_menu.set(projects[0])
self.record_btn.configure(state="normal")
self.status_label.configure(text="Ready to record")
# Try to restore last used project
last_project = self.config.get("last_project")
if last_project in projects:
self.project_menu.set(last_project)
else:
self.project_menu.set(projects[0])
self.load_btn.configure(state="normal")
self._update_recent()
else:
self.project_menu.configure(values=["(No projects - click + New)"])
self.record_btn.configure(state="disabled")
self.status_label.configure(text="Create a project first")
self.load_btn.configure(state="disabled")
def _update_recent(self):
"""Update recent sessions count."""
project = self.project_menu.get()
if project.startswith("("):
self.recent_label.configure(text="")
return
sessions = self.session_manager.list_sessions(project)
if sessions:
self.recent_label.configure(text=f"📋 {len(sessions)} previous sessions")
else:
self.recent_label.configure(text="No sessions yet")
def _new_project(self):
"""Create new project."""
@@ -605,13 +496,8 @@ class KBCaptureGUI:
name_entry.bind("<Return>", lambda e: create())
def _load_video(self):
"""Load an existing video file for processing."""
if not self.app:
messagebox.showwarning("No Folder", "Select a projects folder first")
return
if self.app.state != AppState.IDLE:
messagebox.showwarning("Busy", "Stop current recording first")
"""Load an OBS recording for processing."""
if self.is_processing:
return
project = self.project_menu.get()
@@ -619,11 +505,18 @@ class KBCaptureGUI:
messagebox.showwarning("No Project", "Select a project first")
return
# Save last project
self.config["last_project"] = project
save_config(self.config)
# Ask for video file
initial_dir = self.config.get("last_video_dir", str(Path.home() / "Videos"))
video_path = filedialog.askopenfilename(
title="Select Video to Process",
title="Select OBS Recording",
initialdir=initial_dir,
filetypes=[
("Video files", "*.mp4 *.mkv *.avi *.mov *.webm"),
("Video files", "*.mp4 *.mkv *.avi *.mov *.webm *.flv"),
("All files", "*.*"),
],
)
@@ -636,199 +529,181 @@ class KBCaptureGUI:
messagebox.showerror("Error", "File not found")
return
# Save last video directory
self.config["last_video_dir"] = str(video_path.parent)
save_config(self.config)
# Get session name
name = self.name_entry.get().strip() or video_path.stem
session_type = SessionType.DESIGN if self.type_var.get() == "design" else SessionType.ANALYSIS
# Create session folder and copy/link video
import shutil
session = self.app.session_manager.start_session(name, project, session_type)
session_dir = self.app.session_manager.get_session_dir()
# Copy video to session folder
dest_video = session_dir / "recording.mp4"
self.status_label.configure(text="Copying video...", text_color=COLORS["orange"])
self.window.update()
# Start processing
self.is_processing = True
self._set_processing_ui(True, "Copying video...")
# Run in background
threading.Thread(
target=self._process_video,
args=(video_path, name, project, session_type),
daemon=True,
).start()
def _process_video(self, video_path: Path, name: str, project: str, session_type: SessionType):
"""Process video in background thread."""
try:
# Create session
session = self.session_manager.start_session(name, project, session_type)
session_dir = self.session_manager.get_session_dir()
# Copy video
dest_video = session_dir / "recording.mp4"
self._update_status("Copying video...")
shutil.copy2(video_path, dest_video)
# Transcribe
self._update_status("Loading Whisper model...")
self._transcribe(dest_video, session)
except Exception as e:
messagebox.showerror("Error", f"Failed to copy video: {e}")
self.app.session_manager.cancel_session()
return
# Now transcribe
self.status_label.configure(text="Transcribing...", text_color=COLORS["orange"])
self.record_btn.configure(state="disabled")
self.window.update()
# Run transcription in background
def do_transcribe():
self.app._transcribe(dest_video)
self.app.state = AppState.TRANSCRIBING
threading.Thread(target=do_transcribe, daemon=True).start()
self._update_status(f"Error: {e}", error=True)
if self.session_manager.current_session:
self.session_manager.cancel_session()
finally:
self.is_processing = False
self.window.after(0, lambda: self._set_processing_ui(False))
def _toggle_recording(self):
"""Start or stop recording."""
if not self.app:
return
if self.app.state == AppState.IDLE:
self._start_recording()
elif self.app.state in (AppState.RECORDING, AppState.PAUSED):
self._stop_recording()
def _start_recording(self):
"""Start recording session."""
# Prevent double-click
if self.app and self.app.state != AppState.IDLE:
return
project = self.project_menu.get()
if project.startswith("("):
messagebox.showwarning("No Project", "Select a project first")
return
name = self.name_entry.get().strip() or f"Session {datetime.now().strftime('%H:%M')}"
session_type = SessionType.DESIGN if self.type_var.get() == "design" else SessionType.ANALYSIS
# Disable Record button immediately to prevent double-click
self.record_btn.configure(state="disabled", text="Starting...")
self.window.update()
def _transcribe(self, video_path: Path, session: Session):
"""Transcribe video with Whisper."""
try:
# Get selected screen
screen_selection = self.screen_menu.get()
screen_index = None
if screen_selection != "All Screens":
try:
screen_index = int(screen_selection.split()[-1]) - 1
except:
pass
import whisper
self.app.start_session(name, project, session_type, screen_index=screen_index)
self._update_status("Transcribing (this takes a while)...")
model = whisper.load_model("base")
# Update UI for recording state
self.record_btn.configure(text="Stop", fg_color=COLORS["border"], state="normal")
self.pause_btn.configure(state="normal", fg_color=COLORS["orange"])
# Whisper can handle video files directly
result = model.transcribe(str(video_path), language="en", verbose=False)
# Save transcript
transcript_path = video_path.parent / "transcript.json"
with open(transcript_path, "w") as f:
json.dump(result, f, indent=2)
# Find screenshot triggers
triggers = []
for segment in result.get("segments", []):
text = segment.get("text", "").lower()
if "screenshot" in text:
triggers.append({
"timestamp": segment.get("start", 0),
"text": segment.get("text", ""),
})
# Get video duration
duration = self._get_video_duration(video_path)
# Save metadata
metadata = {
"session_id": session.id,
"name": session.name,
"project": session.project,
"session_type": session.session_type.value,
"created_at": session.created_at.isoformat(),
"duration": duration,
"status": "ready",
"screenshot_triggers": triggers,
"source_file": video_path.name,
"files": {
"video": "recording.mp4",
"transcript": "transcript.json",
},
}
metadata_path = video_path.parent / "metadata.json"
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
# Update session
self.session_manager.set_duration(duration)
self.session_manager.set_transcript("transcript.json")
self.session_manager.end_session()
self._update_status(f"✅ Done! {len(triggers)} screenshot triggers found", success=True)
self.window.after(0, self._update_recent)
except ImportError:
self._update_status("⚠️ Saved (Whisper not installed)", warning=True)
self.session_manager.end_session()
except Exception as e:
error_msg = str(e)
if "audio" in error_msg.lower():
self._update_status("⚠️ Saved (video has no audio track)", warning=True)
# Still save the session without transcript
self.session_manager.end_session()
else:
raise
def _get_video_duration(self, video_path: Path) -> float:
"""Get video duration using ffprobe."""
try:
import subprocess
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path),
],
capture_output=True,
text=True,
timeout=30,
)
return float(result.stdout.strip())
except:
return 0.0
def _update_status(self, text: str, error: bool = False, success: bool = False, warning: bool = False):
"""Update status label from any thread."""
def update():
if error:
color = COLORS["red"]
elif success:
color = COLORS["green"]
elif warning:
color = COLORS["orange"]
else:
color = COLORS["text_secondary"]
self.status_label.configure(text=text, text_color=color)
self.window.after(0, update)
def _set_processing_ui(self, processing: bool, status: str = ""):
"""Update UI for processing state."""
if processing:
self.load_btn.configure(state="disabled", text="Processing...")
self.project_menu.configure(state="disabled")
self.name_entry.configure(state="disabled")
self.design_btn.configure(state="disabled")
self.analysis_btn.configure(state="disabled")
self.screen_menu.configure(state="disabled")
self.timer_label.configure(text_color=COLORS["text"])
self.status_label.configure(text="Recording...", text_color=COLORS["red"])
# Show what's being captured
capture_info = f"🖥️ {screen_selection}"
audio_devices = self.app.recorder.list_audio_devices() if hasattr(self.app, 'recorder') else []
if audio_devices:
capture_info += f" + 🎤 {audio_devices[0][:20]}"
else:
capture_info += " (no mic)"
self.capture_info_label.configure(text=capture_info)
except Exception as e:
# Reset UI on failure
self._reset_ui_to_idle()
messagebox.showerror("Recording Failed", str(e))
def _reset_ui_to_idle(self):
"""Reset UI to idle state (for error recovery)."""
self.record_btn.configure(text="Record", fg_color=COLORS["red"], state="normal")
self.pause_btn.configure(state="disabled", text="Pause", fg_color=COLORS["border"])
self.project_menu.configure(state="normal")
self.name_entry.configure(state="normal")
self.design_btn.configure(state="normal")
self.analysis_btn.configure(state="normal")
self.screen_menu.configure(state="normal")
self.timer_label.configure(text="00:00:00", text_color=COLORS["text_muted"])
self.capture_info_label.configure(text="")
self.status_label.configure(text="Ready to record", text_color=COLORS["text_secondary"])
# Hide reset button
self.reset_btn.pack_forget()
def _show_reset_button(self):
"""Show reset button when stuck."""
self.reset_btn.pack(side="right")
def _force_reset(self):
"""Force reset the app to idle state."""
if self.app:
self.app.force_reset()
self._reset_ui_to_idle()
self.status_label.configure(text="Reset complete - ready to record", text_color=COLORS["green"])
def _stop_recording(self):
"""Stop recording and transcribe."""
if not self.app:
return
self.app.stop()
self.record_btn.configure(text="Record", fg_color=COLORS["red"], state="disabled")
self.pause_btn.configure(state="disabled", text="Pause", fg_color=COLORS["border"])
self.timer_label.configure(text_color=COLORS["green"])
self.status_label.configure(text="Transcribing...", text_color=COLORS["orange"])
def _toggle_pause(self):
"""Toggle pause/resume."""
if not self.app:
return
self.app.toggle_pause()
if self.app.state == AppState.PAUSED:
self.pause_btn.configure(text="Resume", fg_color=COLORS["green"])
self.timer_label.configure(text_color=COLORS["orange"])
self.status_label.configure(text="Paused", text_color=COLORS["orange"])
self.progress.pack(fill="x", pady=(8, 0))
self.progress.configure(mode="indeterminate")
self.progress.start()
if status:
self.status_label.configure(text=status, text_color=COLORS["text_secondary"])
else:
self.pause_btn.configure(text="Pause", fg_color=COLORS["orange"])
self.timer_label.configure(text_color=COLORS["text"])
self.status_label.configure(text="Recording...", text_color=COLORS["red"])
def _on_status_change(self, status: AppStatus):
"""Handle status updates."""
self.window.after(0, lambda: self._update_ui(status))
def _update_ui(self, status: AppStatus):
"""Update UI from status."""
# Timer
if status.state in (AppState.RECORDING, AppState.PAUSED):
secs = int(status.duration)
hours = secs // 3600
mins = (secs % 3600) // 60
secs = secs % 60
self.timer_label.configure(text=f"{hours:02d}:{mins:02d}:{secs:02d}")
# Back to idle (including after errors)
if status.state == AppState.IDLE:
self._reset_ui_to_idle()
self.load_btn.configure(state="normal", text="📂 Load OBS Recording")
self.project_menu.configure(state="normal")
self.name_entry.configure(state="normal")
self.design_btn.configure(state="normal")
self.analysis_btn.configure(state="normal")
self.progress.stop()
self.progress.pack_forget()
self.name_entry.delete(0, "end")
# Keep the timer showing final time if we just finished
if "Stopped" in status.message or "Done" in status.message:
self.timer_label.configure(text_color=COLORS["green"])
# Status message
if status.message:
# Color code the message
if "failed" in status.message.lower() or "error" in status.message.lower():
self.status_label.configure(text=status.message, text_color=COLORS["red"])
# Show reset button on errors
self._show_reset_button()
elif "done" in status.message.lower() or "saved" in status.message.lower():
self.status_label.configure(text=status.message, text_color=COLORS["green"])
else:
self.status_label.configure(text=status.message, text_color=COLORS["text_secondary"])
def _on_close(self):
"""Handle window close."""
if self.app and self.app.state != AppState.IDLE:
if messagebox.askyesno("Recording Active", "Cancel current recording?"):
self.app.cancel()
else:
if self.is_processing:
if not messagebox.askyesno("Processing", "Video is being processed. Close anyway?"):
return
self.window.destroy()
def run(self):