Compare commits

..

18 Commits

Author SHA1 Message Date
ske087 31ad592e98 Fix Windows player: kiosk lockdown, robust video transitions, keep-awake
- Production/kiosk mode: exit_on_escape=0, window-close guard, Ctrl+C
  ignore, Alt+F4/Alt+Tab/Win/Ctrl+Esc keyboard hook (Windows)
- Robust video playback: async (non-blocking) ffpyplayer teardown,
  video progress watchdog (advance at true clip end), EOS re-entrancy
  guard, stale-advance guard, focus keeper for foreground retention
- Resume playback timer after Settings/exit popups close
- Windows keep-awake: SetThreadExecutionState + disable screensaver/
  lock screen (restored on exit)
- Always-on playback_trace.log for diagnosing transitions
- exe metadata: app_icon.ico + version_info.txt (publisher identity)
2026-08-04 15:57:33 +03:00
ske087 5c2b3f545f working windows module 2026-07-31 15:40:59 +03:00
ske087 d0ea94447a Exclude cefpython3 other-Python-version .pyd and GStreamer from build
- Added kivy.lib.gstplayer to excluded_imports (we use ffpyplayer)
- Added all cefpython3_py{27,34-311}.pyd to excluded_imports
- Removes ~300 lines of library-not-found warnings from build output
- Reduces .exe size from 143 MB to 96 MB
2026-07-24 16:45:31 +03:00
ske087 12f2880201 Rewrite CEF browser as child of Kivy window (v2)
v1 created a separate Win32 window — same problem as external Chrome.
v2 creates CEF as a CHILD WINDOW of Kivy's SDL_app window:
  - No separate taskbar entry
  - No z-order fighting (CEF is INSIDE Kivy)
  - No desktop flash
  - CEF message loop pumped via Kivy Clock (main thread)
  - Resize handler attached so CEF follows Kivy window changes
  - build.spec includes cef_browser in hidden imports
2026-07-24 16:22:33 +03:00
ske087 5a030671a2 Fix 3 bugs: delete_unused_media path mismatch, media not downloading on same-version, video EOS not advancing
- delete_unused_media: normalize Windows backslashes to forward slashes
  when comparing with playlist file_name (fixes file deleted after download)
- update_playlist_if_needed: download media even when playlist version matches
- _on_video_eos: now schedules next_media() — was an empty stub
2026-07-24 16:10:23 +03:00
ske087 a2add88f04 Fix video not advancing + media not downloading on same-version startup
BUG-009: _on_video_eos was empty (stub). Added Clock.schedule_once
for next_media() when video reaches end of stream.

BUG-008: download_media_files only ran when server_version >
local_version. Added download check in up-to-date branch so media
files are synced even when playlist version hasn't changed.
2026-07-24 15:43:56 +03:00
ske087 c4e8381898 Remove venv_build from git tracking (should not commit venv) 2026-07-24 15:10:20 +03:00
ske087 ced6e10919 Rebuild .exe with CEF + win32gui fixes
- Updated build.spec with win32gui/win32con hidden imports
- Updated requirements_win.txt with cefpython3 docs
- Rebuilt .exe at 2026-07-24 14:41 (143 MB with CEF)
- All 12 resources bundled (icons, intro1.mp4)
- development-track.md updated with BUG-007, BUG-008
2026-07-24 15:09:48 +03:00
ske087 844e5eeebb Add CEF embedded browser + win32gui for weblink handling
Windows-specific fixes:
- New cef_browser.py: embedded Chromium via cefpython3, no subprocess
- _bring_kivy_to_front(): uses win32gui.SetForegroundWindow (reliable)
- _windows_kill_weblink_after_frame: kills Chrome IMMEDIATELY
- prewarm_weblink disabled on Windows (desktop launch is fast)
- _windows_play_weblink tries CEF first, falls back to subprocess
- Updated requirements_win.txt (cefpython3, pywin32 confirmed)
- Added development-track.md for change tracking
2026-07-24 14:39:12 +03:00
ske087 7efc023327 Add development-track.md — session log, bug tracker, rejected solutions, build info 2026-07-24 13:52:28 +03:00
ske087 6abde5a767 Fix Windows weblink handling: fullscreen Chrome, black overlay masking, proper process tree kill
Windows-specific fixes:
- _windows_play_weblink: uses --start-maximized + --app=URL for true fullscreen
- Shows black Win32 overlay before opening/closing Chrome to mask desktop
- _windows_kill_process_tree: uses taskkill /F /T to kill all Chrome child processes
- _Win32Overlay class: fullscreen borderless always-on-top black window
- Updated README to note Python 3.12 requirement and local data dir behavior
2026-07-24 13:48:49 +03:00
ske087 362f5096a0 Fix infinite recursion when restarting player with empty playlist
- Added empty playlist guard in play_current_media() to return
  early instead of calling restart_playlist()
- Added empty playlist guard in restart_playlist() to return
  early instead of calling play_current_media()
- Wrapped SettingsPopup content in ScrollView so fields
  are not cut off on smaller screens
2026-07-24 09:53:29 +03:00
ske087 3845830a86 Add Windows Player support and related files
- New windows/ directory with build scripts, specs, and configuration
- Windows-specific requirements (requirements_win.txt)
- Launch and runtime scripts for Windows (run_win.py, launch_player.bat)
- PyInstaller build configuration (build.spec)
- Updated .gitignore to exclude windows/venv312/
- Updated config and source files for Windows compatibility
- Moved working_files to proper directory
2026-07-24 08:34:00 +03:00
SKE087 f7b889be11 updated files 2026-07-21 12:23:59 +03:00
SKE087 39565c5761 Improve Raspberry Pi startup and Chromium transitions 2026-07-20 16:27:22 +03:00
SKE087 fb9f46a94f Fix ffpyplayer offline wheel and update install scripts
- Rebuilt evdev wheel for cp313 aarch64 (was missing from repo)
- Updated install.sh: add --system-site-packages to venv, verify imports
- Updated start.sh: activate .venv before running player
- Updated run_player.sh: activate .venv before running player
- Added pip fallback for kivy/ffpyplayer if apt/pip install misses them
2026-07-17 13:02:59 +03:00
SKE087 9d8eb0cf21 Fix Python 3.13 compatibility and installation issues
- Replaced all cp311 wheels with cp313 wheels for Python 3.13 compatibility
- Added new dependency wheels: aiohappyeyeballs, evdev, ffpyplayer
- Rebuilt install.sh with virtual environment + offline wheel installation
- Added --sudo-user/-U and --sudo-password/-W CLI arguments
- Added run_sudo() helper to avoid password prompts during installation
- Fixed 'Setting Up Player Directories' hang (cat->stdin) by using touch
- Updated start.sh to activate virtual environment before running player
- Updated run_player.sh to activate virtual environment
- Removed old cp311 incompatible wheels
- Added backup of previous install.sh as install.sh.bak
2026-07-17 10:21:32 +03:00
Kiwy Player 64e49886df Fix install.sh bugs and add auto-reboot
- Fix: Replace broad pkill with PID-specific kill -9 in start.sh
- Fix: Remove unreachable deactivate call in start.sh
- Fix: Add find_boot_config helper for RPi config file detection
- Fix: Update autostart status message (remove fake systemd service claim)
- Fix: Add ACTUAL_USER/ACTUAL_HOME to power management function
- Add: Auto-reboot at end of install.sh with 5s cancel window
- Add: Ctrl+C cleanup trap during installation
2026-07-15 21:17:14 +03:00
62 changed files with 5731 additions and 1085 deletions
+3
View File
@@ -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
View File
@@ -1 +0,0 @@
1782752485.750587
+11
View File
@@ -0,0 +1,11 @@
#\!/bin/bash
SCRIPT_DIR="/home/pi/kiwy-signage"
LOG_FILE="/home/pi/kiwy-signage/logs/player-background.log"
mkdir -p "."
echo "[2026-07-17 10:54:53] Starting player in background..." >> ""
cd "/home/pi/kiwy-signage" || exit 1
nohup bash start.sh >> "" 2>&1 &
PLAYER_PID=$\!
echo "[2026-07-17 10:54:53] Player started with PID: $PLAYER_PID" >> ""
echo "Player started in background (PID: $PLAYER_PID)"
echo "Logs: $LOG_FILE"
+2 -5
View File
@@ -1,6 +1,3 @@
#!/bin/bash #\!/bin/bash
# Wait for desktop environment to be ready
sleep 15 sleep 15
cd /home/pi/kiwy-signage && bash start.sh
# Start the player
cd "/home/pi/Desktop/Kiwy-Signage" && bash start.sh
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"server_ip": "192.168.0.159", "server_ip": "192.168.0.107",
"port": "8080", "port": "8080",
"screen_name": "rpi-Receptie", "screen_name": "WINDOWS-PC",
"quickconnect_key": "8887779", "quickconnect_key": "8887779",
"orientation": "Landscape", "orientation": "Landscape",
"touch": "True", "touch": "True",
Regular → Executable
+590 -562
View File
File diff suppressed because it is too large Load Diff
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

+22 -8
View File
@@ -1,23 +1,37 @@
{ {
"count": 2, "count": 4,
"player_id": 1, "player_id": 1,
"player_name": "Receptie TV", "player_name": "Test_player1",
"playlist": [ "playlist": [
{ {
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg", "file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
"type": "image", "type": "image",
"url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg", "url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
"duration": 25, "duration": 14,
"edit_on_player": true
},
{
"file_name": "weblink-1e4a4d6d885a",
"type": "weblink",
"url": "https://moto-adv.com/",
"duration": 30,
"edit_on_player": false "edit_on_player": false
}, },
{ {
"file_name": "robert-lukeman-zNN6ubHmruI-unsplash.jpg", "file_name": "sample-30s.mp4",
"type": "image", "type": "video",
"url": "media/robert-lukeman-zNN6ubHmruI-unsplash.jpg", "url": "media/sample-30s.mp4",
"duration": 20, "duration": 31,
"edit_on_player": false "edit_on_player": false
},
{
"file_name": "edited_media/5/eye_e_v2.jpg",
"type": "image",
"url": "media/edited_media/5/eye_e_v2.jpg",
"duration": 50,
"edit_on_player": true
} }
], ],
"playlist_id": 1, "playlist_id": 1,
"playlist_version": 15 "playlist_version": 14
} }
+1
View File
@@ -0,0 +1 @@
Python 3.12.9
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -4
View File
@@ -1,7 +1,6 @@
kivy>=2.3.0 kivy>=2.3.0
ffpyplayer ffpyplayer
requests==2.32.4 requests>=2.32.0,<3.0.0
bcrypt==4.2.1 bcrypt>=4.2.0,<5.0.0
aiohttp==3.9.1 aiohttp>=3.9.0,<4.0.0
asyncio==3.4.3
evdev>=1.6.0 evdev>=1.6.0
-1
View File
@@ -1,6 +1,5 @@
#!/bin/bash #!/bin/bash
# Start Kivy Signage Player # Start Kivy Signage Player
cd "$(dirname "$0")" && source .venv/bin/activate
cd "$(dirname "$0")/src" cd "$(dirname "$0")/src"
python3 main.py python3 main.py
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
MARKER_FILE="/etc/kiwy-signage/pi_boot_config_patched.marker"
is_pi() {
if [[ -f /proc/device-tree/model ]] && grep -qi "raspberry pi" /proc/device-tree/model; then
return 0
fi
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then
return 0
fi
return 1
}
find_boot_config() {
if [[ -f /boot/firmware/config.txt ]]; then
echo "/boot/firmware/config.txt"
elif [[ -f /boot/config.txt ]]; then
echo "/boot/config.txt"
else
echo ""
fi
}
upsert_key() {
local key="$1"
local value="$2"
local file="$3"
if grep -Eq "^[#[:space:]]*${key}=" "$file"; then
sed -i -E "s|^[#[:space:]]*${key}=.*|${key}=${value}|" "$file"
else
echo "${key}=${value}" >> "$file"
fi
}
if [[ "${EUID}" -ne 0 ]]; then
echo "[pi-boot-patch] Please run as root."
exit 1
fi
if ! is_pi; then
echo "[pi-boot-patch] Not a Raspberry Pi. Skipping."
exit 0
fi
if [[ -f "$MARKER_FILE" ]]; then
echo "[pi-boot-patch] Patch already applied. Skipping."
exit 0
fi
BOOT_CONFIG="$(find_boot_config)"
if [[ -z "$BOOT_CONFIG" ]]; then
echo "[pi-boot-patch] Boot config not found. Skipping."
exit 0
fi
echo "[pi-boot-patch] Applying patch to ${BOOT_CONFIG}"
# Graphics and memory settings for smoother Chromium playback on Pi.
upsert_key "dtoverlay" "vc4-kms-v3d" "$BOOT_CONFIG"
upsert_key "gpu_mem" "256" "$BOOT_CONFIG"
upsert_key "disable_overscan" "1" "$BOOT_CONFIG"
# Keep HDMI and display active for kiosk use.
upsert_key "hdmi_blanking" "0" "$BOOT_CONFIG"
upsert_key "hdmi_ignore_cec_init" "1" "$BOOT_CONFIG"
upsert_key "hdmi_force_hotplug" "1" "$BOOT_CONFIG"
mkdir -p "$(dirname "$MARKER_FILE")"
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$MARKER_FILE"
echo "[pi-boot-patch] Patch complete. Marker written to ${MARKER_FILE}"
+15 -1
View File
@@ -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:
+522 -88
View File
@@ -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,68 +1452,80 @@ 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 (Wayland-safe) ────────────────── # ── Weblink → media transition (desktop-flash safe) ──────────────
# On Wayland (Labwc) Window.raise_window() is a no-op, so we must # For web→media transitions, render the next Kivy widget first,
# ensure Chromium's fullscreen window is fully gone BEFORE we try # then close Chromium on the next frame. This prevents a brief
# to render Kivy content. We: # desktop exposure while the compositor removes Chromium.
# 1. Stop watchdog + preload immediately
# 2. Terminate Chromium (non-blocking)
# 3. Hide the Kivy content_area so nothing stale is visible
# 4. Wait 200 ms for the compositor to remove Chromium's window
# 5. Show content_area + re-call play_current_media to render
if not _after_weblink: if not _after_weblink:
proc = getattr(self, '_weblink_proc', None) proc = getattr(self, '_weblink_proc', None)
if proc is not None: if proc is not None:
self._stop_inactivity_watchdog() next_is_weblink = media_item.get('type') == 'weblink'
self._kill_weblink_preload() if next_is_weblink:
self._weblink_proc = None # Weblink -> weblink: close current Chromium first.
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
self._weblink_proc = None
# Hide content while Chromium is still closing
try:
self.ids.content_area.opacity = 0
except Exception:
pass
# Terminate Chromium
if proc.poll() is None:
try:
proc.terminate()
except Exception as exc:
Logger.warning(f"SignagePlayer: weblink terminate: {exc}")
def _resume(dt):
# Force-kill if still alive after the delay
if proc.poll() is None: if proc.poll() is None:
try: try:
proc.kill() proc.terminate()
except Exception as exc:
Logger.warning(f"SignagePlayer: weblink terminate: {exc}")
def _resume(dt):
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
try:
Window.show()
Window.raise_window()
except Exception: except Exception:
pass pass
# Restore content area and show Kivy window self.play_current_media(
try: force_reload=force_reload, _after_weblink=True
self.ids.content_area.opacity = 1 )
except Exception:
pass
try:
Window.show()
Window.raise_window()
except Exception:
pass
# Now render the actual media
self.play_current_media(
force_reload=force_reload, _after_weblink=True
)
# 200 ms gives Labwc time to remove the fullscreen surface Clock.schedule_once(_resume, 0.2)
Clock.schedule_once(_resume, 0.2) return
return
# Weblink -> non-weblink: keep Chromium visible while we
# prepare next Kivy frame, then close Chromium deferred.
self._stop_inactivity_watchdog()
self._kill_weblink_preload()
self._weblink_proc = proc
try:
self.ids.content_area.opacity = 1
except Exception:
pass
try:
Window.show()
Window.raise_window()
except Exception:
pass
Logger.debug("SignagePlayer: Deferred Chromium close after next frame render")
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
# 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
@@ -1374,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:
@@ -1413,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}")
@@ -1441,8 +1604,15 @@ class SignagePlayer(Widget):
self.consecutive_errors = 0 self.consecutive_errors = 0
Logger.debug(f"SignagePlayer: Media started successfully") Logger.debug(f"SignagePlayer: Media started successfully")
# If we arrived here from a weblink item, close Chromium after the
# 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:
trace("closing_weblink_after_frame", name=file_name)
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
@@ -1469,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
@@ -1494,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)
@@ -1507,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"""
@@ -1559,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.
@@ -1602,9 +1988,12 @@ class SignagePlayer(Widget):
self._skip_to_next_media() self._skip_to_next_media()
return False return False
target_width, target_height = self._get_browser_target_size()
try: try:
Logger.info(f"SignagePlayer: Opening weblink in kiosk browser: {url}") Logger.info(f"SignagePlayer: Opening weblink in kiosk browser: {url}")
Logger.info(f"SignagePlayer: Inactivity timeout set to {duration}s (touch resets the countdown)") Logger.info(f"SignagePlayer: Inactivity timeout set to {duration}s (touch resets the countdown)")
Logger.info(f"SignagePlayer: Chromium target launch size: {target_width}x{target_height}")
# Kill the hidden pre-warm instance first; its work (binary in RAM, # Kill the hidden pre-warm instance first; its work (binary in RAM,
# page in disk cache) makes the kiosk relaunch below near-instant. # page in disk cache) makes the kiosk relaunch below near-instant.
self._kill_weblink_preload() self._kill_weblink_preload()
@@ -1616,6 +2005,7 @@ class SignagePlayer(Widget):
# --start-fullscreen is a normal maximised window that the # --start-fullscreen is a normal maximised window that the
# compositor can un-stack without issues. # compositor can un-stack without issues.
'--start-fullscreen', '--start-fullscreen',
'--start-maximized',
'--app=' + url, '--app=' + url,
'--noerrdialogs', '--noerrdialogs',
'--disable-infobars', '--disable-infobars',
@@ -1631,6 +2021,8 @@ class SignagePlayer(Widget):
'--disable-background-networking', '--disable-background-networking',
'--no-default-browser-check', '--no-default-browser-check',
'--window-position=0,0', '--window-position=0,0',
f'--window-size={target_width},{target_height}',
'--force-device-scale-factor=1',
]) ])
# Unschedule any previous fixed timer — the watchdog thread takes # Unschedule any previous fixed timer — the watchdog thread takes
@@ -1653,6 +2045,23 @@ class SignagePlayer(Widget):
self._skip_to_next_media() self._skip_to_next_media()
return False return False
def _get_browser_target_size(self):
"""Return a stable browser launch size to avoid 800x600 first-paint flash."""
default_width, default_height = 1920, 1080
try:
width, height = Window.size
width = int(width)
height = int(height)
except Exception:
return default_width, default_height
# Chromium sometimes first-paints at 800x600 before fullscreen;
# force at least Full HD when reported dimensions are too small.
if width < 1280 or height < 720:
return default_width, default_height
return width, height
def _start_inactivity_watchdog(self, duration): def _start_inactivity_watchdog(self, duration):
"""Start a background thread that monitors /dev/input/* for touch/key """Start a background thread that monitors /dev/input/* for touch/key
activity. The thread resets the idle counter on every event; when the activity. The thread resets the idle counter on every event; when the
@@ -1849,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
@@ -1893,6 +2318,8 @@ class SignagePlayer(Widget):
if not browser: if not browser:
return return
target_width, target_height = self._get_browser_target_size()
# Kill any stale preload first. # Kill any stale preload first.
self._kill_weblink_preload() self._kill_weblink_preload()
@@ -1903,7 +2330,7 @@ class SignagePlayer(Widget):
'--app=' + url, '--app=' + url,
# Place window completely outside the visible display area. # Place window completely outside the visible display area.
'--window-position=-9999,-9999', '--window-position=-9999,-9999',
'--window-size=1920,1080', # pre-render at full resolution f'--window-size={target_width},{target_height}', # pre-render at target resolution
'--noerrdialogs', '--noerrdialogs',
'--disable-infobars', '--disable-infobars',
'--incognito', '--incognito',
@@ -2035,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)
@@ -2255,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
View File
@@ -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}")
+64
View File
@@ -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
+5 -5
View File
@@ -1,10 +1,10 @@
{ {
"hostname": "rpi-Receptie", "hostname": "WINDOWS-PC",
"auth_code": "BrJrGzX_IT9oP_Lfke8Qgo4sDjMc49T1EhSnWmKKpDM", "auth_code": "",
"player_id": 1, "player_id": 2,
"player_name": "Receptie TV", "player_name": "Windows-Player1",
"playlist_id": 1, "playlist_id": 1,
"orientation": "Landscape", "orientation": "Landscape",
"authenticated": true, "authenticated": true,
"server_url": "http://192.168.0.159:8080" "server_url": "http://192.168.0.107:8080"
} }
+313 -289
View File
@@ -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'
+7 -18
View File
@@ -218,19 +218,6 @@ rm -f "$STOP_FLAG_FILE"
# Change to the project directory # Change to the project directory
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
# Check if virtual environment exists
if [ -d ".venv" ]; then
log_message "✓ Virtual environment found"
source .venv/bin/activate
else
log_message "⚠️ Creating virtual environment..."
python3 -m venv .venv
source .venv/bin/activate
log_message "📦 Installing dependencies..."
pip3 install -r requirements.txt
log_message "✓ Virtual environment ready"
fi
# Check if configuration exists # Check if configuration exists
if [ ! -f "config/app_config.json" ]; then if [ ! -f "config/app_config.json" ]; then
log_message "⚠️ WARNING: Configuration file not found!" log_message "⚠️ WARNING: Configuration file not found!"
@@ -250,6 +237,11 @@ while true; do
# Clean old heartbeat # Clean old heartbeat
rm -f "$HEARTBEAT_FILE" rm -f "$HEARTBEAT_FILE"
# Activate virtual environment if it exists
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
source "$SCRIPT_DIR/.venv/bin/activate"
fi
# Start the player # Start the player
cd "$SCRIPT_DIR/src" cd "$SCRIPT_DIR/src"
python3 main.py & python3 main.py &
@@ -292,8 +284,8 @@ while true; do
log_message "⏳ Waiting ${RESTART_DELAY}s before restart..." log_message "⏳ Waiting ${RESTART_DELAY}s before restart..."
sleep $RESTART_DELAY sleep $RESTART_DELAY
# Cleanup any zombie processes # Ensure old player process is gone before restarting
pkill -9 -f "python3 main.py" 2>/dev/null kill -9 $PLAYER_PID 2>/dev/null
done done
@@ -301,6 +293,3 @@ log_message ""
log_message "==========================================" log_message "=========================================="
log_message "Watchdog stopped" log_message "Watchdog stopped"
log_message "==========================================" log_message "=========================================="
# Deactivate virtual environment (this line is never reached in watchdog mode)
deactivate
+34
View File
@@ -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)
+160
View File
@@ -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)
```
+18
View File
@@ -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

+302
View File
@@ -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',
)
+126
View File
@@ -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
+192
View File
@@ -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)
+289
View File
@@ -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
+27
View File
@@ -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
+131
View File
@@ -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
+39
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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)
+43
View File
@@ -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])])
]
)
View File
File diff suppressed because it is too large Load Diff