SXXXXXXX_FlightMonitor/flightmonitor/gui/panels/playback_panel.py
2025-06-17 12:36:29 +02:00

397 lines
17 KiB
Python

# 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("<<ComboboxSelected>>", 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("<<TreeviewSelect>>", 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("<ButtonPress-1>", self._on_timeline_press)
self.timeline_slider.bind("<B1-Motion>", self._on_timeline_drag)
self.timeline_slider.bind("<ButtonRelease-1>", 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("<<ComboboxSelected>>", 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)