# FlightMonitor/gui/panels/playback_panel.py """ Panel for controlling and viewing flight data playback. """ import tkinter as tk from tkinter import ttk, font as tkFont, messagebox from typing import Dict, Any, Optional, List from datetime import datetime, timedelta, timezone from flightmonitor.utils.logger import get_logger module_logger = get_logger(__name__) class PlaybackPanel: """ Manages the UI for the Playback function tab, allowing users to select and control the playback of recorded flight data sessions. """ def __init__(self, parent_frame: ttk.Frame, controller: Any): self.parent_frame = parent_frame self.controller = controller # --- Internal State --- self._session_data_map: Dict[str, Dict[str, Any]] = {} self._current_session_info: Optional[Dict[str, Any]] = None # --- Tkinter Variables --- self.selected_date_var = tk.StringVar() self.virtual_clock_var = tk.StringVar(value="--:--:--") self.playback_speed_var = tk.StringVar(value="1.0") self.timeline_var = tk.DoubleVar() # --- Widget References --- self.date_combobox: Optional[ttk.Combobox] = None self.sessions_treeview: Optional[ttk.Treeview] = None self.play_button: Optional[ttk.Button] = None self.pause_button: Optional[ttk.Button] = None self.stop_button: Optional[ttk.Button] = None self.reset_button: Optional[ttk.Button] = None self.timeline_slider: Optional[ttk.Scale] = None self._build_ui() module_logger.debug("PlaybackPanel initialized.") def _build_ui(self): """Builds all the widgets for the playback panel.""" container = ttk.Frame(self.parent_frame, padding="10 10 10 10") container.pack(fill=tk.BOTH, expand=True) session_frame = ttk.LabelFrame( container, text="Playback Session Selection", padding=10 ) session_frame.pack(fill=tk.X, expand=False, pady=(0, 10)) session_frame.columnconfigure(1, weight=1) scan_button = ttk.Button( session_frame, text="Scan for Recordings", command=self._on_scan_for_recordings, ) scan_button.grid(row=0, column=0, padx=(0, 5), sticky="w") self.date_combobox = ttk.Combobox( session_frame, textvariable=self.selected_date_var, state="readonly" ) self.date_combobox.grid(row=0, column=1, columnspan=2, sticky="ew") self.date_combobox.bind("<>", self._on_date_selected) tree_frame = ttk.Frame(session_frame) tree_frame.grid(row=1, column=0, columnspan=3, sticky="nsew", pady=(10, 0)) tree_frame.columnconfigure(0, weight=1) tree_frame.rowconfigure(0, weight=1) self.sessions_treeview = ttk.Treeview( tree_frame, columns=("start_time", "duration", "type", "area_description"), show="headings", height=5, selectmode="browse", ) self.sessions_treeview.grid(row=0, column=0, sticky="nsew") self.sessions_treeview.bind("<>", self._on_session_selected_in_tree) self.sessions_treeview.heading("start_time", text="Start Time (UTC)") self.sessions_treeview.column("start_time", width=130, anchor=tk.W) self.sessions_treeview.heading("duration", text="Duration") self.sessions_treeview.column("duration", width=80, anchor=tk.CENTER) self.sessions_treeview.heading("type", text="Type") self.sessions_treeview.column("type", width=120, anchor=tk.W) self.sessions_treeview.heading("area_description", text="Area Description") self.sessions_treeview.column("area_description", width=150, stretch=True) scrollbar = ttk.Scrollbar( tree_frame, orient="vertical", command=self.sessions_treeview.yview ) scrollbar.grid(row=0, column=1, sticky="ns") self.sessions_treeview.configure(yscrollcommand=scrollbar.set) controls_frame = ttk.LabelFrame(container, text="Playback Controls", padding=10) controls_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0)) controls_frame.columnconfigure(1, weight=1) clock_font = tkFont.Font(family="Courier New", size=14, weight="bold") virtual_clock_label = ttk.Label( controls_frame, textvariable=self.virtual_clock_var, font=clock_font, anchor="center", ) virtual_clock_label.grid(row=0, column=0, columnspan=4, pady=(0, 5)) self.timeline_slider = ttk.Scale( controls_frame, from_=0, to=100, orient="horizontal", variable=self.timeline_var, state=tk.DISABLED, ) self.timeline_slider.grid( row=1, column=0, columnspan=4, sticky="ew", pady=(0, 10) ) self.timeline_slider.bind("", self._on_timeline_press) self.timeline_slider.bind("", self._on_timeline_drag) self.timeline_slider.bind("", self._on_timeline_release) button_frame = ttk.Frame(controls_frame) button_frame.grid(row=2, column=0, columnspan=4) self.play_button = ttk.Button( button_frame, text="▶ Play", command=self._on_play, state=tk.DISABLED ) self.play_button.pack(side=tk.LEFT, padx=5) self.pause_button = ttk.Button( button_frame, text="❚❚ Pause", command=self._on_pause, state=tk.DISABLED ) self.pause_button.pack(side=tk.LEFT, padx=5) self.stop_button = ttk.Button( button_frame, text="■ Stop", command=self._on_stop, state=tk.DISABLED ) self.stop_button.pack(side=tk.LEFT, padx=5) self.reset_button = ttk.Button( button_frame, text="⟲ Reset", command=self._on_reset, state=tk.DISABLED ) self.reset_button.pack(side=tk.LEFT, padx=(10, 5)) ttk.Label(button_frame, text="Speed:").pack(side=tk.LEFT, padx=(15, 2)) speed_options = ["0.5", "1.0", "2.0", "4.0", "8.0"] speed_combobox = ttk.Combobox( button_frame, textvariable=self.playback_speed_var, values=speed_options, width=5, state="readonly" ) speed_combobox.pack(side=tk.LEFT) speed_combobox.bind("<>", self._on_speed_change) def _on_scan_for_recordings(self): self.update_available_dates() def update_available_dates(self): if not self.controller: return dates = self.controller.get_available_recording_dates() if self.date_combobox: self.date_combobox["values"] = dates if dates: self.selected_date_var.set(dates[0]) self._on_date_selected() else: self.selected_date_var.set("") self._clear_sessions_treeview() self.play_button.config(state=tk.DISABLED) def _on_date_selected(self, event=None): date_str = self.selected_date_var.get() if date_str: self.update_sessions_for_date(date_str) def _clear_sessions_treeview(self): self._session_data_map.clear() if self.sessions_treeview: for item in self.sessions_treeview.get_children(): self.sessions_treeview.delete(item) def update_sessions_for_date(self, date_str: str): self._clear_sessions_treeview() if not self.controller or not self.sessions_treeview: return sessions = self.controller.get_sessions_for_date(date_str) if not sessions: self.play_button.config(state=tk.DISABLED) return for session in sessions: scan_id = session.get("scan_id") if scan_id is None: continue self._session_data_map[str(scan_id)] = session try: start_ts, end_ts = session.get("start_timestamp", 0), session.get("end_timestamp", 0) duration_delta = timedelta(seconds=end_ts - start_ts) area_desc = f"BBox [{session.get('lat_min', 0):.2f}, {session.get('lon_min', 0):.2f}]" self.sessions_treeview.insert( "", "end", iid=str(scan_id), values=( datetime.fromtimestamp(start_ts, timezone.utc).strftime("%H:%M:%S"), str(duration_delta).split(".")[0], session.get("type", "Unknown"), area_desc, ), ) except Exception as e: module_logger.error(f"Failed to process session for display: {e}") children = self.sessions_treeview.get_children() if children: self.sessions_treeview.selection_set(children[0]) self.sessions_treeview.focus(children[0]) self.play_button.config(state=tk.NORMAL) else: self.play_button.config(state=tk.DISABLED) def _on_play(self): if not self.controller or not self.sessions_treeview: return selected_items = self.sessions_treeview.selection() if not selected_items: messagebox.showwarning("No Selection", "Please select a session to play.", parent=self.parent_frame) return session_id = selected_items[0] self._current_session_info = self._session_data_map.get(session_id) # <-- Riga importante if not self._current_session_info: messagebox.showerror("Error", "Could not find data for the selected session.", parent=self.parent_frame) return self.controller.start_playback(self._current_session_info) def _on_pause(self): if self.controller: self.controller.pause_playback() def _on_stop(self): if self.controller: self.controller.stop_playback() self._current_session_info = None # <-- Riga importante def _on_reset(self): # Se il playback è attivo, lo resetta. # Se non è attivo, l'utente dovrebbe selezionare una riga per resettare la vista. if self.controller and self.controller.is_playback_active: self.controller.reset_playback() elif self.controller and self._current_session_info: # Se non è attivo ma una sessione è selezionata, resetta comunque la vista. self.controller.reset_playback_to_session(self._current_session_info) def _on_speed_change(self, event=None): if self.controller: try: self.controller.set_playback_speed(float(self.playback_speed_var.get())) except (ValueError, TypeError): pass def _on_timeline_press(self, event=None): if self.controller and self.controller.is_playback_active: self.controller.pause_playback() def _on_timeline_release(self, event=None): if not self.controller or not self.controller.is_playback_active or not self._current_session_info: return start_ts = self._current_session_info.get("start_timestamp", 0) end_ts = self._current_session_info.get("end_timestamp", 0) duration = end_ts - start_ts if duration > 0: seek_percentage = self.timeline_var.get() / 100.0 seek_timestamp = start_ts + (duration * seek_percentage) self.controller.seek_playback_to_time(seek_timestamp) def set_controls_state(self, is_playing: bool, is_paused: bool = False): if not self.parent_frame.winfo_exists(): return # Stato per i controlli di selezione della sessione session_selection_state = tk.DISABLED if is_playing else tk.NORMAL session_selection_readonly_state = tk.DISABLED if is_playing else "readonly" if self.date_combobox: self.date_combobox.config(state=session_selection_readonly_state) if self.sessions_treeview: # Per i Treeview, non c'è uno stato 'readonly', ma possiamo intercettare gli eventi # per prevenire la selezione. Per ora, lo lasciamo attivo per semplicità visiva. # In alternativa, potremmo disabilitarlo ma diventerebbe grigio. pass # Trova il bottone "Scan for Recordings" e impostane lo stato # Questo assume che il bottone sia un figlio diretto del frame del notebook o di un suo figlio for child in self.parent_frame.winfo_children(): if isinstance(child, ttk.Frame): # Cerca dentro i frame for sub_child in child.winfo_children(): if isinstance(sub_child, ttk.Button) and "scan" in sub_child.cget("text").lower(): sub_child.config(state=session_selection_state) # Stato per i controlli di playback (timeline, pulsanti) playback_controls_state = tk.NORMAL if is_playing else tk.DISABLED if self.timeline_slider: self.timeline_slider.config(state=playback_controls_state) if self.stop_button: self.stop_button.config(state=playback_controls_state) is_session_selected = bool(self._current_session_info) reset_state = tk.NORMAL if is_session_selected else tk.DISABLED if self.reset_button: self.reset_button.config(state=reset_state) # Stato specifico per Play/Pause if is_playing: if is_paused: if self.play_button: self.play_button.config(state=tk.NORMAL, text="▶ Resume") self.play_button.configure(command=self._on_resume) if self.pause_button: self.pause_button.config(state=tk.DISABLED) else: # Playing if self.play_button: self.play_button.config(state=tk.DISABLED, text="▶ Play") if self.pause_button: self.pause_button.config(state=tk.NORMAL) else: # Stopped if self.play_button: self.play_button.config(state=tk.NORMAL, text="▶ Play") self.play_button.configure(command=self._on_play) if self.pause_button: self.pause_button.config(state=tk.DISABLED) def _on_resume(self): if self.controller: self.controller.resume_playback() def update_virtual_clock(self, timestamp: float): if not self.parent_frame.winfo_exists(): return try: self.virtual_clock_var.set(datetime.fromtimestamp(timestamp, timezone.utc).strftime("%H:%M:%S")) except (ValueError, TypeError): pass def update_timeline(self, current_ts: float, start_ts: float, end_ts: float): if not self.parent_frame.winfo_exists() or not self.timeline_slider: return if (end_ts - start_ts) > 0: self.timeline_var.set(((current_ts - start_ts) / (end_ts - start_ts)) * 100) else: self.timeline_var.set(100) def _on_timeline_drag(self, event=None): """Updates the virtual clock in real-time as the user drags the timeline.""" if not self._current_session_info: return start_ts = self._current_session_info.get("start_timestamp", 0) end_ts = self._current_session_info.get("end_timestamp", 0) duration = end_ts - start_ts if duration > 0: # Get the current value of the slider as it's being dragged seek_percentage = self.timeline_var.get() / 100.0 current_drag_timestamp = start_ts + (duration * seek_percentage) # Update only the clock display, do not send any commands yet self.update_virtual_clock(current_drag_timestamp) def _on_session_selected_in_tree(self, event=None): """Called when the user selects a different session in the treeview.""" if not self.sessions_treeview: return selected_items = self.sessions_treeview.selection() if not selected_items: self._current_session_info = None if self.reset_button: self.reset_button.config(state=tk.DISABLED) if self.play_button: self.play_button.config(state=tk.DISABLED) return session_id = selected_items[0] self._current_session_info = self._session_data_map.get(session_id) if self._current_session_info: # Abilita i pulsanti Play e Reset if self.play_button: self.play_button.config(state=tk.NORMAL) if self.reset_button: self.reset_button.config(state=tk.NORMAL) # Resetta la visualizzazione della mappa e l'orologio all'inizio della sessione selezionata self.controller.reset_playback_to_session(self._current_session_info) else: if self.reset_button: self.reset_button.config(state=tk.DISABLED) if self.play_button: self.play_button.config(state=tk.DISABLED)