3ac7f836c4
Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run.
641 lines
30 KiB
Python
641 lines
30 KiB
Python
"""
|
|
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/<media_id>/', 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/<media_id>/'. 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()
|