""" Edit Popup Module Handles image editing/annotation functionality for the signage player """ import os import threading from datetime import datetime import json import re import shutil import time from kivy.uix.widget import Widget from kivy.uix.popup import Popup from kivy.uix.label import Label from kivy.graphics import Color, Line, RoundedRectangle from kivy.clock import Clock from kivy.core.window import Window from kivy.logger import Logger from kivy.uix.video import Video class DrawingLayer(Widget): """Layer for drawing on top of images""" def __init__(self, reset_callback=None, **kwargs): super(DrawingLayer, self).__init__(**kwargs) self.strokes = [] # Store all drawn lines self.current_color = (1, 0, 0, 1) # Default red self.current_width = 3 # Default thickness self.drawing_enabled = True # Drawing always enabled in edit mode self.reset_callback = reset_callback # Callback to reset countdown timer self._last_draw_time = 0 # For throttling touch updates self._draw_throttle_interval = 0.016 # ~60fps (16ms between updates) def on_touch_down(self, touch): if not self.drawing_enabled or not self.collide_point(*touch.pos): return False # Reset countdown on user interaction if self.reset_callback: self.reset_callback() with self.canvas: Color(*self.current_color) new_line = Line(points=[touch.x, touch.y], width=self.current_width) self.strokes.append({'line': new_line, 'color': self.current_color, 'width': self.current_width}) touch.ud['line'] = new_line return True def on_touch_move(self, touch): if 'line' in touch.ud and self.drawing_enabled: # Throttle updates to ~60fps for better performance current_time = time.time() if current_time - self._last_draw_time >= self._draw_throttle_interval: touch.ud['line'].points += [touch.x, touch.y] self._last_draw_time = current_time return True def undo(self): """Remove the last stroke""" if self.strokes: last_stroke = self.strokes.pop() self.canvas.remove(last_stroke['line']) Logger.info("DrawingLayer: Undid last stroke") def clear_all(self): """Clear all strokes""" for stroke in self.strokes: self.canvas.remove(stroke['line']) self.strokes = [] Logger.info("DrawingLayer: Cleared all strokes") def set_color(self, color_tuple): """Set drawing color (RGBA)""" self.current_color = color_tuple def set_thickness(self, value): """Set line thickness""" self.current_width = value class EditPopup(Popup): """Popup for editing/annotating images""" def __init__(self, player_instance, image_path, user_card_data=None, media_id=None, original_filename=None, **kwargs): super(EditPopup, self).__init__(**kwargs) self.player = player_instance self.image_path = image_path self.user_card_data = user_card_data # Store card data to send to server on save # Server naming context: which media item (id) is being edited and what # its original file name is on the server. The server stores edited # media under 'edited_media//', so we must reproduce that. self.media_id = media_id self.original_filename = original_filename # server-side file_name # Auto-close timer (5 minutes) self.auto_close_timeout = 300 # 5 minutes in seconds self.remaining_time = self.auto_close_timeout self.countdown_event = None self.auto_close_event = None # Pause playback (without auto-resume timer) self.was_paused = self.player.is_paused if not self.was_paused: self.player.is_paused = True Clock.unschedule(self.player.next_media) # Cancel auto-resume timer if one exists (don't want auto-resume during editing) if self.player.auto_resume_event: Clock.unschedule(self.player.auto_resume_event) self.player.auto_resume_event = None Logger.info("EditPopup: Cancelled auto-resume timer") # Update button icon to play (to show it's paused) self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/play.png' self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/play.png' if self.player.current_widget and isinstance(self.player.current_widget, Video): self.player.current_widget.state = 'pause' Logger.info("EditPopup: ⏸ Paused playback (no auto-resume) for editing") # Show cursor try: Window.show_cursor = True except: pass # Note: UI is now defined in KV file, but we need to customize after creation # Set image source after KV loads Clock.schedule_once(lambda dt: self._setup_after_kv(), 0) def _setup_after_kv(self): """Setup widgets after KV file has loaded them""" # Set the image source self.ids.image_widget.source = self.image_path # Create and insert drawing layer (custom class, must be added programmatically) self.drawing_layer = DrawingLayer( reset_callback=self.reset_countdown, size_hint=(1, 1), pos_hint={'x': 0, 'y': 0} ) # Replace placeholder with actual drawing layer content = self.content placeholder_index = content.children.index(self.ids.drawing_layer_placeholder) content.remove_widget(self.ids.drawing_layer_placeholder) content.add_widget(self.drawing_layer, index=placeholder_index) # Set icon sources pen_icon_path = os.path.join(self.player.resources_path, 'edit-pen.png') self.ids.color_icon.source = pen_icon_path self.ids.thickness_icon.source = pen_icon_path # Bind button callbacks self.ids.undo_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.undo())) self.ids.clear_btn.bind(on_press=lambda x: (self.reset_countdown(), self.drawing_layer.clear_all())) self.ids.save_btn.bind(on_press=self.save_image) self.ids.cancel_btn.bind(on_press=self.close_without_saving) # Bind color buttons self.ids.red_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 0, 0, 1))) self.ids.blue_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 1, 1))) self.ids.green_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 1, 0, 1))) self.ids.black_btn.bind(on_press=lambda x: self.drawing_layer.set_color((0, 0, 0, 1))) self.ids.white_btn.bind(on_press=lambda x: self.drawing_layer.set_color((1, 1, 1, 1))) # Bind thickness buttons self.ids.small_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(2)) self.ids.medium_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(5)) self.ids.large_btn.bind(on_press=lambda x: self.drawing_layer.set_thickness(10)) # Add rounded corners to buttons for btn_id in ['undo_btn', 'clear_btn', 'save_btn', 'cancel_btn']: btn = self.ids[btn_id] btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn) # Add circular corners to color/thickness buttons for btn_id in ['red_btn', 'blue_btn', 'green_btn', 'black_btn', 'white_btn', 'small_btn', 'medium_btn', 'large_btn']: btn = self.ids[btn_id] btn.bind(pos=self._make_round, size=self._make_round) # Reference to countdown label self.countdown_label = self.ids.countdown_label # Bind to dismiss self.bind(on_dismiss=self.on_popup_dismiss) # Start countdown timer (updates every second) self.countdown_event = Clock.schedule_interval(self.update_countdown, 1) # Start auto-close timer (closes after 5 minutes) self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout) Logger.info(f"EditPopup: Opened for image {os.path.basename(self.image_path)} (auto-close in 5 minutes)") def update_countdown(self, dt): """Update countdown display""" self.remaining_time -= 1 # Format time as MM:SS minutes = self.remaining_time // 60 seconds = self.remaining_time % 60 self.countdown_label.text = f"{minutes}:{seconds:02d}" # Change color as time runs out if self.remaining_time <= 60: # Last minute - red self.countdown_label.color = (1, 0.2, 0.2, 1) elif self.remaining_time <= 120: # Last 2 minutes - yellow self.countdown_label.color = (1, 1, 0, 1) else: self.countdown_label.color = (1, 1, 1, 1) # White if self.remaining_time <= 0: Clock.unschedule(self.countdown_event) def reset_countdown(self): """Reset countdown timer on user interaction""" self.remaining_time = self.auto_close_timeout # Cancel existing timers if self.countdown_event: Clock.unschedule(self.countdown_event) if self.auto_close_event: Clock.unschedule(self.auto_close_event) # Restart timers self.countdown_event = Clock.schedule_interval(self.update_countdown, 1) self.auto_close_event = Clock.schedule_once(self.auto_close, self.auto_close_timeout) # Reset color to white self.countdown_label.color = (1, 1, 1, 1) Logger.info("EditPopup: Countdown reset to 5:00") def auto_close(self, dt): """Auto-close the edit popup after timeout""" Logger.info("EditPopup: Auto-closing after 5 minutes of inactivity") self.close_without_saving(None) def _make_rounded_btn(self, instance, value): """Make toolbar button with slightly rounded corners""" instance.canvas.before.clear() with instance.canvas.before: Color(*instance.background_color) instance.round_rect = RoundedRectangle( pos=instance.pos, size=instance.size, radius=[10] ) def _make_round(self, instance, value): """Make sidebar button fully circular""" instance.canvas.before.clear() with instance.canvas.before: Color(*instance.background_color) instance.round_rect = RoundedRectangle( pos=instance.pos, size=instance.size, radius=[instance.height / 2] ) def save_image(self, instance): """Save the edited image""" try: # Edited media is stored on the server under # 'edited_media//'. Reproduce that subfolder locally so # the upload naming matches what the server expects. Fall back to # the flat 'edited_media/' folder when no media_id is available. edited_base = os.path.join(self.player.base_dir, 'media', 'edited_media') if self.media_id is not None: edited_dir = os.path.join(edited_base, str(self.media_id)) else: edited_dir = edited_base os.makedirs(edited_dir, exist_ok=True) # Get original filename base_name = os.path.basename(self.image_path) name, ext = os.path.splitext(base_name) # Determine version number version_match = re.search(r'_e_v(\d+)$', name) if version_match: # Increment existing version current_version = int(version_match.group(1)) new_version = current_version + 1 # Remove old version suffix original_name = re.sub(r'_e_v\d+$', '', name) new_name = f"{original_name}_e_v{new_version}" else: # First edit version original_name = name new_name = f"{name}_e_v1" # Generate output path output_filename = f"{new_name}.jpg" output_path = os.path.join(edited_dir, output_filename) # Temporarily hide toolbars self.ids.top_toolbar.opacity = 0 self.ids.right_sidebar.opacity = 0 # Force canvas update self.content.canvas.ask_update() # Small delay to ensure rendering is complete def do_export(dt): try: # Export only the visible content (image + drawings, no toolbars) self.content.export_to_png(output_path) Logger.info(f"EditPopup: Saved edited image to {output_path}") # ALSO overwrite the original image with edited content Logger.info(f"EditPopup: Overwriting original image at {self.image_path}") # Get original file info before overwrite orig_size = os.path.getsize(self.image_path) orig_mtime = os.path.getmtime(self.image_path) # Overwrite the file shutil.copy2(output_path, self.image_path) # Force file system sync to ensure data is written to disk. # NOTE: os.sync() does not exist on every platform and used # to raise AttributeError, aborting the whole pipeline before # the metadata/upload steps. Use a best-effort flushes that can # never break the save/upload flow. try: if hasattr(os, 'sync'): os.sync() else: with open(output_path, 'rb') as _f: try: os.fsync(_f.fileno()) except Exception: pass except Exception as _sync_err: Logger.warning(f"EditPopup: File sync skipped ({_sync_err})") # Verify the overwrite new_size = os.path.getsize(self.image_path) new_mtime = os.path.getmtime(self.image_path) Logger.info(f"EditPopup: ✓ File overwritten:") Logger.info(f" - Size: {orig_size} -> {new_size} bytes (changed: {new_size != orig_size})") Logger.info(f" - Modified time: {orig_mtime} -> {new_mtime} (changed: {new_mtime > orig_mtime})") Logger.info(f"EditPopup: ✓ File synced to disk") # Restore toolbars self.ids.top_toolbar.opacity = 1 self.ids.right_sidebar.opacity = 1 # Create and save metadata. This runs in its own guarded # block so that a failure here cannot silently stop the # upload — the two steps are intentionally decoupled. json_filename = None try: json_filename = self._save_metadata(edited_dir, new_name, base_name, new_version if version_match else 1, output_filename) except Exception as meta_err: Logger.error(f"EditPopup: Metadata save failed: {meta_err}") # Upload to server in background (continues after popup closes) upload_thread = threading.Thread( target=self._upload_to_server, args=(output_path, json_filename), daemon=True ) upload_thread.start() # Re-enabled # NOW show saving popup AFTER everything is done def show_saving_and_dismiss(dt): # Create label with background showing detailed save status save_msg = ( 'Saved locally!\n' 'Uploading to server...' ) save_label = Label( text=save_msg, font_size='24sp', color=(1, 1, 1, 1), bold=True ) saving_popup = Popup( title='', content=save_label, size_hint=(0.85, 0.4), auto_dismiss=False, separator_height=0, background_color=(0.2, 0.7, 0.2, 0.95) # Green background ) saving_popup.open() Logger.info("EditPopup: Saving confirmation popup opened") # Update message after 3 seconds to show upload is happening def update_message(dt): if saving_popup: save_label.text = ( '✓ Saved to device\n' 'Upload in progress...' ) Clock.schedule_once(update_message, 3.0) # Dismiss both popups after 4 seconds def dismiss_all(dt): saving_popup.dismiss() Logger.info(f"EditPopup: Dismissing to resume playback...") self.dismiss() Clock.schedule_once(dismiss_all, 4.0) # Small delay to ensure UI is ready, then show popup Clock.schedule_once(show_saving_and_dismiss, 0.1) except Exception as e: Logger.error(f"EditPopup: Error in export: {e}") import traceback Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}") self.title = f'Error saving: {e}' # Restore toolbars self.ids.top_toolbar.opacity = 1 self.ids.right_sidebar.opacity = 1 # Still dismiss on error after brief delay Clock.schedule_once(lambda dt: self.dismiss(), 1) Clock.schedule_once(do_export, 0.1) return except Exception as e: Logger.error(f"EditPopup: Error saving image: {e}") import traceback Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}") self.title = f'Error saving: {e}' def _save_metadata(self, edited_dir, new_name, base_name, version, output_filename): """Save metadata JSON file""" metadata = { 'time_of_modification': datetime.now().isoformat(), 'original_name': base_name, 'new_name': output_filename, 'original_path': self.image_path, 'version': version, 'user_card_data': self.user_card_data # Card data from reader (or None) } # Include the server-side file name and media id so the server can # attach the edit to the correct media item. if self.original_filename: metadata['original_filename'] = self.original_filename else: metadata['original_filename'] = os.path.basename(self.image_path) if self.media_id is not None: metadata['media_id'] = self.media_id # Save metadata JSON json_filename = f"{new_name}_metadata.json" json_path = os.path.join(edited_dir, json_filename) with open(json_path, 'w') as f: json.dump(metadata, f, indent=2) Logger.info(f"EditPopup: Saved metadata to {json_path} (user_card_data: {self.user_card_data})") return json_path def _upload_to_server(self, image_path, metadata_path): """Upload edited image and metadata to server (runs in background thread)""" try: import requests from get_playlists_v2 import get_auth_instance # Get authenticated instance auth = get_auth_instance() if not auth or not auth.is_authenticated(): Logger.warning("EditPopup: Cannot upload - not authenticated (edited media saved locally only)") Logger.warning("EditPopup: Server will NOT receive this edit") return False server_url = auth.auth_data.get('server_url') auth_code = auth.auth_data.get('auth_code') if not server_url or not auth_code: Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)") return False # Load metadata from file (or build it in memory if the metadata # file was not written — the upload must still go through). metadata = None if metadata_path and os.path.exists(metadata_path): try: with open(metadata_path, 'r') as meta_file: metadata = json.load(meta_file) except Exception as e: Logger.warning(f"EditPopup: Could not read metadata file: {e}") if not metadata: metadata = { 'time_of_modification': datetime.now().isoformat(), 'original_name': os.path.basename(image_path), 'new_name': os.path.basename(image_path), 'version': 1, 'user_card_data': self.user_card_data, } if self.original_filename: metadata['original_filename'] = self.original_filename if self.media_id is not None: metadata['media_id'] = self.media_id # Prepare upload URL - send to the original file endpoint upload_url = f"{server_url}/api/player-edit-media" headers = {'Authorization': f'Bearer {auth_code}'} # Ensure the original filename (server-side name) is present so the # server knows which file was edited. Prefer the media context we # captured when the edit popup opened. if not metadata.get('original_filename'): metadata['original_filename'] = os.path.basename(metadata.get('original_path', image_path)) # Disable SSL verification for self-signed certificates (like main code does) # Note: This is NOT recommended for production with untrusted servers Logger.warning("⚠️ SSL verification disabled for edited media upload - only use with trusted servers") # Prepare file and data for upload with open(image_path, 'rb') as img_file: files = { 'image_file': (metadata['original_filename'], img_file, 'image/jpeg') } # Send metadata as JSON string in form data data = { 'metadata': json.dumps(metadata), 'original_file': metadata['original_filename'] } Logger.info(f"EditPopup: 📤 Uploading edited media to {upload_url}") Logger.info(f"EditPopup: - Original file: {metadata['original_filename']}") Logger.info(f"EditPopup: - Edited image: {image_path}") Logger.info(f"EditPopup: - Metadata: {metadata_path}") try: response = requests.post(upload_url, headers=headers, files=files, data=data, timeout=30, verify=False) if response.status_code == 200: response_data = response.json() Logger.info(f"EditPopup: ✅ Successfully uploaded edited media to server") Logger.info(f"EditPopup: Server response: {response_data}") # DO NOT delete local files - keep them as backup # In case the server doesn't process them, we want to keep the edits locally Logger.info(f"EditPopup: ✓ Keeping local edited files as backup:") Logger.info(f" - Image: {image_path}") Logger.info(f" - Metadata: {metadata_path}") # Trigger playlist reload if server provides new version try: new_version = response_data.get('new_playlist_version') if new_version: Logger.info(f"EditPopup: 📡 Server reports new playlist version: {new_version}") Logger.info(f"EditPopup: Triggering playlist reload on next cycle...") # Playlist reload disabled for now - was causing crashes # Will be re-enabled with better implementation Logger.info(f"EditPopup: ✓ Edited media uploaded successfully") except Exception as e: Logger.warning(f"EditPopup: Could not process playlist version from server: {e}") return True elif response.status_code == 404: Logger.error("EditPopup: ❌ Upload endpoint not found on server (404)") Logger.error("EditPopup: Server may not support edited media uploads") Logger.error("EditPopup: Edited media is saved locally only") return False elif response.status_code == 401: Logger.error("EditPopup: ❌ Authentication failed (401) - check auth credentials") Logger.error("EditPopup: Edited media is saved locally only") return False else: Logger.error(f"EditPopup: ❌ Upload failed with status {response.status_code}") Logger.error(f"EditPopup: Response: {response.text}") Logger.error("EditPopup: Edited media is saved locally only") return False except requests.exceptions.Timeout: Logger.error("EditPopup: ❌ Upload timed out after 30 seconds") Logger.error("EditPopup: Check network connection") Logger.error("EditPopup: Edited media is saved locally only") return False except requests.exceptions.ConnectionError as e: Logger.error(f"EditPopup: ❌ Cannot connect to server: {e}") Logger.error("EditPopup: Check server URL and network connection") Logger.error("EditPopup: Edited media is saved locally only") return False except Exception as e: Logger.error(f"EditPopup: ❌ Unexpected error during upload: {e}") import traceback Logger.error(f"EditPopup: Traceback: {traceback.format_exc()}") Logger.error("EditPopup: Edited media is saved locally only") return False def close_without_saving(self, instance): """Close without saving""" Logger.info("EditPopup: Closed without saving") self.dismiss() def on_popup_dismiss(self, *args): """Resume playback when popup closes - reload current image and continue""" # Cancel countdown and auto-close timers if self.countdown_event: Clock.unschedule(self.countdown_event) if self.auto_close_event: Clock.unschedule(self.auto_close_event) # Force remove current widget immediately if self.player.current_widget: Logger.info("EditPopup: Removing current widget to force reload") self.player.ids.content_area.remove_widget(self.player.current_widget) self.player.current_widget = None Logger.info("EditPopup: ✓ Widget removed, ready for fresh load") # Resume playback if it wasn't paused before editing if not self.was_paused: self.player.is_paused = False # Update button icon to pause (to show it's playing) self.player.ids.play_pause_btn.background_normal = self.player.resources_path + '/pause.png' self.player.ids.play_pause_btn.background_down = self.player.resources_path + '/pause.png' # Add delay to ensure file write is complete and synced def reload_media(dt): Logger.info("EditPopup: ▶ Resuming playback and reloading edited image (force_reload=True)") self.player.play_current_media(force_reload=True) Clock.schedule_once(reload_media, 0.5) else: Logger.info("EditPopup: Dismissed, keeping paused state") # Restart control hide timer self.player.schedule_hide_controls()