fix playback command

This commit is contained in:
VALLONGOL 2025-06-17 12:36:29 +02:00
parent 306fdab18e
commit 3f3cf56750
4 changed files with 332 additions and 88 deletions

View File

@ -8,6 +8,7 @@ import subprocess
from queue import Queue, Empty as QueueEmpty
from typing import List, Optional, Dict, Any, TYPE_CHECKING
from tkinter import messagebox, simpledialog
from datetime import timezone,datetime
# Import dei processori e degli adapter
from flightmonitor.controller.historical_data_processor import HistoricalDataProcessor
@ -614,9 +615,23 @@ class AppController:
self.stop_live_monitoring()
self.stop_historical_download()
if self.playback_data_queue:
while not self.playback_data_queue.empty():
try:
self.playback_data_queue.get_nowait()
except QueueEmpty:
break
module_logger.info("Playback data queue cleared before starting new session.")
date_str = session_info.get("date_str")
start_ts = session_info.get("start_timestamp")
end_ts = session_info.get("end_timestamp")
module_logger.info(
f"Controller: Validating playback session info. "
f"Start TS: {start_ts} ({datetime.fromtimestamp(start_ts, timezone.utc) if start_ts else 'N/A'}), "
f"End TS: {end_ts} ({datetime.fromtimestamp(end_ts, timezone.utc) if end_ts else 'N/A'})"
)
bbox = {
"lat_min": session_info.get("lat_min"),
"lon_min": session_info.get("lon_min"),
@ -1165,3 +1180,69 @@ class AppController:
return True
module_logger.warning("Map tile manager not available to clear cache.")
return False
def seek_playback_to_time(self, timestamp: float):
"""Seeks the playback to a specific time and forces a single frame update."""
if self.playback_adapter_thread and self.is_playback_active:
self.pause_playback() # Ensure playback is paused
self.playback_adapter_thread.seek_to_time(timestamp)
# After seeking, force the adapter to push one frame of data for that timestamp
# to the queue, so the GUI updates to the new position.
self.playback_adapter_thread.force_update_for_timestamp(timestamp)
module_logger.info(f"Playback seeked to {timestamp} and a frame update was forced.")
def reset_playback(self):
"""Resets the CURRENTLY ACTIVE playback to its starting point."""
if self.playback_adapter_thread and self.is_playback_active:
self.playback_adapter_thread.reset()
self.pause_playback() # Mette in pausa
# Forza un aggiornamento al primo frame per resettare la mappa
start_ts = self.playback_adapter_thread.start_ts
self.playback_adapter_thread.force_update_for_timestamp(start_ts)
module_logger.info("Active playback session has been reset.")
def reset_playback_to_session(self, session_info: Dict[str, Any]):
"""
Resets the GUI to the start of a given session, fetching and displaying
the initial frame of data without starting the playback loop.
"""
if self.is_playback_active:
self.stop_playback() # Se c'era un altro playback, lo ferma
start_ts = session_info.get("start_timestamp")
date_str = session_info.get("date_str")
if start_ts is None or not date_str or not self.data_storage:
return
# Aggiorna la GUI (orologio e timeline)
if self.main_window:
playback_panel = self.main_window.function_notebook_panel.playback_panel
if playback_panel:
playback_panel.update_virtual_clock(start_ts)
end_ts = session_info.get("end_timestamp", start_ts)
playback_panel.update_timeline(start_ts, start_ts, end_ts)
# Preleva il primo "frame" di dati direttamente dallo storage
# per mostrare lo stato iniziale sulla mappa.
try:
initial_frame_states = self.data_storage.get_positions_in_range(
date_str, start_ts, start_ts + 1.0 # Dati del primo secondo
)
if self.main_window and self.main_window.map_manager_instance:
# Passa i dati al map manager per il disegno
self.main_window.map_manager_instance.update_playback_frame(
initial_frame_states, start_ts
)
else:
# Se la mappa non è pronta, pulisce comunque la vista
if self.main_window:
self.main_window.clear_all_views_data()
except Exception as e:
module_logger.error(f"Failed to fetch initial frame for session reset: {e}", exc_info=True)
if self.main_window:
self.main_window.clear_all_views_data()
module_logger.info(f"Playback view reset to the start of session ID {session_info.get('scan_id')}.")

View File

@ -105,6 +105,15 @@ class PlaybackAdapter(BaseLiveDataAdapter):
f"{self.name}: Thread starting playback for session on {self.session_date_str} "
f"from {self.start_ts:.0f} to {self.end_ts:.0f}."
)
if self.start_ts >= self.end_ts:
err_msg = f"Invalid time range: start_ts ({self.start_ts}) is not less than end_ts ({self.end_ts}). Stopping."
module_logger.error(f"{self.name}: {err_msg}")
self._send_status_to_queue(STATUS_PERMANENT_FAILURE, err_msg)
# Invia anche un messaggio di stop normale per chiudere pulitamente la UI
self._send_status_to_queue(STATUS_STOPPED, "Playback failed due to invalid time range.")
return
self._send_status_to_queue(STATUS_STARTING, "Playback session started.")
if not self.data_storage:
@ -176,3 +185,45 @@ class PlaybackAdapter(BaseLiveDataAdapter):
else:
module_logger.info(f"{self.name}: Playback stopped by user.")
self._send_status_to_queue(STATUS_STOPPED, "Playback stopped.")
def seek_to_time(self, timestamp: float):
"""Jumps the playback to a specific timestamp within the session."""
with self._control_lock:
# Clamp the timestamp to be within the valid session range
self._virtual_time = max(self.start_ts, min(timestamp, self.end_ts))
module_logger.info(f"{self.name}: Seeking playback to timestamp {self._virtual_time:.0f}.")
def reset(self):
"""Resets the playback to the beginning of the session."""
with self._control_lock:
self._virtual_time = self.start_ts
module_logger.info(f"{self.name}: Playback reset to the beginning.")
def force_update_for_timestamp(self, timestamp: float):
"""
Queries all data from the session start up to the specified timestamp
and puts it into the output queue as a single "frame".
"""
if not self.data_storage:
module_logger.error(f"{self.name}: DataStorage not available. Cannot force update.")
return
module_logger.info(f"{self.name}: Forcing data update for all history up to timestamp {timestamp:.0f}.")
try:
# Query for ALL data from the beginning of the session up to the seek time.
all_states_until_seek = self.data_storage.get_positions_in_range(
self.session_date_str, self.start_ts, timestamp
)
# Il "payload" ora contiene la storia completa fino a quel punto.
# Il MapCanvasManager userà questi dati per costruire le tracce e mostrare l'ultimo stato.
payload = {"canonical": all_states_until_seek, "raw_json": "{}"}
message: AdapterMessage = {
"type": MSG_TYPE_FLIGHT_DATA,
"timestamp": timestamp, # L'orologio si sincronizza con l'istante scelto
"payload": payload,
}
self.output_queue.put(message)
except Exception as e:
module_logger.error(f"{self.name}: Error during forced data update: {e}", exc_info=True)

View File

@ -24,6 +24,7 @@ class PlaybackPanel:
# --- 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()
@ -37,6 +38,7 @@ class PlaybackPanel:
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()
@ -47,7 +49,6 @@ class PlaybackPanel:
container = ttk.Frame(self.parent_frame, padding="10 10 10 10")
container.pack(fill=tk.BOTH, expand=True)
# --- Session Selection Frame ---
session_frame = ttk.LabelFrame(
container, text="Playback Session Selection", padding=10
)
@ -80,6 +81,7 @@ class PlaybackPanel:
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)
@ -96,12 +98,10 @@ class PlaybackPanel:
scrollbar.grid(row=0, column=1, sticky="ns")
self.sessions_treeview.configure(yscrollcommand=scrollbar.set)
# --- Playback Controls Frame ---
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)
# Timeline and Clock
clock_font = tkFont.Font(family="Courier New", size=14, weight="bold")
virtual_clock_label = ttk.Label(
controls_frame,
@ -122,8 +122,10 @@ class PlaybackPanel:
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)
# Buttons and Speed Control
button_frame = ttk.Frame(controls_frame)
button_frame.grid(row=2, column=0, columnspan=4)
@ -142,6 +144,11 @@ class PlaybackPanel:
)
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(
@ -149,6 +156,7 @@ class PlaybackPanel:
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)
@ -157,8 +165,7 @@ class PlaybackPanel:
self.update_available_dates()
def update_available_dates(self):
if not self.controller:
return
if not self.controller: return
dates = self.controller.get_available_recording_dates()
if self.date_combobox:
self.date_combobox["values"] = dates
@ -183,8 +190,7 @@ class PlaybackPanel:
def update_sessions_for_date(self, date_str: str):
self._clear_sessions_treeview()
if not self.controller or not self.sessions_treeview:
return
if not self.controller or not self.sessions_treeview: return
sessions = self.controller.get_sessions_for_date(date_str)
if not sessions:
@ -193,39 +199,25 @@ class PlaybackPanel:
for session in sessions:
scan_id = session.get("scan_id")
if scan_id is None:
continue
if scan_id is None: continue
self._session_data_map[str(scan_id)] = session
try:
start_ts = session.get("start_timestamp", 0)
end_ts = session.get("end_timestamp", 0)
start_dt = datetime.fromtimestamp(start_ts, timezone.utc)
end_dt = datetime.fromtimestamp(end_ts, timezone.utc)
duration_delta = end_dt - start_dt
duration_str = str(duration_delta).split(".")[0]
area_desc = (
f"BBox [{session.get('lat_min', 0):.2f}, {session.get('lon_min', 0):.2f}]"
)
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",
"", "end", iid=str(scan_id),
values=(
start_dt.strftime("%H:%M:%S"),
duration_str,
datetime.fromtimestamp(start_ts, timezone.utc).strftime("%H:%M:%S"),
str(duration_delta).split(".")[0],
session.get("type", "Unknown"),
area_desc,
),
iid=str(scan_id),
)
except Exception as e:
module_logger.error(f"Failed to process session for display: {e}")
# Select the first item by default and enable play button
children = self.sessions_treeview.get_children()
if children:
self.sessions_treeview.selection_set(children[0])
@ -235,86 +227,171 @@ class PlaybackPanel:
self.play_button.config(state=tk.DISABLED)
def _on_play(self):
if not self.controller or not self.sessions_treeview:
return
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]
session_info = self._session_data_map.get(session_id)
self._current_session_info = self._session_data_map.get(session_id) # <-- Riga importante
if not session_info:
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(session_info)
self.controller.start_playback(self._current_session_info)
def _on_pause(self):
if self.controller:
self.controller.pause_playback()
if self.controller: self.controller.pause_playback()
def _on_stop(self):
if self.controller:
self.controller.stop_playback()
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:
speed = float(self.playback_speed_var.get())
self.controller.set_playback_speed(speed)
except (ValueError, TypeError):
module_logger.warning("Invalid playback speed value.")
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
# Controls disabled during playback
scan_and_select_state = tk.DISABLED if is_playing else tk.NORMAL
# 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="readonly" if not is_playing else tk.DISABLED)
# Also disable scan button and treeview
# ... (assuming direct widget references or iterating children)
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)
self.stop_button.config(state=tk.NORMAL)
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)
self.stop_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)
self.stop_button.config(state=tk.DISABLED)
def _on_resume(self):
if self.controller:
self.controller.resume_playback()
if self.controller: self.controller.resume_playback()
def update_virtual_clock(self, timestamp: float):
if not self.parent_frame.winfo_exists():
return
if not self.parent_frame.winfo_exists(): return
try:
dt_object = datetime.fromtimestamp(timestamp, timezone.utc)
self.virtual_clock_var.set(dt_object.strftime("%H:%M:%S"))
except (ValueError, TypeError):
self.virtual_clock_var.set("Invalid Time")
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 not self.parent_frame.winfo_exists() or not self.timeline_slider: return
if (end_ts - start_ts) > 0:
progress_percent = ((current_ts - start_ts) / (end_ts - start_ts)) * 100
self.timeline_var.set(progress_percent)
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)

View File

@ -123,6 +123,7 @@ class MapCanvasManager:
# --- Data for Drawing ---
self._current_flights_to_display_gui: List[CanonicalFlightState] = []
self._active_aircraft_states: Dict[str, CanonicalFlightState] = {}
self._last_playback_timestamp: float = 0.0
self.flight_tracks_gui: Dict[str, deque] = {}
self.max_track_points: int = app_config.DEFAULT_TRACK_HISTORY_POINTS
self._map_data_lock: threading.Lock = threading.Lock()
@ -507,22 +508,56 @@ class MapCanvasManager:
self.flight_tracks_gui[state.icao24].append((state.latitude, state.longitude, state.timestamp))
self._request_overlay_render()
def update_playback_frame(self, new_flight_states: List[CanonicalFlightState], virtual_timestamp: float):
def update_playback_frame(self, frame_flight_states: List[CanonicalFlightState], virtual_timestamp: float):
"""
Updates the map for a playback frame. Can handle a single tick or a full history refresh on seek.
"""
with self._map_data_lock:
timed_out_icaos = {icao for icao, state in self._active_aircraft_states.items() if virtual_timestamp - state.last_contact_timestamp > AIRCRAFT_VISIBILITY_TIMEOUT_SECONDS}
for icao in timed_out_icaos:
del self._active_aircraft_states[icao]
if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao]
# Se il nuovo timestamp è inferiore all'ultimo, è un "seek" all'indietro.
# Dobbiamo resettare completamente lo stato per ricostruire la cronologia.
is_seek_event = virtual_timestamp < self._last_playback_timestamp
for state in new_flight_states:
state.last_contact_timestamp = virtual_timestamp # Update last seen time for playback
if is_seek_event:
logger.debug("Playback seek detected. Resetting active states and tracks.")
self._active_aircraft_states.clear()
self.flight_tracks_gui.clear()
# Processa tutti gli stati ricevuti per questo "frame"
# Durante un tick normale, sarà una piccola lista. Durante un seek, sarà l'intera storia.
for state in frame_flight_states:
# Aggiorna lo stato più recente dell'aereo
self._active_aircraft_states[state.icao24] = state
# Aggiungi il punto alla traccia
if state.icao24 not in self.flight_tracks_gui:
self.flight_tracks_gui[state.icao24] = deque(maxlen=self.max_track_points + 5)
if state.latitude is not None and state.longitude is not None and state.timestamp is not None:
self.flight_tracks_gui[state.icao24].append((state.latitude, state.longitude, state.timestamp))
if state.latitude is not None and state.longitude is not None and state.timestamp is not None:
self.flight_tracks_gui[state.icao24].append(
(state.latitude, state.longitude, state.timestamp)
)
# Rimuovi aerei che non sono più attivi (importante per il tick normale)
# Durante un seek, questo non farà nulla perché abbiamo appena pulito _active_aircraft_states
active_icaos_in_frame = {s.icao24 for s in frame_flight_states}
current_active_icaos = set(self._active_aircraft_states.keys())
# Se non è un seek, possiamo fare il pruning dei velivoli scaduti
if not is_seek_event:
timed_out_icaos = {
icao for icao, state in self._active_aircraft_states.items()
if virtual_timestamp - state.last_contact_timestamp > AIRCRAFT_VISIBILITY_TIMEOUT_SECONDS
}
if timed_out_icaos:
for icao in timed_out_icaos:
if icao in self._active_aircraft_states: del self._active_aircraft_states[icao]
if icao in self.flight_tracks_gui: del self.flight_tracks_gui[icao]
# L'elenco degli aerei da disegnare è lo stato finale degli aerei attivi
self._current_flights_to_display_gui = list(self._active_aircraft_states.values())
self._last_playback_timestamp = virtual_timestamp
# Richiedi un aggiornamento dell'overlay con le tracce e posizioni appena calcolate
self._request_overlay_render()
def get_current_map_info(self) -> Dict[str, Any]: