Refactor: Move UI definitions to KV file and modularize edit popup

- Created src/edit_popup.py module for EditPopup and DrawingLayer classes
- Moved EditPopup UI definition to signage_player.kv (reduced main.py by 533 lines)
- Moved CardSwipePopup UI definition to signage_player.kv (reduced main.py by 41 lines)
- Improved code organization with better separation of concerns
- main.py reduced from 2,384 to 1,811 lines (24% reduction)
- All functionality preserved, no breaking changes
This commit is contained in:
Kiwy Signage Player
2025-12-14 14:48:35 +02:00
parent db796e4d66
commit b2d380511a
5 changed files with 872 additions and 792 deletions
+527
View File
@@ -0,0 +1,527 @@
"""
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
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:
touch.ud['line'].points += [touch.x, touch.y]
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
Logger.debug(f"DrawingLayer: Color set to {color_tuple}")
def set_thickness(self, value):
"""Set line thickness"""
self.current_width = value
Logger.debug(f"DrawingLayer: Thickness set to {value}")
class EditPopup(Popup):
"""Popup for editing/annotating images"""
def __init__(self, player_instance, image_path, user_card_data=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
# 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:
# Create edited_media directory if it doesn't exist
edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media')
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
os.sync()
# 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
json_filename = self._save_metadata(edited_dir, new_name, base_name,
new_version if version_match else 1, output_filename)
# 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()
Logger.info(f"EditPopup: Background upload thread started")
# NOW show saving popup AFTER everything is done
def show_saving_and_dismiss(dt):
# Create label with background
save_label = Label(
text='✓ Saved! Reloading player...',
font_size='36sp',
color=(1, 1, 1, 1),
bold=True
)
saving_popup = Popup(
title='',
content=save_label,
size_hint=(0.8, 0.3),
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")
# Dismiss both popups after 2 seconds
def dismiss_all(dt):
saving_popup.dismiss()
Logger.info(f"EditPopup: Dismissing to resume playback...")
self.dismiss()
Clock.schedule_once(dismiss_all, 2.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)
}
# 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 (server will not receive edited media)")
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
with open(metadata_path, 'r') as meta_file:
metadata = json.load(meta_file)
# Prepare upload URL
upload_url = f"{server_url}/api/player-edit-media"
headers = {'Authorization': f'Bearer {auth_code}'}
# Prepare file and data for upload
with open(image_path, 'rb') as img_file:
files = {
'image_file': (metadata['new_name'], img_file, 'image/jpeg')
}
# Send metadata as JSON string in form data
data = {
'metadata': json.dumps(metadata)
}
Logger.info(f"EditPopup: Uploading edited media to {upload_url}")
response = requests.post(upload_url, headers=headers, files=files, data=data, timeout=30)
if response.status_code == 200:
response_data = response.json()
Logger.info(f"EditPopup: ✅ Successfully uploaded edited media to server: {response_data}")
# Delete local files after successful upload
try:
if os.path.exists(image_path):
os.remove(image_path)
Logger.info(f"EditPopup: Deleted local image file: {os.path.basename(image_path)}")
if os.path.exists(metadata_path):
os.remove(metadata_path)
Logger.info(f"EditPopup: Deleted local metadata file: {os.path.basename(metadata_path)}")
Logger.info("EditPopup: ✅ Local edited files cleaned up after successful upload")
except Exception as e:
Logger.warning(f"EditPopup: Could not delete local files: {e}")
return True
elif response.status_code == 404:
Logger.warning("EditPopup: ⚠️ Upload endpoint not available on server (404) - edited media saved locally only")
return False
else:
Logger.warning(f"EditPopup: ⚠️ Upload failed with status {response.status_code} - edited media saved locally only")
return False
except requests.exceptions.Timeout:
Logger.warning("EditPopup: ⚠️ Upload timed out - edited media saved locally only")
return False
except requests.exceptions.ConnectionError:
Logger.warning("EditPopup: ⚠️ Cannot connect to server - edited media saved locally only")
return False
except Exception as e:
Logger.warning(f"EditPopup: ⚠️ Upload failed: {e} - edited media saved locally only")
import traceback
Logger.debug(f"EditPopup: Upload traceback: {traceback.format_exc()}")
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()