Compare commits

...

2 Commits

Author SHA1 Message Date
Kiwy Signage Player
fba2007bdf Improve edit interface: full-screen layout with toolbars, versioned saving to edited_media folder 2025-12-05 00:25:53 +02:00
Kiwy Signage Player
72d382b96b Add image editing feature with drawing tools
- Added pencil edit button to player controls
- Created EditPopup with drawing layer for image annotation
- Drawing tools: color selection (red/blue/green/black), thickness control
- Features: undo last stroke, clear all strokes, save edited image
- Playback automatically pauses during editing
- Only images (.jpg, .jpeg, .png, .bmp) can be edited
- Edited images saved with '_edited' suffix in same directory
- Drawing layer with touch support for annotations
- Full toolbar with color, thickness, and action controls
2025-12-04 22:40:05 +02:00
5 changed files with 595 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
config/resources/pencil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

100
src/edit_drowing.py Normal file
View File

@@ -0,0 +1,100 @@
Kiwy drawing
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.image import Image
from kivy.uix.button import Button
from kivy.uix.widget import Widget
from kivy.graphics import Color, Line
from kivy.core.window import Window
class DrawLayer(Widget):
def init(self, **kwargs):
super().init(**kwargs)
self.strokes = [] # store all drawn lines
self.current_color = (1, 0, 0) # default red
self.current_width = 2 # default thickness
self.drawing_enabled = False # drawing toggle
def on_touch_down(self, touch):
if not self.drawing_enabled:
return False
with self.canvas:
Color(*self.current_color)
new_line = Line(points=[touch.x, touch.y], width=self.current_width)
self.strokes.append(new_line)
return True
def on_touch_move(self, touch):
if self.strokes and self.drawing_enabled:
self.strokes[-1].points += [touch.x, touch.y]
# ==========================
# UNDO LAST LINE
# ==========================
def undo(self):
if self.strokes:
last = self.strokes.pop()
self.canvas.remove(last)
# ==========================
# CHANGE COLOR
# ==========================
def set_color(self, color_tuple):
self.current_color = color_tuple
# ==========================
# CHANGE LINE WIDTH
# ==========================
def set_thickness(self, value):
self.current_width = value
class EditorUI(BoxLayout):
def init(self, **kwargs):
super().init(orientation="vertical", **kwargs)
# Background image
self.img = Image(source="graph.png", allow_stretch=True)
self.add_widget(self.img)
# Drawing layer above image
self.draw = DrawLayer()
self.add_widget(self.draw)
# Toolbar
toolbar = BoxLayout(size_hint_y=0.15)
toolbar.add_widget(Button(text="Draw On", on_press=self.toggle_draw))
toolbar.add_widget(Button(text="Red", on_press=lambda x: self.draw.set_color((1,0,0))))
toolbar.add_widget(Button(text="Blue", on_press=lambda x: self.draw.set_color((0,0,1))))
toolbar.add_widget(Button(text="Green", on_press=lambda x: self.draw.set_color((0,1,0))))
toolbar.add_widget(Button(text="Thin", on_press=lambda x: self.draw.set_thickness(2)))
toolbar.add_widget(Button(text="Thick", on_press=lambda x: self.draw.set_thickness(6)))
toolbar.add_widget(Button(text="Undo", on_press=lambda x: self.draw.undo()))
toolbar.add_widget(Button(text="Save", on_press=lambda x: self.save_image()))
self.add_widget(toolbar)
# ==========================
# TOGGLE DRAWING MODE
# ==========================
def toggle_draw(self, btn):
self.draw.drawing_enabled = not self.draw.drawing_enabled
btn.text = "Draw Off" if self.draw.drawing_enabled else "Draw On"
# ==========================
# SAVE MERGED IMAGE
# ==========================
def save_image(self):
self.export_to_png("edited_graph.png")
print("Saved as edited_graph.png")
class AnnotatorApp(App):
def build(self):
return EditorUI()
AnnotatorApp().run()

View File

@@ -46,12 +46,464 @@ from get_playlists import (
send_player_error_feedback
)
from keyboard_widget import KeyboardWidget
from kivy.graphics import Color, Line, Ellipse
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.slider import Slider
# Load the KV file
Builder.load_file('signage_player.kv')
# Removed VLCVideoWidget - using Kivy's built-in Video widget instead
class DrawingLayer(Widget):
"""Layer for drawing on top of images"""
def __init__(self, **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
def on_touch_down(self, touch):
if not self.drawing_enabled or not self.collide_point(*touch.pos):
return False
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, **kwargs):
super(EditPopup, self).__init__(**kwargs)
self.player = player_instance
self.image_path = image_path
self.drawing_layer = None
# Pause playback
self.was_paused = self.player.is_paused
if not self.was_paused:
self.player.is_paused = True
Clock.unschedule(self.player.next_media)
if self.player.current_widget and isinstance(self.player.current_widget, Video):
self.player.current_widget.state = 'pause'
# Show cursor
try:
Window.show_cursor = True
except:
pass
# Build UI - Full screen without title bar
self.title = '' # No title
self.size_hint = (1, 1) # Full screen
self.auto_dismiss = False
self.separator_height = 0 # Remove separator
# Main container (FloatLayout for overlays)
main_container = FloatLayout()
# Background image (full screen)
self.image_widget = Image(
source=image_path,
allow_stretch=True,
keep_ratio=True,
size_hint=(1, 1),
pos_hint={'x': 0, 'y': 0}
)
main_container.add_widget(self.image_widget)
# Drawing layer on top of image (full screen)
self.drawing_layer = DrawingLayer(
size_hint=(1, 1),
pos_hint={'x': 0, 'y': 0}
)
main_container.add_widget(self.drawing_layer)
# Top toolbar (horizontal, 56dp height - 20% smaller than 70dp, 50% transparency)
top_toolbar = BoxLayout(
orientation='horizontal',
size_hint=(1, None),
height=56,
pos_hint={'top': 1, 'x': 0},
spacing=10,
padding=[10, 8]
)
# Add semi-transparent background to toolbar
from kivy.graphics import Color, Rectangle
with top_toolbar.canvas.before:
Color(0.1, 0.1, 0.1, 0.5) # 50% transparency
top_toolbar.bg_rect = Rectangle(size=top_toolbar.size, pos=top_toolbar.pos)
top_toolbar.bind(pos=self._update_toolbar_bg, size=self._update_toolbar_bg)
# Action buttons in top toolbar (buttons with slightly rounded corners)
top_toolbar.add_widget(Widget()) # Spacer
undo_btn = Button(
text='Undo',
font_size='16sp',
size_hint=(None, 1),
width=100,
background_normal='',
background_color=(0.9, 0.6, 0.2, 0.9),
on_press=lambda x: self.drawing_layer.undo()
)
undo_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
top_toolbar.add_widget(undo_btn)
clear_btn = Button(
text='Clear',
font_size='16sp',
size_hint=(None, 1),
width=100,
background_normal='',
background_color=(0.9, 0.3, 0.2, 0.9),
on_press=lambda x: self.drawing_layer.clear_all()
)
clear_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
top_toolbar.add_widget(clear_btn)
save_btn = Button(
text='Save',
font_size='16sp',
size_hint=(None, 1),
width=100,
background_normal='',
background_color=(0.2, 0.8, 0.2, 0.9),
on_press=self.save_image
)
save_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
top_toolbar.add_widget(save_btn)
cancel_btn = Button(
text='Cancel',
font_size='16sp',
size_hint=(None, 1),
width=100,
background_normal='',
background_color=(0.6, 0.2, 0.2, 0.9),
on_press=self.close_without_saving
)
cancel_btn.bind(pos=self._make_rounded_btn, size=self._make_rounded_btn)
top_toolbar.add_widget(cancel_btn)
top_toolbar.add_widget(Widget()) # Spacer
main_container.add_widget(top_toolbar)
# Right sidebar (vertical, 56dp width to match toolbar height, 50% transparency)
right_sidebar = BoxLayout(
orientation='vertical',
size_hint=(None, 1),
width=56,
pos_hint={'right': 1, 'y': 0},
spacing=10,
padding=[8, 66, 8, 10] # Extra top padding to avoid toolbar (56 + 10)
)
# Add semi-transparent background to sidebar
with right_sidebar.canvas.before:
Color(0.1, 0.1, 0.1, 0.5) # 50% transparency
right_sidebar.bg_rect = Rectangle(size=right_sidebar.size, pos=right_sidebar.pos)
right_sidebar.bind(pos=self._update_sidebar_bg, size=self._update_sidebar_bg)
# Color section with icon
color_header = BoxLayout(
orientation='vertical',
size_hint_y=None,
height=55,
spacing=2
)
pen_icon_path = os.path.join(self.player.resources_path, 'edit-pen.png')
color_icon_img = Image(
source=pen_icon_path,
size_hint_y=None,
height=28,
allow_stretch=True,
keep_ratio=True
)
color_label = Label(
text='Color',
font_size='11sp',
bold=True,
size_hint_y=None,
height=25
)
color_header.add_widget(color_icon_img)
color_header.add_widget(color_label)
right_sidebar.add_widget(color_header)
# Color buttons (round)
colors = [
('R', (1, 0, 0, 1)),
('B', (0, 0, 1, 1)),
('G', (0, 1, 0, 1)),
('K', (0, 0, 0, 1)),
('W', (1, 1, 1, 1))
]
for text, color in colors:
color_btn = Button(
text=text,
font_size='18sp',
size_hint=(1, None),
height=50,
background_normal='',
background_color=color,
on_press=lambda x, c=color: self.drawing_layer.set_color(c)
)
color_btn.bind(pos=self._make_round, size=self._make_round)
right_sidebar.add_widget(color_btn)
# Spacer
right_sidebar.add_widget(Widget(size_hint_y=0.2))
# Thickness section with icon
thickness_header = BoxLayout(
orientation='vertical',
size_hint_y=None,
height=55,
spacing=2
)
thickness_icon_img = Image(
source=pen_icon_path,
size_hint_y=None,
height=28,
allow_stretch=True,
keep_ratio=True
)
thickness_label = Label(
text='Size',
font_size='11sp',
bold=True,
size_hint_y=None,
height=25
)
thickness_header.add_widget(thickness_icon_img)
thickness_header.add_widget(thickness_label)
right_sidebar.add_widget(thickness_header)
# Thickness buttons (round, visual representation)
thicknesses = [
('S', 2),
('M', 5),
('L', 10)
]
for text, thickness in thicknesses:
thick_btn = Button(
text=text,
font_size='20sp',
bold=True,
size_hint=(1, None),
height=50,
background_normal='',
background_color=(0.3, 0.3, 0.3, 0.9),
on_press=lambda x, t=thickness: self.drawing_layer.set_thickness(t)
)
thick_btn.bind(pos=self._make_round, size=self._make_round)
right_sidebar.add_widget(thick_btn)
right_sidebar.add_widget(Widget()) # Bottom spacer
main_container.add_widget(right_sidebar)
self.content = main_container
self.top_toolbar = top_toolbar
self.right_sidebar = right_sidebar
# Bind to dismiss
self.bind(on_dismiss=self.on_popup_dismiss)
Logger.info(f"EditPopup: Opened for image {os.path.basename(image_path)}")
def _update_toolbar_bg(self, instance, value):
"""Update toolbar background rectangle"""
self.top_toolbar.bg_rect.pos = instance.pos
self.top_toolbar.bg_rect.size = instance.size
def _update_sidebar_bg(self, instance, value):
"""Update sidebar background rectangle"""
self.right_sidebar.bg_rect.pos = instance.pos
self.right_sidebar.bg_rect.size = instance.size
def _make_rounded_btn(self, instance, value):
"""Make toolbar button with slightly rounded corners"""
instance.canvas.before.clear()
with instance.canvas.before:
from kivy.graphics import RoundedRectangle
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:
from kivy.graphics import RoundedRectangle
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:
import json
import re
from datetime import datetime
# 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.top_toolbar.opacity = 0
self.right_sidebar.opacity = 0
# Force canvas update
self.content.canvas.ask_update()
# Small delay to ensure rendering is complete
from kivy.clock import Clock
def do_export(dt):
try:
# Export only the visible content (image + drawings, no toolbars)
self.content.export_to_png(output_path)
# Restore toolbars
self.top_toolbar.opacity = 1
self.right_sidebar.opacity = 1
# Create and save metadata
self._save_metadata(edited_dir, new_name, base_name,
new_version if version_match else 1, output_filename)
Logger.info(f"EditPopup: Saved edited image to {output_path}")
# Show confirmation
self.title = f'Saved as {output_filename}'
Clock.schedule_once(lambda dt: self.dismiss(), 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.top_toolbar.opacity = 1
self.right_sidebar.opacity = 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"""
import json
from datetime import datetime
metadata = {
'time_of_modification': datetime.now().isoformat(),
'original_name': base_name,
'new_name': output_filename,
'original_path': self.image_path,
'version': version
}
# 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}")
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"""
# Resume playback if it wasn't paused before
if not self.was_paused:
self.player.is_paused = False
self.player.play_current_media()
# Restart control hide timer
self.player.schedule_hide_controls()
Logger.info("EditPopup: Dismissed, playback resumed")
# Custom keyboard container with close button
class KeyboardContainer(BoxLayout):
def __init__(self, vkeyboard, **kwargs):
@@ -1070,6 +1522,39 @@ class SignagePlayer(Widget):
popup = SettingsPopup(player_instance=self, was_paused=was_paused)
popup.open()
def show_edit_interface(self, instance=None):
"""Show edit interface for current image"""
# Check if current media is an image
if not self.playlist or self.current_index >= len(self.playlist):
Logger.warning("SignagePlayer: No media to edit")
return
media_item = self.playlist[self.current_index]
file_name = media_item.get('file_name', '')
file_extension = os.path.splitext(file_name)[1].lower()
# Only allow editing images
if file_extension not in ['.jpg', '.jpeg', '.png', '.bmp']:
Logger.warning(f"SignagePlayer: Cannot edit {file_extension} files, only images")
# Show error message briefly
self.ids.status_label.text = 'Can only edit image files'
self.ids.status_label.opacity = 1
Clock.schedule_once(lambda dt: setattr(self.ids.status_label, 'opacity', 0), 2)
return
# Get full path to current image
image_path = os.path.join(self.media_dir, file_name)
if not os.path.exists(image_path):
Logger.error(f"SignagePlayer: Image not found: {image_path}")
return
Logger.info(f"SignagePlayer: Opening edit interface for {file_name}")
# Open edit popup
popup = EditPopup(player_instance=self, image_path=image_path)
popup.open()
def show_exit_popup(self, instance=None):
"""Show exit password popup"""
# Pause playback when exit popup opens

View File

@@ -224,12 +224,12 @@
pos: self.pos
radius: [dp(15)]
# New control panel overlay (bottom center, 1/6 width, 90% transparent)
# New control panel overlay (bottom center, width for 6 buttons, 90% transparent)
BoxLayout:
id: controls_layout
orientation: 'horizontal'
size_hint: None, None
width: root.width / 6 if root.width > 0 else dp(260)
width: dp(370)
height: dp(70)
pos: (root.width - self.width) / 2, dp(10)
opacity: 1
@@ -261,6 +261,14 @@
border: (0, 0, 0, 0)
on_press: root.toggle_pause()
Button:
id: edit_btn
size_hint: None, None
size: dp(50), dp(50)
background_normal: root.resources_path + '/pencil.png'
background_down: root.resources_path + '/pencil.png'
border: (0, 0, 0, 0)
on_press: root.show_edit_interface()
Button:
id: settings_btn
size_hint: None, None