Compare commits
13 Commits
main
...
31ad592e98
| Author | SHA1 | Date | |
|---|---|---|---|
| 31ad592e98 | |||
| 5c2b3f545f | |||
| d0ea94447a | |||
| 12f2880201 | |||
| 5a030671a2 | |||
| a2add88f04 | |||
| c4e8381898 | |||
| ced6e10919 | |||
| 844e5eeebb | |||
| 7efc023327 | |||
| 6abde5a767 | |||
| 362f5096a0 | |||
| 3845830a86 |
@@ -25,6 +25,7 @@ wheels/
|
|||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
|
windows/venv312/
|
||||||
|
|
||||||
# Kivy
|
# Kivy
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -59,3 +60,5 @@ playlists/server_playlist_*.json
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
.player_heartbear
|
.player_heartbear
|
||||||
|
|
||||||
|
windows/venv_build/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"server_ip": "192.168.0.109",
|
"server_ip": "192.168.0.107",
|
||||||
"port": "8080",
|
"port": "8080",
|
||||||
"screen_name": "Birou_IT",
|
"screen_name": "WINDOWS-PC",
|
||||||
"quickconnect_key": "8887779",
|
"quickconnect_key": "8887779",
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"touch": "True",
|
"touch": "True",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Python 3.12.9
|
||||||
+15
-1
@@ -354,7 +354,9 @@ def delete_unused_media(playlist_data, media_dir):
|
|||||||
rel_path = os.path.relpath(full_path, media_dir)
|
rel_path = os.path.relpath(full_path, media_dir)
|
||||||
|
|
||||||
# Skip if file is in current playlist
|
# Skip if file is in current playlist
|
||||||
if rel_path in referenced_files:
|
# Normalize paths to handle Windows backslashes vs server forward slashes
|
||||||
|
normalized_rel = rel_path.replace('\\', '/')
|
||||||
|
if normalized_rel in referenced_files or rel_path in referenced_files:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Delete unreferenced file
|
# Delete unreferenced file
|
||||||
@@ -454,6 +456,18 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
|
|||||||
return playlist_file
|
return playlist_file
|
||||||
else:
|
else:
|
||||||
logger.info("✓ Playlist is up to date")
|
logger.info("✓ Playlist is up to date")
|
||||||
|
# Even when the playlist version matches, ensure media files exist locally.
|
||||||
|
# The media folder might be empty (e.g. fresh install or deleted files).
|
||||||
|
logger.info("📥 Checking for missing media files...")
|
||||||
|
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||||
|
server_url = auth.auth_data.get('server_url', '')
|
||||||
|
downloaded = download_media_files(
|
||||||
|
server_data.get('playlist', []), media_dir, ssl_manager, server_url
|
||||||
|
)
|
||||||
|
if downloaded:
|
||||||
|
server_data['playlist'] = downloaded
|
||||||
|
# Re-save playlist with updated URLs if needed
|
||||||
|
save_playlist(server_data, playlist_dir)
|
||||||
return playlist_file
|
return playlist_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+447
-42
@@ -8,33 +8,38 @@ PLAYER_VERSION = "1.2.0"
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import platform
|
import platform
|
||||||
|
import signal
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
# Set environment variables for better video performance
|
# Set environment variables for better video performance
|
||||||
os.environ['KIVY_VIDEO'] = 'ffpyplayer' # Use ffpyplayer as video provider
|
# Use setdefault() so that a wrapper script (e.g. run_win.py) can pre-set
|
||||||
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8' # Support common codecs
|
# Windows-compatible values before this module is imported.
|
||||||
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0' # Prevent screen saver
|
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
|
||||||
os.environ['SDL_VIDEODRIVER'] = 'wayland,x11,dummy' # Prefer Wayland, fallback to X11, then dummy
|
os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
|
||||||
os.environ['SDL_AUDIODRIVER'] = 'alsa,pulse,dummy' # Prefer ALSA, fallback to pulse, then dummy
|
os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0')
|
||||||
|
os.environ.setdefault('SDL_VIDEODRIVER', 'wayland,x11,dummy')
|
||||||
|
os.environ.setdefault('SDL_AUDIODRIVER', 'alsa,pulse,dummy')
|
||||||
|
|
||||||
# Video playback optimizations
|
# Video playback optimizations
|
||||||
# Note: pygame backend requires X11/Wayland context; let Kivy auto-detect for better compatibility
|
os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer')
|
||||||
# os.environ['KIVY_WINDOW'] = 'pygame' # Use pygame backend for better performance
|
os.environ.setdefault('KIVY_GL_BACKEND', 'gl')
|
||||||
os.environ['KIVY_AUDIO'] = 'ffpyplayer' # Use ffpyplayer for audio
|
os.environ.setdefault('KIVY_INPUTPROVIDERS', 'wayland,x11')
|
||||||
os.environ['KIVY_GL_BACKEND'] = 'gl' # Use OpenGL backend
|
os.environ.setdefault('FFMPEG_THREADS', '2')
|
||||||
os.environ['KIVY_INPUTPROVIDERS'] = 'wayland,x11' # Only use Wayland and X11 input providers, skip problematic ones
|
os.environ.setdefault('LIBPLAYER_BUFFER', '1048576')
|
||||||
os.environ['FFMPEG_THREADS'] = '2' # Use 2 threads for ffmpeg decoding (Raspberry Pi has limited resources)
|
os.environ.setdefault('SDL_AUDIODRIVER', 'alsa')
|
||||||
os.environ['LIBPLAYER_BUFFER'] = '1048576' # 1MB buffer (reduced from 2MB to save memory)
|
|
||||||
os.environ['SDL_AUDIODRIVER'] = 'alsa' # Use ALSA for better audio on Pi
|
|
||||||
|
|
||||||
# Configure Kivy BEFORE importing any Kivy modules
|
# Configure Kivy BEFORE importing any Kivy modules
|
||||||
from kivy.config import Config
|
from kivy.config import Config
|
||||||
|
|
||||||
# Performance optimizations for video playback
|
# Performance optimizations for video playback
|
||||||
Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard
|
Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard
|
||||||
|
# Kiosk requirement: Escape must NEVER close the player. The only exit path
|
||||||
|
# is the password-protected exit button. This stays disabled even during
|
||||||
|
# development so exit behavior always mirrors production.
|
||||||
|
Config.set('kivy', 'exit_on_escape', '0')
|
||||||
Config.set('graphics', 'fullscreen', '0') # Will be set to 1 later
|
Config.set('graphics', 'fullscreen', '0') # Will be set to 1 later
|
||||||
Config.set('graphics', 'window_state', 'maximized') # Maximize window
|
Config.set('graphics', 'window_state', 'maximized') # Maximize window
|
||||||
|
|
||||||
@@ -85,9 +90,14 @@ from edit_popup import DrawingLayer, EditPopup
|
|||||||
from kivy.graphics import Color, Line, Ellipse
|
from kivy.graphics import Color, Line, Ellipse
|
||||||
from kivy.uix.floatlayout import FloatLayout
|
from kivy.uix.floatlayout import FloatLayout
|
||||||
from kivy.uix.slider import Slider
|
from kivy.uix.slider import Slider
|
||||||
|
from playback_trace import trace # always-on playback transition logger
|
||||||
|
|
||||||
# Load the KV file
|
# Load the KV file - resolve relative to this file's directory
|
||||||
Builder.load_file('signage_player.kv')
|
_kv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'signage_player.kv')
|
||||||
|
if not os.path.exists(_kv_path):
|
||||||
|
# Fallback: relative to cwd (for PyInstaller bundled runs)
|
||||||
|
_kv_path = 'signage_player.kv'
|
||||||
|
Builder.load_file(_kv_path)
|
||||||
|
|
||||||
|
|
||||||
class CardReader:
|
class CardReader:
|
||||||
@@ -577,16 +587,8 @@ class ExitPasswordPopup(Popup):
|
|||||||
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
|
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
|
||||||
# Hide and remove keyboard
|
# Hide and remove keyboard
|
||||||
self.hide_keyboard()
|
self.hide_keyboard()
|
||||||
|
# Resume playback and re-arm the media advance timer
|
||||||
# Resume playback if it wasn't paused before
|
self.player.resume_after_popup(self.was_paused)
|
||||||
if not self.was_paused:
|
|
||||||
self.player.is_paused = False
|
|
||||||
# Resume video if it was playing
|
|
||||||
if self.player.current_widget and isinstance(self.player.current_widget, Video):
|
|
||||||
self.player.current_widget.state = 'play'
|
|
||||||
|
|
||||||
# Restart the control hide timer
|
|
||||||
self.player.schedule_hide_controls()
|
|
||||||
|
|
||||||
def check_password(self):
|
def check_password(self):
|
||||||
"""Check if entered password matches quickconnect key"""
|
"""Check if entered password matches quickconnect key"""
|
||||||
@@ -609,6 +611,8 @@ class ExitPasswordPopup(Popup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.warning(f"ExitPasswordPopup: Could not create stop flag: {e}")
|
Logger.warning(f"ExitPasswordPopup: Could not create stop flag: {e}")
|
||||||
|
|
||||||
|
# Allow the app to close (releases the production-mode close guard)
|
||||||
|
self.player.set_allow_exit(True)
|
||||||
self.dismiss()
|
self.dismiss()
|
||||||
App.get_running_app().stop()
|
App.get_running_app().stop()
|
||||||
else:
|
else:
|
||||||
@@ -655,6 +659,9 @@ class SettingsPopup(Popup):
|
|||||||
self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}'
|
self.ids.media_count_info.text = f'Media: {len(self.player.playlist)}'
|
||||||
self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}'
|
self.ids.status_info.text = f'Status: {"Playing" if self.player.is_playing else "Paused" if self.player.is_paused else "Idle"}'
|
||||||
|
|
||||||
|
# Refresh the production-mode button to reflect the current state
|
||||||
|
self.update_production_button()
|
||||||
|
|
||||||
# Bind to dismiss event to manage cursor visibility and resume playback
|
# Bind to dismiss event to manage cursor visibility and resume playback
|
||||||
self.bind(on_dismiss=self.on_popup_dismiss)
|
self.bind(on_dismiss=self.on_popup_dismiss)
|
||||||
|
|
||||||
@@ -699,16 +706,44 @@ class SettingsPopup(Popup):
|
|||||||
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
|
"""Handle popup dismissal - resume playback and restart cursor hide timer"""
|
||||||
# Hide and remove keyboard
|
# Hide and remove keyboard
|
||||||
self.hide_keyboard()
|
self.hide_keyboard()
|
||||||
|
# Resume playback and re-arm the media advance timer
|
||||||
|
self.player.resume_after_popup(self.was_paused)
|
||||||
|
|
||||||
# Resume playback if it wasn't paused before
|
def update_production_button(self):
|
||||||
if not self.was_paused:
|
"""Refresh the production-mode button to reflect the current state.
|
||||||
self.player.is_paused = False
|
|
||||||
# Resume video if it was playing
|
|
||||||
if self.player.current_widget and isinstance(self.player.current_widget, Video):
|
|
||||||
self.player.current_widget.state = 'play'
|
|
||||||
|
|
||||||
# Restart the control hide timer
|
Green when production (kiosk) mode is enabled, grey when disabled.
|
||||||
self.player.schedule_hide_controls()
|
"""
|
||||||
|
production = bool(self.player.config.get('production_mode', False))
|
||||||
|
btn = self.ids.production_mode_btn
|
||||||
|
if production:
|
||||||
|
btn.background_color = (0.2, 0.7, 0.2, 1) # green
|
||||||
|
btn.text = 'Production ON'
|
||||||
|
else:
|
||||||
|
btn.background_color = (0.4, 0.4, 0.4, 1) # grey
|
||||||
|
btn.text = 'Enable Production'
|
||||||
|
|
||||||
|
def toggle_production_mode(self):
|
||||||
|
"""Toggle production (kiosk) mode and apply the lockdown immediately."""
|
||||||
|
production = not bool(self.player.config.get('production_mode', False))
|
||||||
|
Logger.info(
|
||||||
|
f"SettingsPopup: {'Enabling' if production else 'Disabling'} "
|
||||||
|
"production mode"
|
||||||
|
)
|
||||||
|
# Apply lockdown (exit_on_escape, close guard, Ctrl+C, platform hooks)
|
||||||
|
self.player.apply_kiosk_mode(production)
|
||||||
|
self.player.save_config()
|
||||||
|
self.update_production_button()
|
||||||
|
if production:
|
||||||
|
self._show_temp_message(
|
||||||
|
'✓ Production mode ENABLED — kiosk lockdown active',
|
||||||
|
(0, 1, 0, 1)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._show_temp_message(
|
||||||
|
'✓ Production mode DISABLED — development mode',
|
||||||
|
(0.8, 0.8, 0.8, 1)
|
||||||
|
)
|
||||||
|
|
||||||
def test_connection(self):
|
def test_connection(self):
|
||||||
"""Test connection to server with current credentials"""
|
"""Test connection to server with current credentials"""
|
||||||
@@ -929,6 +964,16 @@ class SignagePlayer(Widget):
|
|||||||
self.auto_resume_event = None # Track scheduled auto-resume
|
self.auto_resume_event = None # Track scheduled auto-resume
|
||||||
self.config = {}
|
self.config = {}
|
||||||
self.playlist_version = None
|
self.playlist_version = None
|
||||||
|
self._allow_exit = False # Set True by the password exit flow to allow the app to close
|
||||||
|
self._media_started_at = None # monotonic time the current media started
|
||||||
|
self._media_duration = 10 # scheduled duration of the current media
|
||||||
|
self._video_eos_pending = False # guards against duplicate EOS callbacks on one video
|
||||||
|
self._last_advance_at = 0.0 # monotonic time of the last next_media advance (dedupe guard)
|
||||||
|
self._focus_keeper_event = None # Clock interval that keeps the window focused during video
|
||||||
|
self._focus_keeper_elapsed = 0.0
|
||||||
|
self._focus_keeper_duration = 0.0
|
||||||
|
self._video_watchdog_event = None # Clock interval that watches video progress
|
||||||
|
self._video_watchdog_stopped = False
|
||||||
# self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes)
|
# self.should_refresh_playlist = False # Flag to reload playlist after edit upload (DISABLED - causing crashes)
|
||||||
self.consecutive_errors = 0 # Track consecutive playback errors
|
self.consecutive_errors = 0 # Track consecutive playback errors
|
||||||
self.max_consecutive_errors = 10 # Maximum errors before stopping
|
self.max_consecutive_errors = 10 # Maximum errors before stopping
|
||||||
@@ -954,6 +999,11 @@ class SignagePlayer(Widget):
|
|||||||
# Bind to window size for fullscreen
|
# Bind to window size for fullscreen
|
||||||
Window.bind(size=self._update_size)
|
Window.bind(size=self._update_size)
|
||||||
self._update_size(Window, Window.size)
|
self._update_size(Window, Window.size)
|
||||||
|
# Bind the window-close guard. In production mode it returns True
|
||||||
|
# (blocking X / Alt+F4 / any WM close) until the password exit flow
|
||||||
|
# calls set_allow_exit(True). In development mode it returns False so
|
||||||
|
# the window closes normally.
|
||||||
|
Window.bind(on_request_close=self._guard_window_close)
|
||||||
# Initialize player
|
# Initialize player
|
||||||
Clock.schedule_once(self.initialize_player, 0.1)
|
Clock.schedule_once(self.initialize_player, 0.1)
|
||||||
# Hide controls timer
|
# Hide controls timer
|
||||||
@@ -970,6 +1020,96 @@ class SignagePlayer(Widget):
|
|||||||
if hasattr(self, 'ids') and 'content_area' in self.ids:
|
if hasattr(self, 'ids') and 'content_area' in self.ids:
|
||||||
self.ids.content_area.size = value
|
self.ids.content_area.size = value
|
||||||
|
|
||||||
|
def _guard_window_close(self, *args, **kwargs):
|
||||||
|
"""Block window-close attempts in production mode unless exit allowed.
|
||||||
|
|
||||||
|
Bound to Window's `on_request_close` event. Kivy's SDL2 provider
|
||||||
|
dispatches this for the window close button / Alt+F4 / WM close.
|
||||||
|
Returning True cancels the close. The password exit flow calls
|
||||||
|
set_allow_exit(True) just before App.stop(), which lets the app
|
||||||
|
terminate even while production mode is active.
|
||||||
|
"""
|
||||||
|
if not self.config.get('production_mode', False):
|
||||||
|
return False # development mode — allow normal window close
|
||||||
|
if getattr(self, '_allow_exit', False):
|
||||||
|
return False # legitimate password-protected exit
|
||||||
|
Logger.info("SignagePlayer: Window close blocked (production mode)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_allow_exit(self, allow=True):
|
||||||
|
"""Allow the app to actually close (called by the password exit flow)."""
|
||||||
|
self._allow_exit = bool(allow)
|
||||||
|
|
||||||
|
def resume_after_popup(self, was_paused):
|
||||||
|
"""Resume playback and re-arm the media advance timer after a modal
|
||||||
|
popup (settings / exit-password) is dismissed.
|
||||||
|
|
||||||
|
`show_settings()` / `show_exit_popup()` unschedule next_media while
|
||||||
|
the popup is open. Without re-arming here the current media would sit
|
||||||
|
forever once the popup closes, so the playlist stops advancing.
|
||||||
|
"""
|
||||||
|
# Always restart the control-hide timer.
|
||||||
|
self.schedule_hide_controls()
|
||||||
|
if was_paused:
|
||||||
|
return # user had already paused playback — stay paused
|
||||||
|
|
||||||
|
self.is_paused = False
|
||||||
|
# Resume a paused video.
|
||||||
|
if self.current_widget and isinstance(self.current_widget, Video):
|
||||||
|
try:
|
||||||
|
self.current_widget.state = 'play'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Weblinks are advanced by their own watchdog thread — don't touch.
|
||||||
|
if getattr(self, '_weblink_proc', None) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Re-arm the advance timer with the remaining time.
|
||||||
|
started = getattr(self, '_media_started_at', None)
|
||||||
|
duration = getattr(self, '_media_duration', 10)
|
||||||
|
remaining = duration
|
||||||
|
if started is not None:
|
||||||
|
remaining = duration - (time.monotonic() - started)
|
||||||
|
remaining = max(0.5, remaining)
|
||||||
|
Logger.debug(
|
||||||
|
f"SignagePlayer: Re-arming next_media in {remaining:.1f}s after popup"
|
||||||
|
)
|
||||||
|
Clock.unschedule(self.next_media)
|
||||||
|
Clock.schedule_once(self.next_media, remaining)
|
||||||
|
|
||||||
|
def apply_kiosk_mode(self, enabled):
|
||||||
|
"""Enable/disable production (kiosk) lockdown.
|
||||||
|
|
||||||
|
When enabled:
|
||||||
|
- Escape never exits the app (exit_on_escape stays 0).
|
||||||
|
- Window close (X / Alt+F4) is blocked by _guard_window_close until
|
||||||
|
the password exit flow calls set_allow_exit(True).
|
||||||
|
- Ctrl+C is ignored so the console cannot kill the player.
|
||||||
|
Platform wrappers (e.g. run_win.py) may extend this to also swallow
|
||||||
|
Alt+Tab / Win / Ctrl+Esc via a low-level keyboard hook.
|
||||||
|
"""
|
||||||
|
self.config['production_mode'] = bool(enabled)
|
||||||
|
if enabled:
|
||||||
|
# Defense in depth: ensure Escape can never close the player.
|
||||||
|
try:
|
||||||
|
Config.set('kivy', 'exit_on_escape', '0')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Ignore Ctrl+C in production — the console must not kill the app.
|
||||||
|
try:
|
||||||
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
Logger.info("SignagePlayer: PRODUCTION MODE ENABLED — kiosk lockdown active")
|
||||||
|
else:
|
||||||
|
# Development mode: restore the default Ctrl+C handler.
|
||||||
|
try:
|
||||||
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
Logger.info("SignagePlayer: Production mode disabled — development mode")
|
||||||
|
|
||||||
def update_heartbeat(self, dt):
|
def update_heartbeat(self, dt):
|
||||||
"""Update heartbeat file to indicate player is alive"""
|
"""Update heartbeat file to indicate player is alive"""
|
||||||
try:
|
try:
|
||||||
@@ -1043,6 +1183,9 @@ class SignagePlayer(Widget):
|
|||||||
# Load configuration
|
# Load configuration
|
||||||
self.load_config()
|
self.load_config()
|
||||||
|
|
||||||
|
# Apply persisted production/kiosk mode (exit lockdown) if enabled
|
||||||
|
self.apply_kiosk_mode(self.config.get('production_mode', False))
|
||||||
|
|
||||||
# Initialize network monitor
|
# Initialize network monitor
|
||||||
self.start_network_monitoring()
|
self.start_network_monitoring()
|
||||||
|
|
||||||
@@ -1073,7 +1216,8 @@ class SignagePlayer(Widget):
|
|||||||
"quickconnect_key": "1234567",
|
"quickconnect_key": "1234567",
|
||||||
"max_resolution": "auto",
|
"max_resolution": "auto",
|
||||||
"use_https": True,
|
"use_https": True,
|
||||||
"verify_ssl": True
|
"verify_ssl": True,
|
||||||
|
"production_mode": False
|
||||||
}
|
}
|
||||||
self.save_config()
|
self.save_config()
|
||||||
Logger.info("SignagePlayer: Created default configuration with HTTPS enabled")
|
Logger.info("SignagePlayer: Created default configuration with HTTPS enabled")
|
||||||
@@ -1295,7 +1439,11 @@ class SignagePlayer(Widget):
|
|||||||
Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused")
|
Logger.debug(f"SignagePlayer: Skipping play_current_media - player is paused")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.playlist or self.current_index >= len(self.playlist):
|
if not self.playlist:
|
||||||
|
Logger.warning("SignagePlayer: Cannot play - playlist is empty")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.current_index >= len(self.playlist):
|
||||||
# End of playlist, restart
|
# End of playlist, restart
|
||||||
self.restart_playlist()
|
self.restart_playlist()
|
||||||
return
|
return
|
||||||
@@ -1304,8 +1452,20 @@ class SignagePlayer(Widget):
|
|||||||
media_item = self.playlist[self.current_index]
|
media_item = self.playlist[self.current_index]
|
||||||
file_name = media_item.get('file_name', '')
|
file_name = media_item.get('file_name', '')
|
||||||
duration = media_item.get('duration', 10)
|
duration = media_item.get('duration', 10)
|
||||||
|
# Track start time + duration so a popup can re-arm the advance
|
||||||
|
# timer with the correct remaining time after dismissal.
|
||||||
|
self._media_started_at = time.monotonic()
|
||||||
|
self._media_duration = duration
|
||||||
|
|
||||||
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
|
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
|
||||||
|
trace("play_current_media",
|
||||||
|
index=self.current_index + 1,
|
||||||
|
total=len(self.playlist),
|
||||||
|
name=file_name,
|
||||||
|
type=media_item.get('type', '?'),
|
||||||
|
duration=duration,
|
||||||
|
after_weblink=_after_weblink,
|
||||||
|
weblink_proc=bool(getattr(self, '_weblink_proc', None)))
|
||||||
|
|
||||||
# ── Weblink → media transition (desktop-flash safe) ──────────────
|
# ── Weblink → media transition (desktop-flash safe) ──────────────
|
||||||
# For web→media transitions, render the next Kivy widget first,
|
# For web→media transitions, render the next Kivy widget first,
|
||||||
@@ -1365,6 +1525,7 @@ class SignagePlayer(Widget):
|
|||||||
# Handle web links before any file/path handling (no local file exists)
|
# Handle web links before any file/path handling (no local file exists)
|
||||||
if media_item.get('type') == 'weblink':
|
if media_item.get('type') == 'weblink':
|
||||||
Logger.debug("SignagePlayer: Media type: WEBLINK")
|
Logger.debug("SignagePlayer: Media type: WEBLINK")
|
||||||
|
trace("enter_weblink_branch", url=media_item.get('url', '')[:80])
|
||||||
self.ids.status_label.opacity = 0
|
self.ids.status_label.opacity = 0
|
||||||
self._remove_current_widget()
|
self._remove_current_widget()
|
||||||
# Hide content_area — Chromium will cover it; avoids stale frame
|
# Hide content_area — Chromium will cover it; avoids stale frame
|
||||||
@@ -1373,6 +1534,7 @@ class SignagePlayer(Widget):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
started = self.play_weblink(media_item.get('url', ''), duration)
|
started = self.play_weblink(media_item.get('url', ''), duration)
|
||||||
|
trace("weblink_started", ok=bool(started))
|
||||||
if started:
|
if started:
|
||||||
self.consecutive_errors = 0
|
self.consecutive_errors = 0
|
||||||
if self.config:
|
if self.config:
|
||||||
@@ -1412,10 +1574,12 @@ class SignagePlayer(Widget):
|
|||||||
if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']:
|
if file_extension in ['.mp4', '.avi', '.mkv', '.mov', '.webm']:
|
||||||
# Video file
|
# Video file
|
||||||
Logger.debug(f"SignagePlayer: Media type: VIDEO")
|
Logger.debug(f"SignagePlayer: Media type: VIDEO")
|
||||||
|
trace("starting_video", path=media_path)
|
||||||
self.play_video(media_path, duration)
|
self.play_video(media_path, duration)
|
||||||
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
|
elif file_extension in ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']:
|
||||||
# Image file
|
# Image file
|
||||||
Logger.debug(f"SignagePlayer: Media type: IMAGE")
|
Logger.debug(f"SignagePlayer: Media type: IMAGE")
|
||||||
|
trace("starting_image", path=media_path)
|
||||||
self.play_image(media_path, duration, force_reload=force_reload)
|
self.play_image(media_path, duration, force_reload=force_reload)
|
||||||
else:
|
else:
|
||||||
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
|
Logger.warning(f"SignagePlayer: ❌ Unsupported media type: {file_extension}")
|
||||||
@@ -1443,10 +1607,12 @@ class SignagePlayer(Widget):
|
|||||||
# If we arrived here from a weblink item, close Chromium after the
|
# If we arrived here from a weblink item, close Chromium after the
|
||||||
# next Kivy frame so the new widget is already visible underneath.
|
# next Kivy frame so the new widget is already visible underneath.
|
||||||
if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None:
|
if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None:
|
||||||
|
trace("closing_weblink_after_frame", name=file_name)
|
||||||
self._kill_weblink_after_frame()
|
self._kill_weblink_after_frame()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.error(f"SignagePlayer: Error playing media: {e}")
|
Logger.error(f"SignagePlayer: Error playing media: {e}")
|
||||||
|
trace("play_current_media_EXCEPTION", error=str(e))
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
|
|
||||||
# Check if we've exceeded max errors
|
# Check if we've exceeded max errors
|
||||||
@@ -1473,6 +1639,7 @@ class SignagePlayer(Widget):
|
|||||||
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
|
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s")
|
||||||
|
|
||||||
# Create Video widget with optimized settings for smooth playback
|
# Create Video widget with optimized settings for smooth playback
|
||||||
|
self._video_source = video_path
|
||||||
self.current_widget = Video(
|
self.current_widget = Video(
|
||||||
source=video_path,
|
source=video_path,
|
||||||
state='play', # Start playing immediately
|
state='play', # Start playing immediately
|
||||||
@@ -1498,7 +1665,25 @@ class SignagePlayer(Widget):
|
|||||||
# Add to content area
|
# Add to content area
|
||||||
self.ids.content_area.add_widget(self.current_widget)
|
self.ids.content_area.add_widget(self.current_widget)
|
||||||
|
|
||||||
# Schedule next media after duration (unschedule first to prevent overlaps)
|
# Start the focus keeper so the window stays foreground for the
|
||||||
|
# whole video duration (handles SDL surface swaps at load and any
|
||||||
|
# later re-swaps). The keeper is non-blocking.
|
||||||
|
self._start_focus_keeper(duration + 1)
|
||||||
|
trace("video_focus_keeper_started")
|
||||||
|
|
||||||
|
# Start a progress watchdog. ffpyplayer does NOT reliably dispatch
|
||||||
|
# EOS — the trace shows the video looping its tail for the gap
|
||||||
|
# between its real end (~30s for sample-30s.mp4) and the fixed
|
||||||
|
# timer (31s). The watchdog polls the actual position and
|
||||||
|
# force-advances as soon as the video really ends.
|
||||||
|
self._video_watchdog_stopped = False
|
||||||
|
self._video_watchdog_event = Clock.schedule_interval(
|
||||||
|
self._video_watchdog_tick, 0.5
|
||||||
|
)
|
||||||
|
trace("video_watchdog_started", duration=duration)
|
||||||
|
|
||||||
|
# Schedule a safety-net advance after the playlist duration
|
||||||
|
# (unschedule first to prevent overlaps).
|
||||||
Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s")
|
Logger.debug(f"SignagePlayer: Scheduled next media in {duration}s")
|
||||||
Clock.unschedule(self.next_media)
|
Clock.unschedule(self.next_media)
|
||||||
Clock.schedule_once(self.next_media, duration)
|
Clock.schedule_once(self.next_media, duration)
|
||||||
@@ -1511,17 +1696,155 @@ class SignagePlayer(Widget):
|
|||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self._skip_to_next_media()
|
self._skip_to_next_media()
|
||||||
|
|
||||||
|
def _video_watchdog_tick(self, dt):
|
||||||
|
"""Watch the current video's progress and force-advance at its end.
|
||||||
|
|
||||||
|
ffpyplayer sometimes fails to dispatch EOS, leaving the video looping
|
||||||
|
its last frames for the gap between the true clip end and the playlist
|
||||||
|
timer. This polls position vs duration and advances as soon as the
|
||||||
|
video actually finishes, so the tail-loop never shows.
|
||||||
|
"""
|
||||||
|
if self._video_watchdog_stopped:
|
||||||
|
return
|
||||||
|
w = getattr(self, 'current_widget', None)
|
||||||
|
if not isinstance(w, Video) or w.source != getattr(self, '_video_source', None):
|
||||||
|
self._stop_video_watchdog()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
duration = getattr(w, 'duration', 0.0) or 0.0
|
||||||
|
position = getattr(w, 'position', -1) or 0.0
|
||||||
|
if duration > 0 and position >= duration - 0.4:
|
||||||
|
trace(
|
||||||
|
"video_watchdog_advance",
|
||||||
|
pos=round(position, 2),
|
||||||
|
dur=round(duration, 2),
|
||||||
|
)
|
||||||
|
self._stop_video_watchdog()
|
||||||
|
Clock.unschedule(self.next_media)
|
||||||
|
Clock.schedule_once(self._advance_after_video_eos, 0.1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _stop_video_watchdog(self):
|
||||||
|
"""Cancel the video progress watchdog."""
|
||||||
|
self._video_watchdog_stopped = True
|
||||||
|
ev = getattr(self, '_video_watchdog_event', None)
|
||||||
|
if ev is not None:
|
||||||
|
try:
|
||||||
|
Clock.unschedule(ev)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._video_watchdog_event = None
|
||||||
|
|
||||||
|
def _bring_window_to_front_nonblocking(self):
|
||||||
|
"""Bring the Kivy window forward WITHOUT stalling the UI.
|
||||||
|
|
||||||
|
The heavy Win32 bring-to-front (EnumWindows + AttachThreadInput +
|
||||||
|
SetForegroundWindow) can take ~1.5s and was blocking the main thread
|
||||||
|
at every video start (see playback_trace.log: video_loaded then +1.5s
|
||||||
|
before video_focus_reasserted). Running it on a background thread
|
||||||
|
keeps playback smooth.
|
||||||
|
"""
|
||||||
|
def _do():
|
||||||
|
try:
|
||||||
|
from kivy.core.window import Window as _KivyWindow
|
||||||
|
_KivyWindow.raise_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_bring = getattr(self, '_bring_kivy_to_front_win', None)
|
||||||
|
if _bring is not None:
|
||||||
|
_bring()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
threading.Thread(target=_do, daemon=True, name='focus-bring-front').start()
|
||||||
|
|
||||||
|
def _start_focus_keeper(self, duration):
|
||||||
|
"""Periodically keep the Kivy window in the foreground.
|
||||||
|
|
||||||
|
Runs for up to `duration` seconds while a video is on screen. Each tick
|
||||||
|
first does a CHEAP foreground check; the expensive bring-to-front only
|
||||||
|
runs when focus was actually lost, and even then on a background
|
||||||
|
thread so the UI never stalls.
|
||||||
|
"""
|
||||||
|
self._stop_focus_keeper()
|
||||||
|
self._focus_keeper_elapsed = 0.0
|
||||||
|
self._focus_keeper_duration = float(duration)
|
||||||
|
self._focus_keeper_event = Clock.schedule_interval(
|
||||||
|
self._focus_keeper_tick, 0.5
|
||||||
|
)
|
||||||
|
trace("focus_keeper_started", duration=duration)
|
||||||
|
|
||||||
|
def _focus_keeper_tick(self, dt):
|
||||||
|
"""One focus-keeper tick: cheap check, heavy action only if needed."""
|
||||||
|
self._focus_keeper_elapsed += dt
|
||||||
|
if self._focus_keeper_elapsed > self._focus_keeper_duration:
|
||||||
|
self._stop_focus_keeper()
|
||||||
|
return
|
||||||
|
# Cheap foreground check first — avoids the 1.5s Win32 work entirely
|
||||||
|
# when the window already has focus.
|
||||||
|
try:
|
||||||
|
check = getattr(self, '_is_foreground_win', None)
|
||||||
|
if check is not None and check():
|
||||||
|
return # already focused, nothing to do
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
trace("focus_keeper_focus_lost")
|
||||||
|
self._bring_window_to_front_nonblocking()
|
||||||
|
|
||||||
|
def _stop_focus_keeper(self):
|
||||||
|
"""Cancel any active focus keeper interval."""
|
||||||
|
ev = getattr(self, '_focus_keeper_event', None)
|
||||||
|
if ev is not None:
|
||||||
|
try:
|
||||||
|
Clock.unschedule(ev)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._focus_keeper_event = None
|
||||||
|
self._focus_keeper_elapsed = 0.0
|
||||||
|
|
||||||
def _on_video_eos(self, instance):
|
def _on_video_eos(self, instance):
|
||||||
"""Callback when video reaches end of stream"""
|
"""Callback when video reaches end of stream.
|
||||||
|
|
||||||
|
Guarded against re-entrancy: ffpyplayer can dispatch EOS more than once
|
||||||
|
for a single video (thread + main-thread paths), and duplicate advance
|
||||||
|
schedules caused skipped items / crashes on later playlist loops.
|
||||||
|
"""
|
||||||
|
if self._video_eos_pending:
|
||||||
|
trace("video_eos_IGNORED_duplicate", state=getattr(instance, 'state', '?'))
|
||||||
|
return # already handled for this video
|
||||||
|
self._video_eos_pending = True
|
||||||
|
self._stop_video_watchdog()
|
||||||
Logger.debug("SignagePlayer: Video finished playing (EOS)")
|
Logger.debug("SignagePlayer: Video finished playing (EOS)")
|
||||||
|
trace("video_eos", state=getattr(instance, 'state', '?'))
|
||||||
|
# NOTE: do NOT set instance.state = 'stop' here on the main thread —
|
||||||
|
# in Kivy that triggers VideoFFPy.stop() -> unload() -> thread.join(),
|
||||||
|
# a blocking call that freezes the UI. The teardown worker thread in
|
||||||
|
# _remove_current_widget does the stop+unload off the main thread.
|
||||||
|
# Unschedule any pending timer and advance to next media exactly once
|
||||||
|
Clock.unschedule(self.next_media)
|
||||||
|
Clock.schedule_once(self._advance_after_video_eos, 0.5)
|
||||||
|
|
||||||
|
def _advance_after_video_eos(self, dt):
|
||||||
|
"""Advance to the next media after a video ended (single-fire)."""
|
||||||
|
self._video_eos_pending = False
|
||||||
|
self._stop_video_watchdog()
|
||||||
|
trace("advance_after_video_eos", index=self.current_index)
|
||||||
|
self.next_media()
|
||||||
|
|
||||||
def _on_video_loaded(self, instance, value):
|
def _on_video_loaded(self, instance, value):
|
||||||
"""Callback when video is loaded - log video information"""
|
"""Callback when the video's first frame is decoded and loaded.
|
||||||
|
|
||||||
|
The SDL surface swap at this moment can drop the window from the
|
||||||
|
foreground. The periodic focus keeper (started in play_video) handles
|
||||||
|
keeping it focused; here we just log it.
|
||||||
|
"""
|
||||||
if value:
|
if value:
|
||||||
try:
|
try:
|
||||||
Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s")
|
Logger.debug(f"SignagePlayer: Video loaded: {instance.texture.size if instance.texture else 'No texture'}, {instance.duration}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.debug(f"SignagePlayer: Could not log video info: {e}")
|
Logger.debug(f"SignagePlayer: Could not log video info: {e}")
|
||||||
|
trace("video_loaded")
|
||||||
|
|
||||||
def play_image(self, image_path, duration, force_reload=False):
|
def play_image(self, image_path, duration, force_reload=False):
|
||||||
"""Play an image file"""
|
"""Play an image file"""
|
||||||
@@ -1563,20 +1886,79 @@ class SignagePlayer(Widget):
|
|||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self._skip_to_next_media()
|
self._skip_to_next_media()
|
||||||
|
|
||||||
|
def _teardown_video_async(self, widget):
|
||||||
|
"""Stop and unload a finished video OFF the main thread.
|
||||||
|
|
||||||
|
CRITICAL: Kivy's `Video.state = 'stop'` calls VideoFFPy.stop() ->
|
||||||
|
unload() -> self._thread.join(), which BLOCKS until the ffpyplayer
|
||||||
|
decode thread exits. That join is variable (0.4s up to 100s+) and was
|
||||||
|
freezing the whole UI thread at every video->next transition (see
|
||||||
|
playback_trace.log gaps between remove_current_widget and
|
||||||
|
widget_removed_async). Doing the full stop+unload on a worker thread
|
||||||
|
keeps the UI responsive.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Rebind the same widget's EOS is not needed; just stop+unload.
|
||||||
|
widget.state = 'stop'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
widget.unload()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _remove_current_widget(self):
|
def _remove_current_widget(self):
|
||||||
"""Stop and remove the current Kivy media widget if one is present."""
|
"""Stop and remove the current Kivy media widget if one is present."""
|
||||||
|
trace("remove_current_widget",
|
||||||
|
has=bool(self.current_widget),
|
||||||
|
is_video=isinstance(self.current_widget, Video))
|
||||||
if self.current_widget:
|
if self.current_widget:
|
||||||
# Properly stop video if it's playing to prevent resource leaks
|
# Properly stop video if it's playing to prevent resource leaks
|
||||||
if isinstance(self.current_widget, Video):
|
if isinstance(self.current_widget, Video):
|
||||||
try:
|
try:
|
||||||
Logger.debug("SignagePlayer: Stopping previous video widget...")
|
Logger.debug("SignagePlayer: Stopping previous video widget...")
|
||||||
self.current_widget.state = 'stop'
|
# Unbind EOS first so stopping/unloading cannot re-trigger
|
||||||
self.current_widget.unload()
|
# the transition callback (caused double-advance / crashes
|
||||||
|
# at the video -> next-media boundary).
|
||||||
|
try:
|
||||||
|
self.current_widget.unbind(on_eos=self._on_video_eos)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Do NOT set state='stop' on the main thread — in Kivy that
|
||||||
|
# triggers VideoFFPy.stop() -> unload() -> thread.join(), a
|
||||||
|
# blocking call that froze the UI for up to 100s+ (see
|
||||||
|
# playback_trace.log gaps between remove_current_widget and
|
||||||
|
# widget_removed_async). Detach immediately and let the
|
||||||
|
# worker thread do the full stop+unload.
|
||||||
|
try:
|
||||||
|
self.ids.content_area.remove_widget(self.current_widget)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
widget = self.current_widget
|
||||||
|
self.current_widget = None
|
||||||
|
self._video_eos_pending = False
|
||||||
|
self._stop_focus_keeper()
|
||||||
|
self._stop_video_watchdog()
|
||||||
|
threading.Thread(
|
||||||
|
target=self._teardown_video_async,
|
||||||
|
args=(widget,), daemon=True,
|
||||||
|
name='video-teardown'
|
||||||
|
).start()
|
||||||
|
trace("widget_removed_async")
|
||||||
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.warning(f"SignagePlayer: Error stopping video: {e}")
|
Logger.warning(f"SignagePlayer: Error stopping video: {e}")
|
||||||
self.ids.content_area.remove_widget(self.current_widget)
|
try:
|
||||||
|
self.ids.content_area.remove_widget(self.current_widget)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.current_widget = None
|
self.current_widget = None
|
||||||
|
# Reset the EOS guard so the next video can advance normally.
|
||||||
|
self._video_eos_pending = False
|
||||||
|
self._stop_focus_keeper()
|
||||||
|
self._stop_video_watchdog()
|
||||||
Logger.debug("SignagePlayer: Previous widget removed")
|
Logger.debug("SignagePlayer: Previous widget removed")
|
||||||
|
trace("widget_removed")
|
||||||
|
|
||||||
def play_weblink(self, url, duration):
|
def play_weblink(self, url, duration):
|
||||||
"""Display a live web page fullscreen using a Chromium kiosk overlay.
|
"""Display a live web page fullscreen using a Chromium kiosk overlay.
|
||||||
@@ -1876,11 +2258,27 @@ class SignagePlayer(Widget):
|
|||||||
Clock.schedule_once(self.next_media, 1)
|
Clock.schedule_once(self.next_media, 1)
|
||||||
|
|
||||||
def next_media(self, dt=None):
|
def next_media(self, dt=None):
|
||||||
"""Move to next media item"""
|
"""Move to next media item.
|
||||||
|
|
||||||
|
A stale-advance guard prevents duplicate/queued next_media calls from
|
||||||
|
firing right after a long (blocked) teardown and skipping the media
|
||||||
|
that was just shown.
|
||||||
|
"""
|
||||||
|
trace("next_media_called",
|
||||||
|
was_index=self.current_index,
|
||||||
|
paused=self.is_paused)
|
||||||
if self.is_paused:
|
if self.is_paused:
|
||||||
Logger.info(f"SignagePlayer: ⏸ Blocked next_media - player is paused")
|
Logger.info(f"SignagePlayer: ⏸ Blocked next_media - player is paused")
|
||||||
|
trace("next_media_BLOCKED_paused")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_advance_at < 1.0:
|
||||||
|
trace("next_media_IGNORED_stale",
|
||||||
|
since_last=round(now - self._last_advance_at, 3))
|
||||||
|
return
|
||||||
|
self._last_advance_at = now
|
||||||
|
|
||||||
Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})")
|
Logger.info(f"SignagePlayer: Transitioning to next media (was index {self.current_index})")
|
||||||
self.current_index += 1
|
self.current_index += 1
|
||||||
|
|
||||||
@@ -2064,6 +2462,12 @@ class SignagePlayer(Widget):
|
|||||||
|
|
||||||
def restart_playlist(self):
|
def restart_playlist(self):
|
||||||
"""Restart playlist from beginning"""
|
"""Restart playlist from beginning"""
|
||||||
|
trace("restart_playlist", count=len(self.playlist))
|
||||||
|
if not self.playlist:
|
||||||
|
Logger.warning("SignagePlayer: Cannot restart - playlist is empty")
|
||||||
|
trace("restart_playlist_EMPTY")
|
||||||
|
return
|
||||||
|
|
||||||
Logger.info("SignagePlayer: Restarting playlist")
|
Logger.info("SignagePlayer: Restarting playlist")
|
||||||
|
|
||||||
# Send restart feedback asynchronously (non-blocking)
|
# Send restart feedback asynchronously (non-blocking)
|
||||||
@@ -2284,6 +2688,7 @@ class SignagePlayer(Widget):
|
|||||||
def exit_app(self, instance=None):
|
def exit_app(self, instance=None):
|
||||||
"""Exit the application"""
|
"""Exit the application"""
|
||||||
Logger.info("SignagePlayer: Exiting application")
|
Logger.info("SignagePlayer: Exiting application")
|
||||||
|
self.set_allow_exit(True)
|
||||||
App.get_running_app().stop()
|
App.get_running_app().stop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+163
-94
@@ -6,11 +6,16 @@ Checks server connectivity and manages WiFi restart on connection failure
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import platform
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from kivy.logger import Logger
|
from kivy.logger import Logger
|
||||||
from kivy.clock import Clock
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
# Detect platform once so the ping / WiFi-restart commands below can
|
||||||
|
# pick the correct syntax (Linux vs Windows).
|
||||||
|
IS_WINDOWS = platform.system() == 'Windows'
|
||||||
|
|
||||||
|
|
||||||
class NetworkMonitor:
|
class NetworkMonitor:
|
||||||
"""Monitor network connectivity and manage WiFi restart"""
|
"""Monitor network connectivity and manage WiFi restart"""
|
||||||
@@ -99,9 +104,15 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
Logger.info(f"NetworkMonitor: Pinging server: {hostname}")
|
||||||
|
|
||||||
# Ping the server hostname with 3 attempts
|
# Ping the server hostname with 3 attempts.
|
||||||
|
# Windows ping uses -n for count and -w for timeout (ms),
|
||||||
|
# while Linux uses -c and -W.
|
||||||
|
if IS_WINDOWS:
|
||||||
|
cmd = ['ping', '-n', '3', '-w', '3000', hostname]
|
||||||
|
else:
|
||||||
|
cmd = ['ping', '-c', '3', '-W', '3', hostname]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['ping', '-c', '3', '-W', '3', hostname],
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
@@ -123,8 +134,11 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
def _restart_wifi(self):
|
def _restart_wifi(self):
|
||||||
"""
|
"""
|
||||||
Restart WiFi by turning it off for a specified duration then back on
|
Restart WiFi by turning it off for a specified duration then back on.
|
||||||
This runs in a separate thread to not block the main application
|
Uses the platform-appropriate commands:
|
||||||
|
- Linux (Raspberry Pi): sudo rfkill / ifconfig / dhclient
|
||||||
|
- Windows: netsh wlan disconnect / connect
|
||||||
|
This runs in a separate thread to not block the main application.
|
||||||
"""
|
"""
|
||||||
def wifi_restart_thread():
|
def wifi_restart_thread():
|
||||||
try:
|
try:
|
||||||
@@ -132,97 +146,10 @@ class NetworkMonitor:
|
|||||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
if IS_WINDOWS:
|
||||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
self._restart_wifi_windows()
|
||||||
result = subprocess.run(
|
|
||||||
['sudo', 'rfkill', 'block', 'wifi'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
|
||||||
Logger.info("NetworkMonitor: WiFi is now DISABLED and will remain OFF")
|
|
||||||
else:
|
else:
|
||||||
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
self._restart_wifi_linux()
|
||||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
|
||||||
|
|
||||||
# Fallback to ifconfig
|
|
||||||
result2 = subprocess.run(
|
|
||||||
['sudo', 'ifconfig', 'wlan0', 'down'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result2.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
|
||||||
else:
|
|
||||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
|
||||||
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
|
||||||
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Wait for the specified duration with WiFi OFF
|
|
||||||
wait_minutes = self.wifi_restart_duration / 60
|
|
||||||
Logger.info(f"NetworkMonitor: ====================================")
|
|
||||||
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
|
||||||
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
|
||||||
Logger.info(f"NetworkMonitor: ====================================")
|
|
||||||
|
|
||||||
# Sleep while WiFi is OFF
|
|
||||||
time.sleep(self.wifi_restart_duration)
|
|
||||||
|
|
||||||
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
|
||||||
|
|
||||||
# Turn WiFi back on after the wait period
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
|
|
||||||
# Unblock WiFi using rfkill
|
|
||||||
result = subprocess.run(
|
|
||||||
['sudo', 'rfkill', 'unblock', 'wifi'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
|
||||||
else:
|
|
||||||
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
|
||||||
|
|
||||||
# Also bring interface up
|
|
||||||
result2 = subprocess.run(
|
|
||||||
['sudo', 'ifconfig', 'wlan0', 'up'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if result2.returncode == 0:
|
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
|
||||||
|
|
||||||
# Wait a bit for connection to establish
|
|
||||||
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
# Try to restart DHCP
|
|
||||||
Logger.info("NetworkMonitor: Requesting IP address...")
|
|
||||||
subprocess.run(
|
|
||||||
['sudo', 'dhclient', 'wlan0'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=15
|
|
||||||
)
|
|
||||||
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
|
||||||
else:
|
|
||||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||||
@@ -233,3 +160,145 @@ class NetworkMonitor:
|
|||||||
import threading
|
import threading
|
||||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
def _restart_wifi_windows(self):
|
||||||
|
"""Windows WiFi restart using netsh. Turn off for the wait period,
|
||||||
|
then turn back on so Windows reconnects to the preferred network."""
|
||||||
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: Windows WiFi restart — off for {wait_minutes:.0f} min"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Turn WiFi OFF
|
||||||
|
off = subprocess.run(
|
||||||
|
['netsh', 'wlan', 'disconnect'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if off.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF (netsh wlan disconnect)")
|
||||||
|
else:
|
||||||
|
Logger.warning(
|
||||||
|
f"NetworkMonitor: netsh wlan disconnect failed: {off.stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait with WiFi OFF
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes "
|
||||||
|
f"(started {datetime.now().strftime('%H:%M:%S')})"
|
||||||
|
)
|
||||||
|
time.sleep(self.wifi_restart_duration)
|
||||||
|
Logger.info(
|
||||||
|
f"NetworkMonitor: Wait period completed at {datetime.now().strftime('%H:%M:%S')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Turn WiFi back ON — Windows reconnects to the preferred network
|
||||||
|
on = subprocess.run(
|
||||||
|
['netsh', 'wlan', 'connect'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
if on.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi re-enabled (netsh wlan connect)")
|
||||||
|
else:
|
||||||
|
# 'netsh wlan connect' without a profile may return non-zero even
|
||||||
|
# though the radio comes back on; log it but don't fail hard.
|
||||||
|
Logger.warning(
|
||||||
|
f"NetworkMonitor: netsh wlan connect returned {on.returncode}: "
|
||||||
|
f"{on.stderr.strip()} (may reconnect automatically)"
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
def _restart_wifi_linux(self):
|
||||||
|
"""Linux (Raspberry Pi) WiFi restart using rfkill/ifconfig/dhclient."""
|
||||||
|
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||||
|
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'block', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||||
|
|
||||||
|
# Fallback to ifconfig
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ifconfig', 'wlan0', 'down'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (ifconfig)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi off: {result2.stderr}")
|
||||||
|
Logger.error(f"NetworkMonitor: Return code: {result2.returncode}")
|
||||||
|
Logger.error(f"NetworkMonitor: STDOUT: {result2.stdout}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for the specified duration with WiFi OFF
|
||||||
|
wait_minutes = self.wifi_restart_duration / 60
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
Logger.info(f"NetworkMonitor: WiFi will remain OFF for {wait_minutes:.0f} minutes")
|
||||||
|
Logger.info(f"NetworkMonitor: Waiting period started at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
Logger.info(f"NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Sleep while WiFi is OFF
|
||||||
|
time.sleep(self.wifi_restart_duration)
|
||||||
|
|
||||||
|
Logger.info(f"NetworkMonitor: Wait period completed at: {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
|
# Turn WiFi back on after the wait period
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: Now turning WiFi back ON...")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
# Unblock WiFi using rfkill
|
||||||
|
result = subprocess.run(
|
||||||
|
['sudo', 'rfkill', 'unblock', 'wifi'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi unblocked successfully (rfkill)")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: rfkill unblock failed: {result.stderr}")
|
||||||
|
|
||||||
|
# Also bring interface up
|
||||||
|
result2 = subprocess.run(
|
||||||
|
['sudo', 'ifconfig', 'wlan0', 'up'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result2.returncode == 0:
|
||||||
|
Logger.info("NetworkMonitor: ✓ WiFi interface brought UP successfully")
|
||||||
|
|
||||||
|
# Wait a bit for connection to establish
|
||||||
|
Logger.info("NetworkMonitor: Waiting 10 seconds for WiFi to initialize...")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Try to restart DHCP
|
||||||
|
Logger.info("NetworkMonitor: Requesting IP address...")
|
||||||
|
subprocess.run(
|
||||||
|
['sudo', 'dhclient', 'wlan0'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
Logger.info("NetworkMonitor: WIFI RESTART SEQUENCE COMPLETED")
|
||||||
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
else:
|
||||||
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
playback_trace.py — Always-on playback transition logger.
|
||||||
|
|
||||||
|
Kivy's log level is forced to 'warning' in main.py / run_win.py, which
|
||||||
|
suppresses every Logger.info()/Logger.debug() line. That made it impossible
|
||||||
|
to see why the player skips/crashes at the weblink->image and video->next
|
||||||
|
transitions.
|
||||||
|
|
||||||
|
This module writes a plain-text trace file (logs/playback_trace.log) with
|
||||||
|
timestamps, INDEPENDENT of Kivy's log level, so we can always see exactly
|
||||||
|
what the player is doing. It is thread-safe (a lock guards the append) and
|
||||||
|
never throws (all failures are swallowed) so it can never break playback.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from playback_trace import trace
|
||||||
|
trace("play_current_media", index=3, name="foo.jpg", type="image")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_LOG_PATH = None
|
||||||
|
_OPENED = False
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_path():
|
||||||
|
global _LOG_PATH, _OPENED
|
||||||
|
if _OPENED:
|
||||||
|
return _LOG_PATH
|
||||||
|
_OPENED = True
|
||||||
|
try:
|
||||||
|
# Respect the local data dir the launcher set (same place as logs/).
|
||||||
|
base = os.environ.get('KIWY_DATA_DIR') or os.getcwd()
|
||||||
|
log_dir = os.path.join(base, 'logs')
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
_LOG_PATH = os.path.join(log_dir, 'playback_trace.log')
|
||||||
|
except Exception:
|
||||||
|
_LOG_PATH = None
|
||||||
|
return _LOG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def trace(event, **kwargs):
|
||||||
|
"""Append one line to the playback trace log.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: short event name, e.g. 'next_media', 'eos', 'web_open'.
|
||||||
|
**kwargs: key=value context, e.g. index=3, name='foo.jpg'.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = _ensure_path()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
t = time.strftime('%H:%M:%S')
|
||||||
|
ms = int((time.time() % 1) * 1000)
|
||||||
|
parts = [f"{t}.{ms:03d}", event]
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
parts.append(f"{k}={v}")
|
||||||
|
with _LOCK:
|
||||||
|
with open(path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(" ".join(parts) + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass # tracing must never break the player
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"hostname": "Birou_IT",
|
"hostname": "WINDOWS-PC",
|
||||||
"auth_code": "CZncd_2dlTZGieBEdqAbUTjf3qNyEUPDXr8jLVx7NLs",
|
"auth_code": "",
|
||||||
"player_id": 1,
|
"player_id": 2,
|
||||||
"player_name": "Test_player1",
|
"player_name": "Windows-Player1",
|
||||||
"playlist_id": 1,
|
"playlist_id": 1,
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"authenticated": true,
|
"authenticated": true,
|
||||||
"server_url": "http://192.168.0.109:8080"
|
"server_url": "http://192.168.0.107:8080"
|
||||||
}
|
}
|
||||||
+313
-289
@@ -352,303 +352,327 @@
|
|||||||
# Settings popup content
|
# Settings popup content
|
||||||
<SettingsPopup@Popup>:
|
<SettingsPopup@Popup>:
|
||||||
title: 'Player Settings'
|
title: 'Player Settings'
|
||||||
size_hint: 0.8, 0.8
|
size_hint: 0.9, 0.85
|
||||||
auto_dismiss: True
|
auto_dismiss: True
|
||||||
|
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'vertical'
|
orientation: 'vertical'
|
||||||
padding: dp(20)
|
padding: [dp(15), dp(10)]
|
||||||
spacing: dp(15)
|
spacing: dp(8)
|
||||||
|
|
||||||
# Server configuration
|
ScrollView:
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'vertical'
|
||||||
|
spacing: dp(8)
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Server IP:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: server_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Server port
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Port:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: port_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
hint_text: '80 or 8080 (leave empty for default)'
|
||||||
|
input_filter: 'int'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Screen name
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Screen Name:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: screen_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Quickconnect key
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Quickconnect:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: quickconnect_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Orientation
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Orientation:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: orientation_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Touch
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Touch:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: touch_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Resolution
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Max Resolution:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: resolution_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(13)
|
||||||
|
hint_text: '1920x1080 or auto'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
|
# Edit Feature Enable/Disable
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(36)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Enable Edit:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
CheckBox:
|
||||||
|
id: edit_enabled_checkbox
|
||||||
|
size_hint_x: None
|
||||||
|
width: dp(36)
|
||||||
|
active: True
|
||||||
|
on_active: root.on_edit_feature_toggle(self.active)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: '(Allow editing images)'
|
||||||
|
size_hint_x: 0.4
|
||||||
|
font_size: sp(11)
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
Widget:
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
|
# Reset Buttons Section
|
||||||
|
Label:
|
||||||
|
text: 'Reset Options:'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(26)
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
bold: True
|
||||||
|
font_size: sp(14)
|
||||||
|
|
||||||
|
# Reset Buttons Row
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(44)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: reset_auth_btn
|
||||||
|
text: 'Reset Player Auth'
|
||||||
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
|
on_press: root.reset_player_auth()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: reset_playlist_btn
|
||||||
|
text: 'Reset Playlist to v0'
|
||||||
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
|
on_press: root.reset_playlist_version()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: restart_player_btn
|
||||||
|
text: 'Restart Player'
|
||||||
|
background_color: 0.2, 0.6, 0.8, 1
|
||||||
|
font_size: sp(12)
|
||||||
|
on_press: root.restart_player()
|
||||||
|
|
||||||
|
# Test Connection + Production Mode Buttons
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(44)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: test_connection_btn
|
||||||
|
text: 'Test Server Connection'
|
||||||
|
background_color: 0.2, 0.4, 0.8, 1
|
||||||
|
font_size: sp(13)
|
||||||
|
on_press: root.test_connection()
|
||||||
|
|
||||||
|
Button:
|
||||||
|
id: production_mode_btn
|
||||||
|
text: 'Enable Production'
|
||||||
|
background_color: 0.4, 0.4, 0.4, 1 # grey = disabled
|
||||||
|
font_size: sp(13)
|
||||||
|
on_press: root.toggle_production_mode()
|
||||||
|
|
||||||
|
# Connection Status Label
|
||||||
|
Label:
|
||||||
|
id: connection_status
|
||||||
|
text: 'Click button to test connection'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(32)
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
Widget:
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
|
# Status information row
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(26)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
id: playlist_info
|
||||||
|
text: 'Playlist: N/A'
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
id: media_count_info
|
||||||
|
text: 'Media: 0'
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
id: status_info
|
||||||
|
text: 'Status: Idle'
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'center'
|
||||||
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
|
|
||||||
|
# Action buttons (always visible, outside scroll)
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(44)
|
||||||
spacing: dp(10)
|
spacing: dp(15)
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Server IP:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: server_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Server port
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Port:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: port_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
hint_text: '80 or 8080 (leave empty for default)'
|
|
||||||
input_filter: 'int'
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Screen name
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Screen Name:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: screen_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Quickconnect key
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Quickconnect:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: quickconnect_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Orientation
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Orientation:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: orientation_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Touch
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Touch:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: touch_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Resolution
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Max Resolution:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
TextInput:
|
|
||||||
id: resolution_input
|
|
||||||
size_hint_x: 0.7
|
|
||||||
multiline: False
|
|
||||||
font_size: sp(14)
|
|
||||||
hint_text: '1920x1080 or auto'
|
|
||||||
write_tab: False
|
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
|
||||||
|
|
||||||
# Edit Feature Enable/Disable
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: 'Enable Edit Feature:'
|
|
||||||
size_hint_x: 0.3
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
|
|
||||||
CheckBox:
|
|
||||||
id: edit_enabled_checkbox
|
|
||||||
size_hint_x: None
|
|
||||||
width: dp(40)
|
|
||||||
active: True
|
|
||||||
on_active: root.on_edit_feature_toggle(self.active)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
text: '(Allow editing images on this player)'
|
|
||||||
size_hint_x: 0.4
|
|
||||||
font_size: sp(12)
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
color: 0.7, 0.7, 0.7, 1
|
|
||||||
|
|
||||||
Widget:
|
|
||||||
size_hint_y: 0.05
|
|
||||||
|
|
||||||
# Reset Buttons Section
|
|
||||||
Label:
|
|
||||||
text: 'Reset Options:'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(30)
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'left'
|
|
||||||
valign: 'middle'
|
|
||||||
bold: True
|
|
||||||
font_size: sp(16)
|
|
||||||
|
|
||||||
# Reset Buttons Row
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(50)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Button:
|
|
||||||
id: reset_auth_btn
|
|
||||||
text: 'Reset Player Auth'
|
|
||||||
background_color: 0.8, 0.4, 0.2, 1
|
|
||||||
on_press: root.reset_player_auth()
|
|
||||||
|
|
||||||
Button:
|
|
||||||
id: reset_playlist_btn
|
|
||||||
text: 'Reset Playlist to v0'
|
|
||||||
background_color: 0.8, 0.4, 0.2, 1
|
|
||||||
on_press: root.reset_playlist_version()
|
|
||||||
|
|
||||||
Button:
|
|
||||||
id: restart_player_btn
|
|
||||||
text: 'Restart Player'
|
|
||||||
background_color: 0.2, 0.6, 0.8, 1
|
|
||||||
on_press: root.restart_player()
|
|
||||||
|
|
||||||
# Test Connection Button
|
|
||||||
Button:
|
|
||||||
id: test_connection_btn
|
|
||||||
text: 'Test Server Connection'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(50)
|
|
||||||
background_color: 0.2, 0.4, 0.8, 1
|
|
||||||
on_press: root.test_connection()
|
|
||||||
|
|
||||||
# Connection Status Label
|
|
||||||
Label:
|
|
||||||
id: connection_status
|
|
||||||
text: 'Click button to test connection'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(40)
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'center'
|
|
||||||
valign: 'middle'
|
|
||||||
color: 0.7, 0.7, 0.7, 1
|
|
||||||
|
|
||||||
Widget:
|
|
||||||
size_hint_y: 0.05
|
|
||||||
|
|
||||||
# Status information row
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(30)
|
|
||||||
spacing: dp(10)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
id: playlist_info
|
|
||||||
text: 'Playlist: N/A'
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'center'
|
|
||||||
valign: 'middle'
|
|
||||||
font_size: sp(12)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
id: media_count_info
|
|
||||||
text: 'Media: 0'
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'center'
|
|
||||||
valign: 'middle'
|
|
||||||
font_size: sp(12)
|
|
||||||
|
|
||||||
Label:
|
|
||||||
id: status_info
|
|
||||||
text: 'Status: Idle'
|
|
||||||
text_size: self.size
|
|
||||||
halign: 'center'
|
|
||||||
valign: 'middle'
|
|
||||||
font_size: sp(12)
|
|
||||||
|
|
||||||
Widget:
|
|
||||||
size_hint_y: 0.05
|
|
||||||
|
|
||||||
# Action buttons
|
|
||||||
BoxLayout:
|
|
||||||
orientation: 'horizontal'
|
|
||||||
size_hint_y: None
|
|
||||||
height: dp(50)
|
|
||||||
spacing: dp(20)
|
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
text: 'Save & Close'
|
text: 'Save & Close'
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
Requirement already satisfied: ffpyplayer in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (4.5.3)
|
||||||
|
Requirement already satisfied: requests in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.34.2)
|
||||||
|
Requirement already satisfied: aiohttp in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (3.14.3)
|
||||||
|
Requirement already satisfied: bcrypt in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (5.0.0)
|
||||||
|
Requirement already satisfied: certifi in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2026.7.22)
|
||||||
|
Requirement already satisfied: pyinstaller in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (6.21.0)
|
||||||
|
Requirement already satisfied: kivy[base] in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.3.1)
|
||||||
|
Requirement already satisfied: Kivy-Garden>=0.1.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.1.5)
|
||||||
|
Requirement already satisfied: docutils in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.23)
|
||||||
|
Requirement already satisfied: pygments in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (2.20.0)
|
||||||
|
Requirement already satisfied: filetype in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (1.2.0)
|
||||||
|
Requirement already satisfied: kivy-deps.angle~=0.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.4.0)
|
||||||
|
Requirement already satisfied: kivy-deps.sdl2~=0.8.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.8.0)
|
||||||
|
Requirement already satisfied: kivy-deps.glew~=0.3.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.3.1)
|
||||||
|
Requirement already satisfied: pypiwin32 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (223)
|
||||||
|
Requirement already satisfied: pillow<11,>=9.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (10.4.0)
|
||||||
|
Requirement already satisfied: charset_normalizer<4,>=2 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.4.9)
|
||||||
|
Requirement already satisfied: idna<4,>=2.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.18)
|
||||||
|
Requirement already satisfied: urllib3<3,>=1.26 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (2.7.0)
|
||||||
|
Requirement already satisfied: aiohappyeyeballs>=2.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (2.7.1)
|
||||||
|
Requirement already satisfied: aiosignal>=1.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.4.0)
|
||||||
|
Requirement already satisfied: attrs>=17.3.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (26.1.0)
|
||||||
|
Requirement already satisfied: frozenlist>=1.1.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.8.0)
|
||||||
|
Requirement already satisfied: multidict<7.0,>=4.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (6.7.1)
|
||||||
|
Requirement already satisfied: propcache>=0.2.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (0.5.2)
|
||||||
|
Requirement already satisfied: typing_extensions>=4.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (4.16.0)
|
||||||
|
Requirement already satisfied: yarl<2.0,>=1.17.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.24.5)
|
||||||
|
Requirement already satisfied: altgraph in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.17.5)
|
||||||
|
Requirement already satisfied: packaging>=22.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (26.2)
|
||||||
|
Requirement already satisfied: pefile>=2022.5.30 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2024.8.26)
|
||||||
|
Requirement already satisfied: pyinstaller-hooks-contrib>=2026.6 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2026.6)
|
||||||
|
Requirement already satisfied: pywin32-ctypes>=0.2.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.2.3)
|
||||||
|
Requirement already satisfied: setuptools>=42.0.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (83.0.0)
|
||||||
|
Requirement already satisfied: pywin32>=223 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pypiwin32->kivy[base]) (312)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Kiwy Signage Player - Windows Edition
|
||||||
|
|
||||||
|
Build and run the Kiwy digital signage player on Windows as a standalone `.exe`.
|
||||||
|
|
||||||
|
## 📋 Requirements Analysis
|
||||||
|
|
||||||
|
The original app was built for **Raspberry Pi (Linux)**, using these technologies:
|
||||||
|
|
||||||
|
| Component | Original (RPi/Linux) | Windows Equivalent |
|
||||||
|
|-----------|---------------------|-------------------|
|
||||||
|
| **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) |
|
||||||
|
| **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) |
|
||||||
|
| **Card Reader** | evdev (Linux input) | **Not available** — gracefully disabled |
|
||||||
|
| **Screen Keep-Awake** | xset, xdotool, Wayland | `SetThreadExecutionState` (Win32 API) |
|
||||||
|
| **Weblink** | chromium-browser (kiosk) | Chrome/Edge (--kiosk mode) |
|
||||||
|
| **Audio** | ALSA/PulseAudio | DirectSound |
|
||||||
|
| **Window Backend** | SDL2 (Wayland/X11) | SDL2 (Windows native) |
|
||||||
|
| **OpenGL** | Desktop GL | ANGLE (DirectX wrapper) |
|
||||||
|
|
||||||
|
### What works on Windows
|
||||||
|
- ✅ Media playback (images, videos via ffpyplayer)
|
||||||
|
- ✅ Playlist sync from DigiServer (HTTP/HTTPS)
|
||||||
|
- ✅ Touch & mouse controls
|
||||||
|
- ✅ Settings popup
|
||||||
|
- ✅ Image editing/annotation
|
||||||
|
- ✅ Password-protected exit
|
||||||
|
- ✅ Web links (opens in Chrome/Edge kiosk)
|
||||||
|
- ✅ Network monitoring
|
||||||
|
- ✅ Auto-update playlist
|
||||||
|
|
||||||
|
### What is disabled on Windows
|
||||||
|
- ❌ Card reader (evdev is Linux-only; `EVDEV_AVAILABLE = False`)
|
||||||
|
- ❌ HDMI power management (tvservice is RPi-specific)
|
||||||
|
- ❌ WiFi restart (uses Linux `nmcli`)
|
||||||
|
|
||||||
|
## 🚀 Quick Start (Development)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
1. **Python 3.12+** (64-bit) — [python.org](https://python.org)
|
||||||
|
- ⚠️ **Python 3.13+ is NOT supported** — Kivy 2.3.1 does not have pre-built wheels for it
|
||||||
|
- ⚠️ **Python 3.14 is NOT supported** — no Kivy wheels available
|
||||||
|
- ✅ **Python 3.12.9** is the recommended version (confirmed working)
|
||||||
|
2. **FFmpeg** — for video codec support
|
||||||
|
- Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||||
|
- Add `bin\` folder to your PATH
|
||||||
|
3. **Visual C++ Redistributable** — [latest](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||||
|
|
||||||
|
### Install & Run
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
|
||||||
|
REM Create virtual environment with Python 3.12
|
||||||
|
py -3.12 -m venv venv
|
||||||
|
:: OR specify full path:
|
||||||
|
:: "C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe" -m venv venv
|
||||||
|
|
||||||
|
venv\Scripts\activate
|
||||||
|
|
||||||
|
REM Install dependencies
|
||||||
|
pip install -r requirements_win.txt
|
||||||
|
|
||||||
|
REM Run in development mode
|
||||||
|
python run_win.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Building the .exe
|
||||||
|
|
||||||
|
### One-Command Build
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
build_win.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Build
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r requirements_win.txt
|
||||||
|
pyinstaller build.spec --clean --noconfirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output
|
||||||
|
```
|
||||||
|
windows\dist\KiwySignagePlayer\
|
||||||
|
├── KiwySignagePlayer.exe # Main executable
|
||||||
|
├── config/ # Config files (auto-copied)
|
||||||
|
├── resources/ # Icons, intro video
|
||||||
|
└── ... (supporting DLLs)
|
||||||
|
```
|
||||||
|
|
||||||
|
For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` section and comment out the `coll = COLLECT(...)` section.
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
1. On first run, config files are created in the **same folder as the executable** (not in `%APPDATA%`)
|
||||||
|
- The .exe creates: `config/`, `media/`, `playlists/`, `logs/` directories locally
|
||||||
|
- This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works
|
||||||
|
2. Edit `config\app_config.json` (next to the .exe) to set your server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_ip": "192.168.0.109",
|
||||||
|
"port": "8080",
|
||||||
|
"screen_name": "Birou_IT",
|
||||||
|
"quickconnect_key": "8887779",
|
||||||
|
"orientation": "Landscape",
|
||||||
|
"touch": "True",
|
||||||
|
"max_resolution": "1920x1080",
|
||||||
|
"edit_feature_enabled": true,
|
||||||
|
"use_https": false,
|
||||||
|
"verify_ssl": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
venv\Scripts\activate
|
||||||
|
python run_win.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| **"ffpyplayer not found"** | Install: `pip install ffpyplayer` |
|
||||||
|
| **"No video" / black screen** | Install FFmpeg and add to PATH. Try `KIVY_GL_BACKEND=angle_sdl2` or `KIVY_GL_BACKEND=gl` |
|
||||||
|
| **Kivy window doesn't open** | Run from command prompt to see error messages. Ensure GPU drivers are up to date. |
|
||||||
|
| **Weblinks not opening** | Install Google Chrome or Microsoft Edge |
|
||||||
|
| **Can't connect to server** | Check firewall. Try `use_https: false` and `verify_ssl: false` for testing |
|
||||||
|
| **Antivirus flags .exe** | Add the output folder to antivirus exclusions. This is a false positive common with PyInstaller. |
|
||||||
|
|
||||||
|
## 📁 Project Structure (Build)
|
||||||
|
|
||||||
|
```
|
||||||
|
Kiwy-Signage/
|
||||||
|
├── windows/
|
||||||
|
│ ├── run_win.py # Windows entry point (patches platform differences)
|
||||||
|
│ ├── build.spec # PyInstaller configuration
|
||||||
|
│ ├── build_win.bat # One-click build script
|
||||||
|
│ ├── pyi_runtime_hook.py # PyInstaller runtime hook
|
||||||
|
│ ├── requirements_win.txt # Windows Python dependencies
|
||||||
|
│ └── README_WINDOWS_BUILD.md # This file
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py # Main application (original)
|
||||||
|
│ ├── get_playlists_v2.py # Playlist sync
|
||||||
|
│ ├── player_auth.py # Authentication
|
||||||
|
│ ├── ssl_utils.py # SSL/HTTPS
|
||||||
|
│ ├── keyboard_widget.py # On-screen keyboard
|
||||||
|
│ ├── network_monitor.py # Network monitoring
|
||||||
|
│ ├── edit_popup.py # Image editing
|
||||||
|
│ └── signage_player.kv # Kivy UI layout
|
||||||
|
├── config/
|
||||||
|
│ ├── app_config.json # Player configuration
|
||||||
|
│ └── resources/ # Icons, images, intro video
|
||||||
|
├── media/ # Downloaded media (created at runtime)
|
||||||
|
├── playlists/ # Playlist files (created at runtime)
|
||||||
|
└── logs/ # Log files (created at runtime)
|
||||||
|
```
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Quick syntax check for build files."""
|
||||||
|
import ast, sys
|
||||||
|
|
||||||
|
files = [
|
||||||
|
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\run_win.py',
|
||||||
|
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\pyi_runtime_hook.py',
|
||||||
|
]
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
with open(f, encoding='utf-8') as fh:
|
||||||
|
ast.parse(fh.read())
|
||||||
|
print(f"OK: {f}")
|
||||||
|
except SyntaxError as e:
|
||||||
|
print(f"SYNTAX ERROR in {f}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("All files OK")
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,302 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
PyInstaller spec file for Kiwy Signage Player (Windows .exe)
|
||||||
|
|
||||||
|
Build command (from windows/ directory):
|
||||||
|
pyinstaller build.spec --clean --noconfirm
|
||||||
|
|
||||||
|
OR use the build script:
|
||||||
|
build_win.bat
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# --- Paths -----------------------------------------------------------
|
||||||
|
# This spec file is in windows/build.spec, so the project root is
|
||||||
|
# always two levels up from this file's real location.
|
||||||
|
# __file__ may not be available in PyInstaller spec context fallback to cwd.
|
||||||
|
try:
|
||||||
|
_spec_dir = Path(__file__).resolve().parent
|
||||||
|
except NameError:
|
||||||
|
_spec_dir = Path(os.getcwd()).resolve()
|
||||||
|
# _spec_dir is now the absolute path to the windows/ directory
|
||||||
|
BUILD_DIR = _spec_dir
|
||||||
|
ROOT_DIR = BUILD_DIR.parent
|
||||||
|
SRC_DIR = ROOT_DIR / 'src'
|
||||||
|
CONFIG_DIR = ROOT_DIR / 'config'
|
||||||
|
RESOURCES_DIR = CONFIG_DIR / 'resources'
|
||||||
|
|
||||||
|
# --- Determine hidden imports that PyInstaller might miss -------------
|
||||||
|
hidden_imports = [
|
||||||
|
# Kivy core modules
|
||||||
|
'kivy.core.window',
|
||||||
|
'kivy.core.video',
|
||||||
|
'kivy.core.audio',
|
||||||
|
'kivy.core.text',
|
||||||
|
'kivy.core.image',
|
||||||
|
'kivy.core.gl',
|
||||||
|
'kivy.core.camera',
|
||||||
|
'kivy.core.clipboard',
|
||||||
|
'kivy.core.spelling',
|
||||||
|
'kivy.core.text.markup',
|
||||||
|
'kivy.core.window.window_sdl2',
|
||||||
|
'kivy.core.image.img_sdl2',
|
||||||
|
'kivy.core.video.video_ffpyplayer',
|
||||||
|
'kivy.core.audio.audio_ffpyplayer',
|
||||||
|
# Kivy modules
|
||||||
|
'kivy.uix.video',
|
||||||
|
'kivy.uix.vkeyboard',
|
||||||
|
'kivy.uix.popup',
|
||||||
|
'kivy.uix.image',
|
||||||
|
'kivy.uix.button',
|
||||||
|
'kivy.uix.label',
|
||||||
|
'kivy.uix.textinput',
|
||||||
|
'kivy.uix.boxlayout',
|
||||||
|
'kivy.uix.floatlayout',
|
||||||
|
'kivy.uix.slider',
|
||||||
|
'kivy.uix.widget',
|
||||||
|
'kivy.uix.checkbox',
|
||||||
|
'kivy.graphics',
|
||||||
|
'kivy.graphics.texture',
|
||||||
|
'kivy.graphics.vertex_instructions',
|
||||||
|
'kivy.graphics.context_instructions',
|
||||||
|
'kivy.clock',
|
||||||
|
'kivy.loader',
|
||||||
|
'kivy.animation',
|
||||||
|
'kivy.lang',
|
||||||
|
'kivy.logger',
|
||||||
|
'kivy.config',
|
||||||
|
'kivy.properties',
|
||||||
|
'kivy.metrics',
|
||||||
|
'kivy.factory',
|
||||||
|
# Graphics providers
|
||||||
|
'kivy.graphics.opengl',
|
||||||
|
'kivy.graphics.opengl_utils',
|
||||||
|
'kivy.graphics.fbo',
|
||||||
|
'kivy.graphics.gl_instructions',
|
||||||
|
'kivy.graphics.stencil_instructions',
|
||||||
|
'kivy.graphics.scissor_instructions',
|
||||||
|
'kivy.graphics.buffer',
|
||||||
|
'kivy.graphics.vbo',
|
||||||
|
'kivy.graphics.shader',
|
||||||
|
'kivy.graphics.compiler',
|
||||||
|
# ffpyplayer
|
||||||
|
'ffpyplayer',
|
||||||
|
'ffpyplayer.player',
|
||||||
|
'ffpyplayer.pic',
|
||||||
|
'ffpyplayer.writer',
|
||||||
|
# Networking
|
||||||
|
'requests',
|
||||||
|
'aiohttp',
|
||||||
|
'urllib3',
|
||||||
|
'certifi',
|
||||||
|
'bcrypt',
|
||||||
|
# Platform
|
||||||
|
'ctypes',
|
||||||
|
'ctypes.wintypes',
|
||||||
|
'subprocess',
|
||||||
|
'shutil',
|
||||||
|
'glob',
|
||||||
|
'selectors',
|
||||||
|
'tempfile',
|
||||||
|
# Windows-specific
|
||||||
|
'cef_browser',
|
||||||
|
'win32gui',
|
||||||
|
'win32con',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Exclude Linux-only modules
|
||||||
|
excluded_imports = [
|
||||||
|
'gi', # GTK introspection (Linux)
|
||||||
|
'gi.repository',
|
||||||
|
'evdev', # We inject a fake evdev module in run_win.py
|
||||||
|
# GStreamer — we use ffpyplayer, not GStreamer
|
||||||
|
'kivy.lib.gstplayer',
|
||||||
|
# cefpython3: keep only Python 3.12 .pyd, exclude other version .pyd files
|
||||||
|
'cefpython3.cefpython_py27',
|
||||||
|
'cefpython3.cefpython_py34',
|
||||||
|
'cefpython3.cefpython_py35',
|
||||||
|
'cefpython3.cefpython_py36',
|
||||||
|
'cefpython3.cefpython_py37',
|
||||||
|
'cefpython3.cefpython_py38',
|
||||||
|
'cefpython3.cefpython_py39',
|
||||||
|
'cefpython3.cefpython_py310',
|
||||||
|
'cefpython3.cefpython_py311',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Application data files to bundle --------------------------------
|
||||||
|
# Resources (icons, intro video, etc.)
|
||||||
|
resources_data = []
|
||||||
|
for item in RESOURCES_DIR.iterdir():
|
||||||
|
if item.is_file():
|
||||||
|
target_dir = 'config/resources'
|
||||||
|
resources_data.append((str(item), target_dir))
|
||||||
|
|
||||||
|
# Config directory (app_config.json)
|
||||||
|
config_data = []
|
||||||
|
config_file = CONFIG_DIR / 'app_config.json'
|
||||||
|
if config_file.exists():
|
||||||
|
config_data.append((str(config_file), 'config'))
|
||||||
|
|
||||||
|
# Source files - .kv file
|
||||||
|
kv_file = SRC_DIR / 'signage_player.kv'
|
||||||
|
kv_data = []
|
||||||
|
if kv_file.exists():
|
||||||
|
kv_data.append((str(kv_file), '.'))
|
||||||
|
|
||||||
|
# Bundle the entire src directory as a tree
|
||||||
|
source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*.ini'])
|
||||||
|
|
||||||
|
# --- Collect binary DLLs from kivy_deps and ffpyplayer ----------------
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
|
||||||
|
def _site_packages_dir(package_path):
|
||||||
|
"""Climb up from a package's __init__.py to its site-packages dir."""
|
||||||
|
d = _Path(package_path).parent
|
||||||
|
while d.name != 'site-packages' and d.parent != d:
|
||||||
|
d = d.parent
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _find_share_dlls(package_name, share_name=None):
|
||||||
|
"""Find .dll files under venv_root/share/<share_name>/.
|
||||||
|
|
||||||
|
kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into
|
||||||
|
<venv>/share/<pkg>/... NOT inside the package dir. The share folder is
|
||||||
|
named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the
|
||||||
|
dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly.
|
||||||
|
"""
|
||||||
|
if share_name is None:
|
||||||
|
share_name = package_name
|
||||||
|
spec = importlib.util.find_spec(package_name)
|
||||||
|
if spec is None or not spec.origin:
|
||||||
|
return []
|
||||||
|
sp = _site_packages_dir(spec.origin)
|
||||||
|
# Climb from site-packages up until we find a sibling 'share' dir
|
||||||
|
# (site-packages -> Lib -> venv, where venv/share lives).
|
||||||
|
d = sp
|
||||||
|
while d.parent != d:
|
||||||
|
if (d.parent / 'share').is_dir():
|
||||||
|
share = d.parent / 'share' / share_name
|
||||||
|
break
|
||||||
|
d = d.parent
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
if not share.is_dir():
|
||||||
|
return []
|
||||||
|
results = []
|
||||||
|
for root, dirs, files in os.walk(share):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.dll'):
|
||||||
|
results.append((os.path.join(root, f), '.'))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _find_ffpyplayer_bins():
|
||||||
|
"""Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL).
|
||||||
|
|
||||||
|
ffpyplayer ships a `dep_bins` list that already points at the correct
|
||||||
|
share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ffpyplayer
|
||||||
|
bins = getattr(ffpyplayer, 'dep_bins', None)
|
||||||
|
if not bins:
|
||||||
|
return []
|
||||||
|
results = []
|
||||||
|
for b in bins:
|
||||||
|
bpath = _Path(b)
|
||||||
|
if bpath.is_dir():
|
||||||
|
for f in bpath.glob('*.dll'):
|
||||||
|
results.append((str(f), '.'))
|
||||||
|
return results
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs)
|
||||||
|
_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2')
|
||||||
|
_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle')
|
||||||
|
_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew')
|
||||||
|
|
||||||
|
# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins)
|
||||||
|
_ffpy_dlls = _find_ffpyplayer_bins()
|
||||||
|
|
||||||
|
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
||||||
|
|
||||||
|
if not _all_binaries:
|
||||||
|
print("=" * 70)
|
||||||
|
print("WARNING: No Kivy/ffpyplayer DLLs found via share/ directories.")
|
||||||
|
print("PyInstaller may still auto-detect them, but if the .exe")
|
||||||
|
print("fails with 'SDL2.dll not found' or similar, you will need")
|
||||||
|
print("to manually add the DLL paths to the spec file.")
|
||||||
|
print("=" * 70)
|
||||||
|
else:
|
||||||
|
print(f"[spec] Bundling {len(_all_binaries)} DLLs:")
|
||||||
|
for _p, _t in sorted(_all_binaries):
|
||||||
|
print(f" {_Path(_p).name} <- {_p}")
|
||||||
|
|
||||||
|
# --- Build the .exe --------------------------------------------------
|
||||||
|
a = Analysis(
|
||||||
|
['run_win.py'], # Entry point (relative to this spec)
|
||||||
|
pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules
|
||||||
|
binaries=_all_binaries,
|
||||||
|
datas=resources_data + config_data + kv_data,
|
||||||
|
hiddenimports=hidden_imports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[str(BUILD_DIR / 'pyi_runtime_hook.py')],
|
||||||
|
excludes=excluded_imports,
|
||||||
|
noarchive=False,
|
||||||
|
module_collection_mode={
|
||||||
|
'kivy': 'pyz',
|
||||||
|
'kivy.core': 'pyz',
|
||||||
|
'kivy.uix': 'pyz',
|
||||||
|
'kivy.graphics': 'pyz',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the source tree (main.py, etc.)
|
||||||
|
a.datas += source_tree
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='KiwySignagePlayer',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True, # Show console for debugging startup errors
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None,
|
||||||
|
version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- COLLECT everything into a single folder -------------------------
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='KiwySignagePlayer',
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
@echo off
|
||||||
|
REM =====================================================================
|
||||||
|
REM Kiwy Signage Player - Windows Build Script
|
||||||
|
REM =====================================================================
|
||||||
|
REM This script builds a standalone Windows .exe using PyInstaller.
|
||||||
|
REM
|
||||||
|
REM Prerequisites:
|
||||||
|
REM 1. Python 3.10+ installed (with "Add to PATH" checked)
|
||||||
|
REM 2. Visual C++ Redistributable (for ffpyplayer)
|
||||||
|
REM 3. FFmpeg binaries in PATH (optional, for video codec support)
|
||||||
|
REM
|
||||||
|
REM Steps:
|
||||||
|
REM 1. Run this script from the project root or the windows\ folder
|
||||||
|
REM 2. The .exe will be created in windows\dist\KiwySignagePlayer\
|
||||||
|
REM =====================================================================
|
||||||
|
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Kiwy Signage Player - Windows Build
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM ---- Check Python ----
|
||||||
|
where python >nul 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Python not found! Please install Python 3.10+ and add it to PATH.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [INFO] Using Python:
|
||||||
|
python --version
|
||||||
|
|
||||||
|
REM ---- Create virtual environment (if not exists) ----
|
||||||
|
if not exist "venv\Scripts\python.exe" (
|
||||||
|
echo.
|
||||||
|
echo [STEP] Creating virtual environment...
|
||||||
|
python -m venv venv
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Failed to create virtual environment.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Virtual environment already exists.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Activate virtual environment ----
|
||||||
|
call venv\Scripts\activate.bat
|
||||||
|
|
||||||
|
REM ---- Install/upgrade pip ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Upgrading pip...
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
|
||||||
|
REM ---- Install dependencies ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Installing Windows dependencies...
|
||||||
|
pip install -r requirements_win.txt
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Failed to install dependencies.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Verify Kivy installation ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Verifying Kivy installation...
|
||||||
|
python -c "import kivy; print(f'Kivy {kivy.__version__}')" 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [WARNING] Kivy check failed. Build may still work but test carefully.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Check PyInstaller ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Verifying PyInstaller...
|
||||||
|
python -c "import PyInstaller; print(f'PyInstaller {PyInstaller.__version__}')" 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] PyInstaller not found.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Create app icon (from PNG if possible) ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Checking for app icon...
|
||||||
|
if not exist "..\config\resources\app_icon.ico" (
|
||||||
|
echo [INFO] No .ico icon found. Will use default PyInstaller icon.
|
||||||
|
echo [INFO] To add a custom icon, place app_icon.ico in config\resources\
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Run PyInstaller ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Building executable with PyInstaller...
|
||||||
|
echo This may take several minutes. Please wait...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
pyinstaller build.spec --clean --noconfirm
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] PyInstaller build failed!
|
||||||
|
echo Check the output above for error details.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Success ----
|
||||||
|
echo.
|
||||||
|
echo ============================================
|
||||||
|
echo BUILD COMPLETE!
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Output: %~dp0dist\KiwySignagePlayer\
|
||||||
|
echo.
|
||||||
|
echo The executable is:
|
||||||
|
echo %~dp0dist\KiwySignagePlayer\KiwySignagePlayer.exe
|
||||||
|
echo.
|
||||||
|
echo To run: Double-click KiwySignagePlayer.exe
|
||||||
|
echo.
|
||||||
|
echo Note: The first run may take a while as Windows Defender
|
||||||
|
echo scans the executable. This is normal.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
cef_browser.py v2 — Embedded Chromium INSIDE Kivy's SDL2 window
|
||||||
|
|
||||||
|
v1 created a separate Win32 window (same as external Chrome).
|
||||||
|
v2 creates CEF as a **child window** of Kivy's SDL_app window:
|
||||||
|
- No separate taskbar entry
|
||||||
|
- No z-order fighting
|
||||||
|
- No desktop flash
|
||||||
|
- CEF message loop pumped via Kivy Clock (main thread)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cefpython3 import cefpython as cef
|
||||||
|
CEF_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
CEF_AVAILABLE = False
|
||||||
|
|
||||||
|
WS_CHILD = 0x40000000
|
||||||
|
WS_VISIBLE = 0x10000000
|
||||||
|
WS_CLIPSIBLINGS = 0x04000000
|
||||||
|
WS_CLIPCHILDREN = 0x02000000
|
||||||
|
SW_HIDE = 0
|
||||||
|
SW_SHOWNORMAL = 1
|
||||||
|
|
||||||
|
|
||||||
|
class CefBrowser:
|
||||||
|
def __init__(self):
|
||||||
|
self._browser = None
|
||||||
|
self._cef_initialized = False
|
||||||
|
self._child_hwnd = None
|
||||||
|
self._kivy_hwnd = None
|
||||||
|
self._clock_event = None
|
||||||
|
self._showing = False
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def show(self, url):
|
||||||
|
if not CEF_AVAILABLE:
|
||||||
|
return False
|
||||||
|
if not self._cef_initialized:
|
||||||
|
self._init_cef()
|
||||||
|
if self._browser is not None:
|
||||||
|
self._browser.Navigate(url)
|
||||||
|
self._show_in_kivy()
|
||||||
|
return True
|
||||||
|
return self._create_embedded(url)
|
||||||
|
|
||||||
|
def hide(self):
|
||||||
|
self._showing = False
|
||||||
|
if self._clock_event is not None:
|
||||||
|
try:
|
||||||
|
from kivy.clock import Clock
|
||||||
|
Clock.unschedule(self._clock_event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._clock_event = None
|
||||||
|
if self._child_hwnd:
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_HIDE)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._browser is not None:
|
||||||
|
try:
|
||||||
|
self._browser.CloseBrowser(True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._browser = None
|
||||||
|
if self._child_hwnd:
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.DestroyWindow(self._child_hwnd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._child_hwnd = None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.hide()
|
||||||
|
if self._cef_initialized:
|
||||||
|
try:
|
||||||
|
cef.Shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._cef_initialized = False
|
||||||
|
|
||||||
|
def navigate(self, url):
|
||||||
|
if self._browser is not None:
|
||||||
|
self._browser.Navigate(url)
|
||||||
|
|
||||||
|
def is_showing(self):
|
||||||
|
return self._showing
|
||||||
|
|
||||||
|
def resize(self, width, height):
|
||||||
|
"""Called when Kivy window resizes — repositions CEF child."""
|
||||||
|
if self._child_hwnd:
|
||||||
|
ctypes.windll.user32.SetWindowPos(
|
||||||
|
self._child_hwnd, 0, 0, 0, width, height, 0x0004
|
||||||
|
)
|
||||||
|
if self._browser:
|
||||||
|
self._browser.SetBounds(0, 0, width, height)
|
||||||
|
|
||||||
|
# ── Internal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _init_cef(self):
|
||||||
|
settings = {
|
||||||
|
"multi_threaded_message_loop": False,
|
||||||
|
"single_process": True,
|
||||||
|
"log_severity": cef.LOGSEVERITY_WARNING,
|
||||||
|
"user_agent": "Mozilla/5.0 KiwySignage/1.0",
|
||||||
|
"cache_path": str(
|
||||||
|
Path(os.environ.get("KIWY_DATA_DIR", ".")) / ".cef_cache"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
cef.Initialize(settings=settings)
|
||||||
|
self._cef_initialized = True
|
||||||
|
|
||||||
|
def _get_kivy_hwnd(self):
|
||||||
|
if self._kivy_hwnd is not None:
|
||||||
|
return self._kivy_hwnd
|
||||||
|
try:
|
||||||
|
import win32gui
|
||||||
|
hwnd = win32gui.FindWindow("SDL_app", None)
|
||||||
|
if hwnd:
|
||||||
|
self._kivy_hwnd = hwnd
|
||||||
|
return hwnd
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_embedded(self, url):
|
||||||
|
kivy_hwnd = self._get_kivy_hwnd()
|
||||||
|
if not kivy_hwnd:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
rect = (ctypes.c_long * 4)()
|
||||||
|
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||||
|
w, h = rect[2], rect[3]
|
||||||
|
|
||||||
|
hinstance = ctypes.windll.kernel32.GetModuleHandleW(None)
|
||||||
|
self._child_hwnd = user32.CreateWindowExW(
|
||||||
|
0, b'#32770', b'',
|
||||||
|
WS_CHILD | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
|
||||||
|
0, 0, w, h, kivy_hwnd, 0, hinstance, 0,
|
||||||
|
)
|
||||||
|
if not self._child_hwnd:
|
||||||
|
return False
|
||||||
|
|
||||||
|
winfo = cef.WindowInfo()
|
||||||
|
winfo.SetAsChild(self._child_hwnd, [0, 0, w, h])
|
||||||
|
self._browser = cef.CreateBrowserSync(
|
||||||
|
window_info=winfo,
|
||||||
|
settings={"background_color": 0x00000000},
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
self._showing = True
|
||||||
|
self._show_in_kivy()
|
||||||
|
self._start_clock_pump()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _show_in_kivy(self):
|
||||||
|
if not self._child_hwnd:
|
||||||
|
return
|
||||||
|
kivy_hwnd = self._get_kivy_hwnd()
|
||||||
|
if kivy_hwnd:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
rect = (ctypes.c_long * 4)()
|
||||||
|
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||||
|
user32.SetWindowPos(
|
||||||
|
self._child_hwnd, 0, 0, 0, rect[2], rect[3], 0x0004
|
||||||
|
)
|
||||||
|
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_SHOWNORMAL)
|
||||||
|
self._showing = True
|
||||||
|
|
||||||
|
def _start_clock_pump(self):
|
||||||
|
if self._clock_event is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _pump(dt):
|
||||||
|
if self._cef_initialized:
|
||||||
|
try:
|
||||||
|
cef.MessageLoopWork()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._showing:
|
||||||
|
from kivy.clock import Clock
|
||||||
|
self._clock_event = Clock.schedule_once(_pump, 0.01)
|
||||||
|
|
||||||
|
from kivy.clock import Clock
|
||||||
|
self._clock_event = Clock.schedule_once(_pump, 0)
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# 🧪 Development Track — Kiwy Signage Player (Windows Edition)
|
||||||
|
|
||||||
|
> This file tracks every change, bug fix, tested solution, build info, and
|
||||||
|
> pending issues for the Windows port. Read this FIRST before starting any
|
||||||
|
> debugging or coding session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Current Session — 2026-07-31
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Branch** | `Windows-Player` |
|
||||||
|
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||||
|
| **Kivy** | 2.3.1 |
|
||||||
|
| **PyInstaller** | 6.21.0 |
|
||||||
|
| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||||
|
| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||||
|
|
||||||
|
### 📋 Cross-platform audit — Linux commands → Windows handling
|
||||||
|
|
||||||
|
Every Linux-only command in `src/` was cross-referenced against the patches
|
||||||
|
in `windows/run_win.py`. All are covered except the one listed below:
|
||||||
|
|
||||||
|
| # | File / method | Linux commands | Windows handling |
|
||||||
|
|---|---------------|----------------|------------------|
|
||||||
|
| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` |
|
||||||
|
| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess |
|
||||||
|
| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog |
|
||||||
|
| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back |
|
||||||
|
| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` |
|
||||||
|
| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` |
|
||||||
|
| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||||
|
| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||||
|
| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Bug Tracker
|
||||||
|
|
||||||
|
### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on
|
||||||
|
connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` /
|
||||||
|
`sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present).
|
||||||
|
- **Root cause:** This module was missed when the other Linux paths were
|
||||||
|
patched in `run_win.py`.
|
||||||
|
- **Fix:** Made `network_monitor.py` self-contained cross-platform:
|
||||||
|
1. Added `IS_WINDOWS = platform.system() == 'Windows'`
|
||||||
|
2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows
|
||||||
|
3. `_restart_wifi()` dispatches to `_restart_wifi_windows()`
|
||||||
|
(`netsh wlan disconnect` → wait → `netsh wlan connect`) or
|
||||||
|
`_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact)
|
||||||
|
- **Files:** `src/network_monitor.py`
|
||||||
|
- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly)
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** Web link items don't show. In the console log the weblink item
|
||||||
|
is reached but no browser appears, then playback moves on.
|
||||||
|
- **Root causes (two compounding):**
|
||||||
|
1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])`
|
||||||
|
delegates the URL to the already-running Chrome process and this launched
|
||||||
|
process **exits immediately** (`poll() != None`) → the watchdog fired
|
||||||
|
instantly and advanced to the next item, so the weblink never displayed.
|
||||||
|
2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called
|
||||||
|
`_bring_kivy_to_front()`, so even when Chrome did open it sat *behind*
|
||||||
|
the borderless-fullscreen Kivy window.
|
||||||
|
- **Fix (in `windows/run_win.py`):**
|
||||||
|
1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`<data>/.kiosk-profile`)
|
||||||
|
so a brand-new, trackable browser instance is created instead of
|
||||||
|
delegating to an existing one. Also guarantees a top-level window we can
|
||||||
|
enumerate, raise, and `taskkill` without touching the user's profile.
|
||||||
|
2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper
|
||||||
|
that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID)
|
||||||
|
instead of raising Kivy.
|
||||||
|
- **Update (2026-07-31 15:42):** added **`--kiosk`** flag to the weblink launch
|
||||||
|
args so the browser opens in true kiosk mode (no UI/chrome, locks to screen).
|
||||||
|
Safe with the dedicated `--user-data-dir` — does not affect the user's normal
|
||||||
|
browser session.
|
||||||
|
- **Update (2026-07-31 16:04):** replaced the fixed 1.0s overlay-hide timer with
|
||||||
|
**adaptive polling** (`_hide_overlay_when_chrome_ready`). The black overlay now
|
||||||
|
stays up until Chrome's window is actually detected on screen
|
||||||
|
(`_find_chrome_hwnd`), so the host desktop is never exposed during cold
|
||||||
|
starts / slow disk / GPU init. Falls back to Kivy after a 6s timeout.
|
||||||
|
- **Update (2026-07-31 16:19):** added a **persistent `_Win32Backdrop`** — a
|
||||||
|
fullscreen black window created at player startup (`_Win32Backdrop.show()`)
|
||||||
|
placed at `HWND_BOTTOM` (below Kivy & the kiosk browser, above the desktop),
|
||||||
|
destroyed only on clean exit. Any browser load/unload gap now reveals clean
|
||||||
|
black instead of the host desktop.
|
||||||
|
- **Test:** exe rebuilt 2026-07-31 16:19; DLL set intact (28 DLLs incl. FFmpeg).
|
||||||
|
|
||||||
|
### [BUG-012] Next widget never comes to foreground after weblink ends
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** After a weblink finishes, the next media/widget renders but the
|
||||||
|
Kivy window stays behind (or the window focus is lost) — user sees the wrong
|
||||||
|
window / frozen view.
|
||||||
|
- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but
|
||||||
|
`win32con` is a pure-Python module in `win32\lib\` that is **only importable
|
||||||
|
via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller
|
||||||
|
apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` —
|
||||||
|
only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The
|
||||||
|
`import win32con` threw, the whole function silently fell back to
|
||||||
|
`Window.raise_window()`, and the Kivy window was never reliably raised.
|
||||||
|
- **Fix (in `windows/run_win.py`):**
|
||||||
|
1. Replaced the `win32con` dependency with **raw ctypes + numeric constants**
|
||||||
|
(`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …).
|
||||||
|
2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with
|
||||||
|
`AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost
|
||||||
|
flash.
|
||||||
|
3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows
|
||||||
|
for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as
|
||||||
|
last-resort fallback.
|
||||||
|
- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [BUG-001] RecursionError: play_current_media ↔ restart_playlist
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes
|
||||||
|
infinite recursion: `play_current_media → restart_playlist → play_current_media → ...`
|
||||||
|
- **Fix:** Added empty-playlist guard in both `play_current_media()` and
|
||||||
|
`restart_playlist()` → they return early instead of calling each other.
|
||||||
|
- **Files:** `src/main.py` — lines ~1304 and ~2073
|
||||||
|
- **Test:** Verified no Python syntax errors via `ast.parse`.
|
||||||
|
|
||||||
|
### [BUG-002] Settings fields cut off on small screens
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** "Screen Name", "Quickconnect" and other fields at the top of
|
||||||
|
the settings popup are invisible on smaller resolutions because content
|
||||||
|
overflows the popup.
|
||||||
|
- **Fix:** Wrapped settings content in a `ScrollView`. Moved "Save & Close" /
|
||||||
|
"Cancel" buttons outside the scroll (always visible). Reduced row heights.
|
||||||
|
- **Files:** `src/signage_player.kv` — `<SettingsPopup@Popup>` block
|
||||||
|
|
||||||
|
### [BUG-003] Chromium not fullscreen on Windows
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** Web links open in a small window instead of fullscreen.
|
||||||
|
- **Fix:** Changed launch args from `--kiosk` to `--start-maximized --app=URL`
|
||||||
|
+ explicit `--window-size=WxH`. `--kiosk` uses Wayland exclusive-fullscreen
|
||||||
|
protocol which doesn't work on Windows.
|
||||||
|
- **Tested rejected solutions:**
|
||||||
|
- ❌ `--kiosk` alone → small window, no fullscreen
|
||||||
|
- ❌ `--start-fullscreen` alone → not reliable
|
||||||
|
- ✅ `--start-maximized --app=URL --window-size=...` → works
|
||||||
|
- **Files:** `windows/run_win.py` — `_windows_play_weblink()`
|
||||||
|
|
||||||
|
### [BUG-004] Desktop flash when switching between Chromium and Kivy
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** When Chrome closes, the desktop is briefly visible before Kivy
|
||||||
|
reappears. Also when Chrome opens, there's a flash.
|
||||||
|
- **Fix:** Added `_Win32Overlay` class — a fullscreen black Win32 window that
|
||||||
|
covers the screen during transitions. Shown BEFORE closing Chrome / opening
|
||||||
|
Chrome, hidden AFTER Kivy is ready.
|
||||||
|
- **Tested rejected solutions:**
|
||||||
|
- ❌ `Window.raise_window()` alone → still shows flash
|
||||||
|
- ✅ Win32 black overlay → smooth masking
|
||||||
|
- **Files:** `windows/run_win.py` — `_Win32Overlay` class
|
||||||
|
|
||||||
|
### [BUG-005] Chrome processes linger after closing weblink
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** After a weblink item ends, Chrome child processes (GPU,
|
||||||
|
renderer) remain running → blank windows accumulate.
|
||||||
|
- **Fix:** Use `taskkill /F /T /PID <pid>` to kill the entire process tree.
|
||||||
|
- **Tested rejected solutions:**
|
||||||
|
- ❌ `proc.terminate()` → leaves children running
|
||||||
|
- ❌ `proc.kill()` → same problem
|
||||||
|
- ✅ `taskkill /F /T` → kills everything
|
||||||
|
- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()`
|
||||||
|
|
||||||
|
### [BUG-007] Video plays behind Chromium on weblink→media transition
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-26 (final)**
|
||||||
|
- **Symptom:** When a weblink ends and the next media starts, the media plays
|
||||||
|
*behind* Chromium. Audio is heard but user sees Chrome.
|
||||||
|
- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
|
||||||
|
Chrome → widget visible. On Windows Chrome stays ON TOP.
|
||||||
|
`Window.raise_window()` is unreliable. Three compounding issues:
|
||||||
|
1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably
|
||||||
|
2. `_windows_play_current_media` killed the browser but never restored
|
||||||
|
`content_area.opacity = 1`, so next widget rendered invisible
|
||||||
|
3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow`
|
||||||
|
refuses to let a background process steal focus
|
||||||
|
- **Fix applied (2026-07-26):**
|
||||||
|
1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy
|
||||||
|
stays visible behind the overlay instead of being hidden
|
||||||
|
2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media`
|
||||||
|
and `_windows_kill_weblink_after_frame()` — ensures next widget is visible
|
||||||
|
3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass
|
||||||
|
Windows foreground lock so Kivy can steal focus from Chrome
|
||||||
|
4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of
|
||||||
|
`Window.raise_window()`
|
||||||
|
5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls
|
||||||
|
`_bring_kivy_to_front()` after hiding
|
||||||
|
- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess
|
||||||
|
Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path.
|
||||||
|
- **Files:** `windows/run_win.py`
|
||||||
|
|
||||||
|
### [BUG-008] Intro video and media files not found at runtime
|
||||||
|
- **Status:** ✅ **Fixed** 2026-07-24
|
||||||
|
- **Symptom:** `[ERROR] [Image] Error loading <...intro1.mp4>` — intro
|
||||||
|
broken. Also `❌ Media file not found` for playlist items.
|
||||||
|
- **Root cause:** Media download only ran when `server_version > local_version`.
|
||||||
|
When versions matched (v16 == v16), `download_media_files` was never called
|
||||||
|
→ media folder stayed empty.
|
||||||
|
- **Fix:** Added download check in the "up to date" branch — now downloads
|
||||||
|
missing media files even when playlist version hasn't changed.
|
||||||
|
|
||||||
|
### [BUG-009] Video never advances to next item (EOS handler empty)
|
||||||
|
- **Status:** ✅ **Fixed** 2026-07-24
|
||||||
|
- **Symptom:** Video plays but never advances to the next playlist item.
|
||||||
|
- **Root cause:** `_on_video_eos()` callback was a stub — just logged
|
||||||
|
"Video finished playing (EOS)" but never called `next_media()`.
|
||||||
|
- **Fix:** Added `Clock.unschedule(self.next_media)` + `Clock.schedule_once`
|
||||||
|
to advance after 0.5s when a video reaches end of stream.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Tested & Rejected Solutions Log
|
||||||
|
|
||||||
|
> Keep a record of approaches that were tried and didn't work, so we don't
|
||||||
|
> waste time re-testing them.
|
||||||
|
|
||||||
|
| Date | What was tested | Result | Reason it failed |
|
||||||
|
|------|----------------|--------|-----------------|
|
||||||
|
| 2026-07-24 | Python 3.14 with Kivy | ❌ | `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel |
|
||||||
|
| 2026-07-24 | `--kiosk` Chrome flag on Windows | ❌ | Not fullscreen, Wayland exclusive-fullscreen not available |
|
||||||
|
| 2026-07-24 | `--start-fullscreen` alone | ❌ | Inconsistent, sometimes not full |
|
||||||
|
| 2026-07-24 | `proc.terminate()` for Chrome | ❌ | Leaves child processes running |
|
||||||
|
| 2026-07-24 | `proc.kill()` for Chrome | ❌ | Same as terminate — children survive |
|
||||||
|
| 2026-07-24 | `Window.raise_window()` for transition | ❌ | Brief desktop flash visible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Data Directory Behaviour
|
||||||
|
|
||||||
|
When the .exe runs:
|
||||||
|
1. Runtime hook (`pyi_runtime_hook.py`) sets `KIWY_DATA_DIR = exe_dir`
|
||||||
|
2. `run_win.py` patches `SignagePlayer.__init__` to use `KIWY_DATA_DIR`
|
||||||
|
3. Local folders created next to the .exe:
|
||||||
|
```
|
||||||
|
KiwySignagePlayer.exe
|
||||||
|
config/
|
||||||
|
app_config.json
|
||||||
|
resources/ (icons, intro video)
|
||||||
|
certs/ (SSL certificates)
|
||||||
|
media/
|
||||||
|
edited_media/
|
||||||
|
playlists/
|
||||||
|
logs/
|
||||||
|
.kivy/ (Kivy home)
|
||||||
|
.player_heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Build Cheatsheet
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Build the .exe (from windows/ directory)
|
||||||
|
Set-Location windows
|
||||||
|
& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||||
|
|
||||||
|
# Run in dev mode (no build needed)
|
||||||
|
& .\venv\Scripts\python.exe run_win.py
|
||||||
|
|
||||||
|
# Test imports only
|
||||||
|
& .\venv\Scripts\python.exe test_import_fix.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes for the Next Session
|
||||||
|
|
||||||
|
- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
|
||||||
|
- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui`
|
||||||
|
- [x] Install `cefpython3` — embedded Chromium, no more subprocess
|
||||||
|
- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge
|
||||||
|
- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()`
|
||||||
|
- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir
|
||||||
|
- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run
|
||||||
|
- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec`
|
||||||
|
- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux)
|
||||||
|
- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix
|
||||||
|
- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway)
|
||||||
|
- [ ] Verify the .exe works on a fresh Windows machine (no Python installed)
|
||||||
|
- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed
|
||||||
|
- [ ] Add a standalone `.bat` launcher for development mode
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
REM ============================================================
|
||||||
|
REM Kiwy Signage Player - Windows Launcher
|
||||||
|
REM ============================================================
|
||||||
|
REM This batch file launches the Kiwy Signage Player executable.
|
||||||
|
REM It creates local folders for playlist, media, config, and logs
|
||||||
|
REM next to the executable.
|
||||||
|
REM ============================================================
|
||||||
|
|
||||||
|
cd /d "%~dp0dist\KiwySignagePlayer"
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Kiwy Signage Player - Windows Edition
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Launching player...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
start "" "KiwySignagePlayer.exe"
|
||||||
|
|
||||||
|
echo Player started.
|
||||||
|
echo.
|
||||||
|
echo If the player window does not appear, check:
|
||||||
|
echo dist\KiwySignagePlayer\logs\crash.log
|
||||||
|
echo dist\KiwySignagePlayer\logs\fatal_crash.log
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
PyInstaller Runtime Hook for Kiwy Signage Player
|
||||||
|
------------------------------------------------
|
||||||
|
Runs at startup of the packaged .exe to fix paths and environment.
|
||||||
|
Creates all necessary folders LOCAL to the executable's directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── IMPORTANT: Set Windows environment BEFORE any Kivy code runs ──
|
||||||
|
# This must happen before main.py's top-level code executes, because
|
||||||
|
# main.py sets SDL_VIDEODRIVER=wayland,x11,dummy which would crash on Windows.
|
||||||
|
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||||
|
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||||
|
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||||
|
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||||
|
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||||
|
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||||
|
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||||
|
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||||
|
os.environ['KIVY_NO_FILELOG'] = '1'
|
||||||
|
os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect on Windows
|
||||||
|
|
||||||
|
# ── Capture ALL early output to a crash log ─────────────────────────
|
||||||
|
# Ensure we catch any exception that happens before Logger is available.
|
||||||
|
_startup_log_path = None
|
||||||
|
try:
|
||||||
|
_exe_dir = Path(sys.executable).parent
|
||||||
|
_startup_log_path = _exe_dir / 'logs' / 'startup_crash.log'
|
||||||
|
(_startup_log_path.parent).mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(_startup_log_path, 'w') as _f:
|
||||||
|
_f.write("pyi_runtime_hook.py started\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_paths():
|
||||||
|
"""Ensure the app can find its bundled files at runtime.
|
||||||
|
|
||||||
|
All data folders (config, media, playlists, logs) are created
|
||||||
|
LOCAL to the executable's directory — NOT in %%APPDATA%%.
|
||||||
|
"""
|
||||||
|
# In PyInstaller, sys.executable is the .exe path.
|
||||||
|
# sys._MEIPASS is the extraction directory (i.e. _internal/ folder).
|
||||||
|
exe_dir = Path(sys.executable).parent
|
||||||
|
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||||
|
|
||||||
|
# ── Change cwd to _internal so Builder.load_file('signage_player.kv')
|
||||||
|
# and other relative file references from main.py resolve ─────
|
||||||
|
os.chdir(str(internal_dir))
|
||||||
|
|
||||||
|
# Add bundled src directory to Python path
|
||||||
|
src_dir = str(internal_dir / 'src')
|
||||||
|
if os.path.isdir(src_dir) and src_dir not in sys.path:
|
||||||
|
sys.path.insert(0, src_dir)
|
||||||
|
|
||||||
|
# Add internal directory for config/media/playlists access
|
||||||
|
if str(internal_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(internal_dir))
|
||||||
|
|
||||||
|
# ── Local folders next to the .exe ──────────────────────────────
|
||||||
|
# All data lives in the SAME folder as the executable so the user
|
||||||
|
# can copy/move the whole directory and everything still works.
|
||||||
|
os.environ['KIWY_DATA_DIR'] = str(exe_dir)
|
||||||
|
|
||||||
|
# Set KIVY_HOME to a local .kivy folder next to the .exe
|
||||||
|
kivy_home = exe_dir / '.kivy'
|
||||||
|
os.environ.setdefault('KIVY_HOME', str(kivy_home))
|
||||||
|
kivy_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create local data folders next to the .exe
|
||||||
|
for sub in ['config', 'config/resources', 'media', 'playlists', 'logs']:
|
||||||
|
(exe_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_bundled_resources():
|
||||||
|
"""Copy bundled resource/config files to the local folders on first run."""
|
||||||
|
exe_dir = Path(sys.executable).parent
|
||||||
|
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||||
|
|
||||||
|
# Files to copy (source in bundle -> destination next to .exe)
|
||||||
|
files_to_copy = [
|
||||||
|
('config/app_config.json', 'config/app_config.json'),
|
||||||
|
('config/resources/access-card.png', 'config/resources/access-card.png'),
|
||||||
|
('config/resources/arrow.png', 'config/resources/arrow.png'),
|
||||||
|
('config/resources/backward.png', 'config/resources/backward.png'),
|
||||||
|
('config/resources/card-checked.png', 'config/resources/card-checked.png'),
|
||||||
|
('config/resources/edit-pen.png', 'config/resources/edit-pen.png'),
|
||||||
|
('config/resources/exit.png', 'config/resources/exit.png'),
|
||||||
|
('config/resources/forward.png', 'config/resources/forward.png'),
|
||||||
|
('config/resources/intro1.mp4', 'config/resources/intro1.mp4'),
|
||||||
|
('config/resources/pause.png', 'config/resources/pause.png'),
|
||||||
|
('config/resources/pencil.png', 'config/resources/pencil.png'),
|
||||||
|
('config/resources/play.png', 'config/resources/play.png'),
|
||||||
|
('config/resources/settings.png', 'config/resources/settings.png'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for src_rel, dest_rel in files_to_copy:
|
||||||
|
src_path = internal_dir / src_rel
|
||||||
|
dest_path = exe_dir / dest_rel
|
||||||
|
if src_path.is_file() and not dest_path.exists():
|
||||||
|
try:
|
||||||
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(str(src_path), str(dest_path))
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical; app can still run
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wrap everything in try/except to capture early crashes ──────────
|
||||||
|
try:
|
||||||
|
_setup_paths()
|
||||||
|
_copy_bundled_resources()
|
||||||
|
# If we reach here, the hook finished successfully
|
||||||
|
try:
|
||||||
|
with open(_startup_log_path, 'a') as _f:
|
||||||
|
_f.write("pyi_runtime_hook.py completed successfully\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as _hook_exc:
|
||||||
|
import traceback as _tb
|
||||||
|
try:
|
||||||
|
with open(_startup_log_path, 'a') as _f:
|
||||||
|
_f.write(f"pyi_runtime_hook.py CRASHED: {_hook_exc}\n")
|
||||||
|
_tb.print_exc(file=_f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise # Re-raise so the .exe still fails visibly
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# =====================================================================
|
||||||
|
# Kiwy Signage Player - Windows Dependencies
|
||||||
|
# =====================================================================
|
||||||
|
# Install with: pip install -r requirements_win.txt
|
||||||
|
|
||||||
|
# --- Core GUI Framework ---
|
||||||
|
# Kivy 2.3+ with SDL2 backend (best for Windows)
|
||||||
|
kivy[base]>=2.3.0
|
||||||
|
|
||||||
|
# --- Video Playback ---
|
||||||
|
# ffpyplayer for video decoding
|
||||||
|
ffpyplayer>=4.5
|
||||||
|
|
||||||
|
# --- HTTP / Networking ---
|
||||||
|
requests>=2.32.0,<3.0.0
|
||||||
|
aiohttp>=3.9.0,<4.0.0
|
||||||
|
certifi>=2024.0.0
|
||||||
|
|
||||||
|
# --- Password / Auth ---
|
||||||
|
bcrypt>=4.2.0,<5.0.0
|
||||||
|
|
||||||
|
# --- Packaging ---
|
||||||
|
# PyInstaller for building the .exe
|
||||||
|
pyinstaller>=6.0
|
||||||
|
|
||||||
|
# --- Windows-specific Libraries ---
|
||||||
|
# cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge)
|
||||||
|
# Installed separately because it's a large package (69 MB):
|
||||||
|
# pip install cefpython3
|
||||||
|
# cefpython3>=66.1
|
||||||
|
# Note: Uncomment above line to bundle cefpython3 in the .exe.
|
||||||
|
# Without it, weblinks fall back to subprocess Chrome/Edge.
|
||||||
|
|
||||||
|
# pywin32: Windows API bindings (win32gui for SetForegroundWindow etc.)
|
||||||
|
# Already installed as a dependency of kivy[base]
|
||||||
|
|
||||||
|
# --- Optional: DirectShow filters for better video on Windows ---
|
||||||
|
# ffmpeg (install via chocolatey or manual download)
|
||||||
|
# https://ffmpeg.org/download.html
|
||||||
+1469
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
"""Test that setting env vars before importing main.py fixes the crash."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# This is the KEY fix: set Windows env vars BEFORE main.py is imported
|
||||||
|
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||||
|
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||||
|
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||||
|
# Use 'angle_sdl2' on Windows for better DirectX compatibility
|
||||||
|
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||||
|
# Let Kivy auto-detect input providers on Windows
|
||||||
|
os.environ['KIVY_INPUTPROVIDERS'] = ''
|
||||||
|
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||||
|
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||||
|
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||||
|
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, r'C:\Users\Dell-PC\Desktop\Kiwy-Signage\src')
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Testing main.py import with Windows env vars...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import main
|
||||||
|
print("SUCCESS: main.py imported without crashing!")
|
||||||
|
print(f" SDL_VIDEODRIVER = {os.environ.get('SDL_VIDEODRIVER')}")
|
||||||
|
print(f" KIVY_WINDOW = {os.environ.get('KIVY_WINDOW')}")
|
||||||
|
print(f" KIVY_GL_BACKEND = {os.environ.get('KIVY_GL_BACKEND')}")
|
||||||
|
print(f" KIVY_INPUTPROVIDERS = {os.environ.get('KIVY_INPUTPROVIDERS')}")
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"FAILED: SystemExit({e}) - Kivy window provider still not loading")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAILED with exception: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# UTF-8
|
||||||
|
#
|
||||||
|
# Windows version resource for KiwySignagePlayer.exe
|
||||||
|
# This file is used by PyInstaller (version=) to embed publisher/product
|
||||||
|
# metadata into the executable so Windows Smart App Control / SmartScreen
|
||||||
|
# can identify the app instead of flagging it as "Unknown publisher".
|
||||||
|
#
|
||||||
|
# Note: A code-signing certificate is still required for a fully trusted
|
||||||
|
# publisher name; this metadata at least names the product/company and
|
||||||
|
# supplies a version number.
|
||||||
|
#
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=(1, 2, 0, 0),
|
||||||
|
prodvers=(1, 2, 0, 0),
|
||||||
|
mask=0x3f,
|
||||||
|
flags=0x0,
|
||||||
|
OS=0x40004,
|
||||||
|
fileType=0x1,
|
||||||
|
subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo(
|
||||||
|
[
|
||||||
|
StringTable(
|
||||||
|
'040904B0',
|
||||||
|
[
|
||||||
|
StringStruct('CompanyName', 'Kiwy Signage'),
|
||||||
|
StringStruct('FileDescription', 'Kiwy Signage Player - Digital Signage Player'),
|
||||||
|
StringStruct('FileVersion', '1.2.0.0'),
|
||||||
|
StringStruct('InternalName', 'KiwySignagePlayer'),
|
||||||
|
StringStruct('LegalCopyright', 'Copyright (c) 2026 Kiwy Signage'),
|
||||||
|
StringStruct('OriginalFilename', 'KiwySignagePlayer.exe'),
|
||||||
|
StringStruct('ProductName', 'Kiwy Signage Player'),
|
||||||
|
StringStruct('ProductVersion', '1.2.0.0'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
VarFileInfo([VarStruct('Translation', [1033, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
Executable → Regular
Reference in New Issue
Block a user