Compare commits
13 Commits
main
..
31ad592e98
| Author | SHA1 | Date | |
|---|---|---|---|
| 31ad592e98 | |||
| 5c2b3f545f | |||
| d0ea94447a | |||
| 12f2880201 | |||
| 5a030671a2 | |||
| a2add88f04 | |||
| c4e8381898 | |||
| ced6e10919 | |||
| 844e5eeebb | |||
| 7efc023327 | |||
| 6abde5a767 | |||
| 362f5096a0 | |||
| 3845830a86 |
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Aggressive display keep-alive for Raspberry Pi
|
||||||
|
# Supports both X11 and Wayland environments
|
||||||
|
|
||||||
|
DISPLAY_TIMEOUT=30
|
||||||
|
|
||||||
|
# Detect display server type
|
||||||
|
detect_display_server() {
|
||||||
|
if [ -n "$WAYLAND_DISPLAY" ]; then
|
||||||
|
echo "wayland"
|
||||||
|
elif [ -n "$DISPLAY" ]; then
|
||||||
|
echo "x11"
|
||||||
|
else
|
||||||
|
echo "unknown"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
DISPLAY_SERVER=$(detect_display_server)
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# Keep HDMI powered on (works for both X11 and Wayland)
|
||||||
|
if command -v tvservice &> /dev/null; then
|
||||||
|
/usr/bin/tvservice -p 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DISPLAY_SERVER" = "wayland" ]; then
|
||||||
|
# Wayland-specific power management
|
||||||
|
|
||||||
|
# Method 1: Use wlr-randr for Wayland compositors (if available)
|
||||||
|
if command -v wlr-randr &> /dev/null; then
|
||||||
|
wlr-randr --output HDMI-A-1 --on 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 2: Prevent idle using systemd-inhibit
|
||||||
|
if command -v systemd-inhibit &> /dev/null; then
|
||||||
|
# This is already running, but refresh the lock
|
||||||
|
systemctl --user restart plasma-ksmserver.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 3: Use wlopm (Wayland output power management)
|
||||||
|
if command -v wlopm &> /dev/null; then
|
||||||
|
wlopm --on \* 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 4: Simulate activity via input (works on Wayland)
|
||||||
|
if command -v ydotool &> /dev/null; then
|
||||||
|
ydotool mousemove -x 1 -y 1 2>/dev/null || true
|
||||||
|
ydotool mousemove -x -1 -y -1 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Method 5: GNOME/KDE Wayland idle inhibit
|
||||||
|
if command -v gnome-session-inhibit &> /dev/null; then
|
||||||
|
# Already inhibited by running process
|
||||||
|
true
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
# X11-specific power management (original code)
|
||||||
|
if command -v xset &> /dev/null; then
|
||||||
|
DISPLAY=:0 xset s off 2>/dev/null
|
||||||
|
DISPLAY=:0 xset -dpms 2>/dev/null
|
||||||
|
DISPLAY=:0 xset dpms force on 2>/dev/null
|
||||||
|
DISPLAY=:0 xset s reset 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Move mouse to trigger activity
|
||||||
|
if command -v xdotool &> /dev/null; then
|
||||||
|
DISPLAY=:0 xdotool mousemove_relative 1 1 2>/dev/null
|
||||||
|
DISPLAY=:0 xdotool mousemove_relative -1 -1 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disable monitor power saving
|
||||||
|
if command -v xrandr &> /dev/null; then
|
||||||
|
DISPLAY=:0 xrandr --output HDMI-1 --power-profile performance 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep $DISPLAY_TIMEOUT
|
||||||
|
done
|
||||||
+15
-36
@@ -25,53 +25,30 @@ wheels/
|
|||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
|
windows/venv312/
|
||||||
|
|
||||||
# Kivy
|
# Kivy
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Media files (optional - remove if you want to track media)
|
# Media files (optional - remove if you want to track media)
|
||||||
media/**/*.jpg
|
media/*.jpg
|
||||||
media/**/*.jpeg
|
media/*.jpeg
|
||||||
media/**/*.png
|
media/*.png
|
||||||
media/**/*.gif
|
media/*.gif
|
||||||
media/**/*.bmp
|
media/*.bmp
|
||||||
media/**/*.mp4
|
media/*.mp4
|
||||||
media/**/*.avi
|
media/*.avi
|
||||||
media/**/*.mkv
|
media/*.mkv
|
||||||
media/**/*.mov
|
media/*.mov
|
||||||
media/**/*.webm
|
media/*.webm
|
||||||
|
|
||||||
# Downloaded / on-device edited media (server-managed runtime content)
|
# Playlists cache (auto-generated)
|
||||||
media/edited_media/
|
playlists/server_playlist_*.json
|
||||||
|
|
||||||
# Playlists cache (auto-generated by the player)
|
|
||||||
playlists/server_playlist*.json
|
|
||||||
|
|
||||||
# Player configuration (machine-specific; install.sh writes a template if absent)
|
|
||||||
config/app_config.json
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
.kivy/
|
.kivy/
|
||||||
|
|
||||||
# Runtime state (generated by the player / watchdog)
|
|
||||||
.player_heartbeat
|
|
||||||
.player_stop_requested
|
|
||||||
.player_watchdog.lock
|
|
||||||
.post-clone-setup.done
|
|
||||||
.post-clone-backups/
|
|
||||||
|
|
||||||
# Installer-generated launchers (recreated by install.sh)
|
|
||||||
.run-background.sh
|
|
||||||
.start-player-cron.sh
|
|
||||||
|
|
||||||
# Secrets / machine-specific auth tokens (never commit)
|
|
||||||
player_auth.json
|
|
||||||
|
|
||||||
# Local backups / editor leftovers
|
|
||||||
*.bak
|
|
||||||
*~
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
@@ -83,3 +60,5 @@ player_auth.json
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
.player_heartbear
|
.player_heartbear
|
||||||
|
|
||||||
|
windows/venv_build/
|
||||||
|
|||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Keep-screen-alive wrapper for player
|
||||||
|
# Prevents screen from locking/turning off while player is running
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Function to keep screen awake
|
||||||
|
keep_screen_awake() {
|
||||||
|
while true; do
|
||||||
|
# Move mouse slightly to prevent idle
|
||||||
|
if command -v xdotool &> /dev/null; then
|
||||||
|
xdotool mousemove_relative 1 1
|
||||||
|
xdotool mousemove_relative -1 -1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disable DPMS and screensaver periodically
|
||||||
|
if command -v xset &> /dev/null; then
|
||||||
|
xset s reset
|
||||||
|
xset dpms force on
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to inhibit systemd sleep (if available)
|
||||||
|
inhibit_sleep() {
|
||||||
|
if command -v systemd-inhibit &> /dev/null; then
|
||||||
|
# Run player under systemd inhibit to prevent sleep
|
||||||
|
systemd-inhibit --what=sleep --why="Signage player running" \
|
||||||
|
bash "$SCRIPT_DIR/start.sh"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try systemd inhibit first (most reliable)
|
||||||
|
if inhibit_sleep; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: Start keep-alive in background
|
||||||
|
keep_screen_awake &
|
||||||
|
KEEPALIVE_PID=$!
|
||||||
|
|
||||||
|
# Start the player
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
bash start.sh
|
||||||
|
PLAYER_EXIT=$?
|
||||||
|
|
||||||
|
# Kill keep-alive when player exits
|
||||||
|
kill $KEEPALIVE_PID 2>/dev/null || true
|
||||||
|
|
||||||
|
exit $PLAYER_EXIT
|
||||||
Executable
+11
@@ -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"
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#\!/bin/bash
|
||||||
|
sleep 15
|
||||||
|
cd /home/pi/kiwy-signage && bash start.sh
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Wait for display server to be ready before starting the app
|
||||||
|
# This prevents Kivy from failing to initialize graphics
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MAX_WAIT=60
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
echo "[$(date)] Waiting for display server to be ready..."
|
||||||
|
|
||||||
|
# Wait for display socket/device to appear
|
||||||
|
while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||||
|
# Check for Wayland socket (primary for Bookworm)
|
||||||
|
if [ -S "$XDG_RUNTIME_DIR/wayland-0" ] 2>/dev/null; then
|
||||||
|
echo "[$(date)] ✓ Wayland display socket found"
|
||||||
|
export WAYLAND_DISPLAY=wayland-0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for X11 display
|
||||||
|
if [ -S "$XDG_RUNTIME_DIR/X11/display:0" ] 2>/dev/null; then
|
||||||
|
echo "[$(date)] ✓ X11 display socket found"
|
||||||
|
export DISPLAY=:0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if display manager is running (for fallback)
|
||||||
|
if pgrep -f "wayland|weston|gnome-shell|xfwm4|openbox" > /dev/null 2>&1; then
|
||||||
|
echo "[$(date)] ✓ Display manager detected"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date)] Waiting for display... ($ELAPSED/$MAX_WAIT seconds)"
|
||||||
|
sleep 1
|
||||||
|
((ELAPSED++))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ELAPSED -ge $MAX_WAIT ]; then
|
||||||
|
echo "[$(date)] ⚠️ Display timeout after $MAX_WAIT seconds, proceeding anyway..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set default display if not detected
|
||||||
|
if [ -z "$WAYLAND_DISPLAY" ] && [ -z "$DISPLAY" ]; then
|
||||||
|
echo "[$(date)] Using fallback display settings"
|
||||||
|
export DISPLAY=:0
|
||||||
|
export WAYLAND_DISPLAY=wayland-0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date)] Environment: DISPLAY=$DISPLAY WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
|
||||||
|
echo "[$(date)] XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
|
||||||
|
|
||||||
|
# Now start the app
|
||||||
|
cd "$SCRIPT_DIR" || exit 1
|
||||||
|
exec bash start.sh
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"server_ip": "192.168.0.107",
|
||||||
|
"port": "8080",
|
||||||
|
"screen_name": "WINDOWS-PC",
|
||||||
|
"quickconnect_key": "8887779",
|
||||||
|
"orientation": "Landscape",
|
||||||
|
"touch": "True",
|
||||||
|
"max_resolution": "1920x1080",
|
||||||
|
"edit_feature_enabled": true,
|
||||||
|
"use_https": false,
|
||||||
|
"verify_ssl": false
|
||||||
|
}
|
||||||
+53
-175
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#\!/bin/bash
|
||||||
|
|
||||||
# Kivy Signage Player Installation Script
|
# Kivy Signage Player Installation Script
|
||||||
# Supports both online and offline installation using a Python virtual environment
|
# Supports both online and offline installation using a Python virtual environment
|
||||||
@@ -19,8 +19,7 @@
|
|||||||
# -n, --name NAME Screen/player name
|
# -n, --name NAME Screen/player name
|
||||||
# -k, --key KEY Quick-connect / auth key
|
# -k, --key KEY Quick-connect / auth key
|
||||||
# --https Enable HTTPS
|
# --https Enable HTTPS
|
||||||
# --verify-ssl Enable SSL certificate verification
|
# --no-verify-ssl Disable SSL verification
|
||||||
# --no-verify-ssl Disable SSL verification (default)
|
|
||||||
# -o, --orientation Landscape or Portrait
|
# -o, --orientation Landscape or Portrait
|
||||||
# --touch Enable touch input
|
# --touch Enable touch input
|
||||||
# -r, --resolution WxH Max resolution
|
# -r, --resolution WxH Max resolution
|
||||||
@@ -78,48 +77,6 @@ detect_raspberry_pi() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Helper: Internet reachability probe (dependency-free)
|
|
||||||
# ============================================================
|
|
||||||
# Returns 0 if the public internet (PyPI) is reachable, 1 otherwise.
|
|
||||||
# Uses only the Python stdlib so it works on a bare, offline host.
|
|
||||||
has_internet() {
|
|
||||||
python3 - << 'PYEOF' >/dev/null 2>&1
|
|
||||||
import socket
|
|
||||||
try:
|
|
||||||
socket.setdefaulttimeout(3)
|
|
||||||
socket.create_connection(("pypi.org", 443), timeout=3).close()
|
|
||||||
raise SystemExit(0)
|
|
||||||
except Exception:
|
|
||||||
raise SystemExit(1)
|
|
||||||
PYEOF
|
|
||||||
return $?
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Helper: Fetch a URL using curl, wget, or the Python stdlib
|
|
||||||
# ============================================================
|
|
||||||
# Works on minimal/offline images where curl/wget may be absent.
|
|
||||||
fetch_url() {
|
|
||||||
local url="$1"
|
|
||||||
if command -v curl &>/dev/null; then
|
|
||||||
curl -sSf --max-time 20 "$url"
|
|
||||||
elif command -v wget &>/dev/null; then
|
|
||||||
wget -qO- --timeout=20 "$url"
|
|
||||||
else
|
|
||||||
python3 - "$url" << 'PYEOF'
|
|
||||||
import sys, urllib.request
|
|
||||||
url = sys.argv[1]
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(url, timeout=20) as r:
|
|
||||||
sys.stdout.write(r.read().decode("utf-8", "replace"))
|
|
||||||
except Exception as e:
|
|
||||||
sys.stderr.write("fetch failed: %s\n" % e)
|
|
||||||
raise SystemExit(1)
|
|
||||||
PYEOF
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# CLI Argument Parsing
|
# CLI Argument Parsing
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -135,7 +92,6 @@ MAX_RESOLUTION=""
|
|||||||
EDIT_ENABLED=""
|
EDIT_ENABLED=""
|
||||||
PROVISION_URL=""
|
PROVISION_URL=""
|
||||||
CONFIG_FILE_PATH=""
|
CONFIG_FILE_PATH=""
|
||||||
NO_REBOOT=false
|
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 [OPTIONS]"
|
echo "Usage: $0 [OPTIONS]"
|
||||||
@@ -155,8 +111,7 @@ usage() {
|
|||||||
echo " -n, --name NAME Screen/player name"
|
echo " -n, --name NAME Screen/player name"
|
||||||
echo " -k, --key KEY Quick-connect / auth key"
|
echo " -k, --key KEY Quick-connect / auth key"
|
||||||
echo " --https Enable HTTPS for server communication"
|
echo " --https Enable HTTPS for server communication"
|
||||||
echo " --verify-ssl Enable SSL certificate verification"
|
echo " --no-verify-ssl Disable SSL certificate verification"
|
||||||
echo " --no-verify-ssl Disable SSL certificate verification (default)"
|
|
||||||
echo " -o, --orientation Landscape or Portrait (default: Landscape)"
|
echo " -o, --orientation Landscape or Portrait (default: Landscape)"
|
||||||
echo " --touch Enable touch input"
|
echo " --touch Enable touch input"
|
||||||
echo " -r, --resolution WxH Max resolution (default: 1920x1080)"
|
echo " -r, --resolution WxH Max resolution (default: 1920x1080)"
|
||||||
@@ -164,7 +119,6 @@ usage() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Installation options:"
|
echo "Installation options:"
|
||||||
echo " --offline Force offline installation from local packages"
|
echo " --offline Force offline installation from local packages"
|
||||||
echo " --no-reboot Do not reboot automatically at the end"
|
|
||||||
echo " -q, --quiet Silent mode (minimal output)"
|
echo " -q, --quiet Silent mode (minimal output)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -208,10 +162,6 @@ while [ $# -gt 0 ]; do
|
|||||||
USE_HTTPS="true"
|
USE_HTTPS="true"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--verify-ssl)
|
|
||||||
VERIFY_SSL="true"
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--no-verify-ssl)
|
--no-verify-ssl)
|
||||||
VERIFY_SSL="false"
|
VERIFY_SSL="false"
|
||||||
shift
|
shift
|
||||||
@@ -236,10 +186,6 @@ while [ $# -gt 0 ]; do
|
|||||||
OFFLINE_MODE=true
|
OFFLINE_MODE=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--no-reboot)
|
|
||||||
NO_REBOOT=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-q|--quiet)
|
-q|--quiet)
|
||||||
exec > >(tee -a /tmp/kivy-install.log) 2>&1
|
exec > >(tee -a /tmp/kivy-install.log) 2>&1
|
||||||
shift
|
shift
|
||||||
@@ -259,12 +205,6 @@ if [ "$OFFLINE_MODE" = false ] && [ -d "$WHEELS_DIR" ] && [ "$(ls -A "$WHEELS_DI
|
|||||||
OFFLINE_MODE=true
|
OFFLINE_MODE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Probe internet reachability once (guards every network step below)
|
|
||||||
HAS_INTERNET=false
|
|
||||||
if has_internet; then
|
|
||||||
HAS_INTERNET=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Mode Banner
|
# Mode Banner
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -276,11 +216,6 @@ if [ "$OFFLINE_MODE" = true ]; then
|
|||||||
else
|
else
|
||||||
echo "Mode: Online Installation"
|
echo "Mode: Online Installation"
|
||||||
fi
|
fi
|
||||||
if [ "$HAS_INTERNET" = true ]; then
|
|
||||||
echo "Internet: reachable"
|
|
||||||
else
|
|
||||||
echo "Internet: NOT available (local-network / isolated install)"
|
|
||||||
fi
|
|
||||||
if [ -n "$SUDO_USERNAME" ]; then
|
if [ -n "$SUDO_USERNAME" ]; then
|
||||||
echo "Sudo user: $SUDO_USERNAME"
|
echo "Sudo user: $SUDO_USERNAME"
|
||||||
fi
|
fi
|
||||||
@@ -293,54 +228,27 @@ echo "Step 1/6: Installing system dependencies..."
|
|||||||
echo "--------------------"
|
echo "--------------------"
|
||||||
|
|
||||||
echo "Updating package lists..."
|
echo "Updating package lists..."
|
||||||
if [ "$HAS_INTERNET" = true ]; then
|
run_sudo apt update
|
||||||
run_sudo apt update 2>/dev/null || echo " WARNING: 'apt update' failed; continuing with existing lists"
|
|
||||||
else
|
|
||||||
echo " Offline / no internet — skipping 'apt update'"
|
|
||||||
run_sudo apt update 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installing system packages..."
|
echo "Installing system packages..."
|
||||||
|
|
||||||
# System dependencies required for Kivy + video playback
|
if [ "$OFFLINE_MODE" = true ] && [ -d "$DEB_DIR" ] && [ "$(ls -A $DEB_DIR/*.deb 2>/dev/null)" ]; then
|
||||||
APT_PACKAGES="python3-pip python3-setuptools python3-dev python3-venv \
|
echo "Installing from offline .deb packages..."
|
||||||
libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev \
|
|
||||||
libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev \
|
|
||||||
zlib1g-dev ffmpeg libavcodec-extra \
|
|
||||||
gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
|
||||||
curl wget"
|
|
||||||
|
|
||||||
# Prefer the offline .deb bundle when present
|
|
||||||
USED_DEB_BUNDLE=false
|
|
||||||
if [ -d "$DEB_DIR" ] && ls "$DEB_DIR"/*.deb >/dev/null 2>&1; then
|
|
||||||
echo "Installing from offline .deb packages in $DEB_DIR..."
|
|
||||||
cd "$DEB_DIR"
|
cd "$DEB_DIR"
|
||||||
run_sudo dpkg -i *.deb 2>/dev/null || true
|
run_sudo dpkg -i *.deb 2>/dev/null || true
|
||||||
# Resolve dependencies from the local bundle only (no-op if already satisfied)
|
run_sudo apt-get install -f -y || true
|
||||||
run_sudo apt-get install -f -y 2>/dev/null || true
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
USED_DEB_BUNDLE=true
|
|
||||||
elif [ "$OFFLINE_MODE" = true ]; then
|
|
||||||
echo "WARNING: Offline mode but no .deb bundle found in $DEB_DIR"
|
|
||||||
echo " System packages will still be attempted via apt (may fail without internet)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install/complete system packages. Never abort the installer:
|
run_sudo apt install -y \
|
||||||
# offline, some packages may already be present (fine) or simply unavailable.
|
python3-pip python3-setuptools python3-dev \
|
||||||
if run_sudo apt install -y $APT_PACKAGES 2>/dev/null; then
|
python3-venv \
|
||||||
echo "System dependencies installed"
|
libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev \
|
||||||
else
|
libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev \
|
||||||
echo "WARNING: bulk system package install did not fully succeed."
|
zlib1g-dev ffmpeg libavcodec-extra \
|
||||||
echo " Installing available packages individually..."
|
gstreamer1.0-plugins-base gstreamer1.0-plugins-good
|
||||||
for pkg in $APT_PACKAGES; do
|
|
||||||
run_sudo apt install -y "$pkg" 2>/dev/null || echo " - unavailable (skipped): $pkg"
|
|
||||||
done
|
|
||||||
if [ "$HAS_INTERNET" != true ] && [ "$USED_DEB_BUNDLE" != true ]; then
|
|
||||||
echo " NOTE: Host is offline and no .deb bundle was found."
|
|
||||||
echo " Add .deb files to $DEB_DIR to satisfy missing system packages."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
echo "System dependencies installed"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -360,12 +268,8 @@ echo "Virtual environment created at $VENV_DIR"
|
|||||||
|
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
if [ "$OFFLINE_MODE" = true ] && [ "$HAS_INTERNET" != true ]; then
|
echo "Upgrading pip..."
|
||||||
echo "Skipping pip self-upgrade (offline, no internet)"
|
pip install --upgrade pip --quiet
|
||||||
else
|
|
||||||
echo "Upgrading pip..."
|
|
||||||
pip install --upgrade pip --quiet 2>/dev/null || echo " WARNING: pip upgrade failed; continuing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -389,13 +293,9 @@ if [ -f "$REQUIREMENTS_FILE" ]; then
|
|||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
echo "Offline installation failed (some wheels may be missing or incompatible)"
|
echo "Offline installation failed (some wheels may be missing or incompatible)"
|
||||||
if [ "$HAS_INTERNET" = true ]; then
|
|
||||||
echo " Falling back to online installation (requires internet)..."
|
echo " Falling back to online installation (requires internet)..."
|
||||||
|
echo ""
|
||||||
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
||||||
else
|
|
||||||
echo " No internet available — cannot fall back to online installation."
|
|
||||||
echo " Add the missing/compatible wheels to: $WHEELS_DIR"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
pip install -r "$REQUIREMENTS_FILE" --find-links="$WHEELS_DIR" 2>&1 || true
|
||||||
@@ -406,20 +306,16 @@ if [ -f "$REQUIREMENTS_FILE" ]; then
|
|||||||
echo "Verifying installed packages..."
|
echo "Verifying installed packages..."
|
||||||
pip list --format=columns 2>/dev/null | grep -iE "kivy|ffpyplayer" || true
|
pip list --format=columns 2>/dev/null | grep -iE "kivy|ffpyplayer" || true
|
||||||
|
|
||||||
# If a critical package is missing, fall back to online install only when reachable
|
# If kivy is still missing after pip install, try system-wide fallback
|
||||||
for pkg in kivy ffpyplayer; do
|
python3 -c "import kivy" 2>/dev/null && echo " kivy OK" || {
|
||||||
if python3 -c "import $pkg" 2>/dev/null; then
|
echo ""
|
||||||
echo " $pkg OK"
|
echo " kivy not found in venv — installing system-wide via pip (break-system-packages)..."
|
||||||
else
|
pip install kivy --break-system-packages 2>/dev/null || pip install --user kivy 2>/dev/null || true
|
||||||
echo " WARNING: $pkg could not be imported in the virtual environment"
|
}
|
||||||
if [ "$HAS_INTERNET" = true ]; then
|
python3 -c "import ffpyplayer" 2>/dev/null && echo " ffpyplayer OK" || {
|
||||||
echo " Attempting online fallback install for $pkg..."
|
echo " ffpyplayer not found — installing system-wide..."
|
||||||
pip install "$pkg" 2>/dev/null || echo " - $pkg online install failed (continuing)"
|
pip install ffpyplayer --break-system-packages 2>/dev/null || pip install --user ffpyplayer 2>/dev/null || true
|
||||||
else
|
}
|
||||||
echo " Offline: ensure a compatible '$pkg' wheel exists in $WHEELS_DIR"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
else
|
else
|
||||||
echo "requirements.txt not found - skipping pip install"
|
echo "requirements.txt not found - skipping pip install"
|
||||||
fi
|
fi
|
||||||
@@ -462,32 +358,24 @@ run_sudo chown "$ACTUAL_USER:$ACTUAL_USER" "$AUTOSTART_DIR/kivy-signage-player.d
|
|||||||
chmod 644 "$AUTOSTART_DIR/kivy-signage-player.desktop" 2>/dev/null || true
|
chmod 644 "$AUTOSTART_DIR/kivy-signage-player.desktop" 2>/dev/null || true
|
||||||
echo "XDG autostart entry created"
|
echo "XDG autostart entry created"
|
||||||
|
|
||||||
# Cron fallback (secondary to XDG autostart; start.sh enforces single-instance)
|
# Cron fallback
|
||||||
if command -v crontab &>/dev/null; then
|
if command -v crontab &>/dev/null; then
|
||||||
CRON_WRAPPER="$SCRIPT_DIR/.start-player-cron.sh"
|
CRON_WRAPPER="$SCRIPT_DIR/.start-player-cron.sh"
|
||||||
CRON_MARKER_TAG=".start-player-cron.sh"
|
|
||||||
cat > "$CRON_WRAPPER" << EOF
|
cat > "$CRON_WRAPPER" << EOF
|
||||||
#!/bin/bash
|
#\!/bin/bash
|
||||||
# Kiwy signage player cron fallback (managed by install.sh)
|
|
||||||
sleep 15
|
sleep 15
|
||||||
cd $SCRIPT_DIR && bash start.sh
|
cd $SCRIPT_DIR && bash start.sh
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$CRON_WRAPPER"
|
chmod +x "$CRON_WRAPPER"
|
||||||
|
|
||||||
# Idempotent: strip ALL previous Kiwy cron lines, then add exactly one.
|
CRON_ENTRY="@reboot sleep 20 && $CRON_WRAPPER > /tmp/kivy-player-cron.log 2>&1"
|
||||||
# (The old guard grepped for a string that never appeared in the entry,
|
# Use sudo -u which works when running with sudo (no password prompt)
|
||||||
# so every re-install appended another @reboot line.)
|
if sudo -u "$ACTUAL_USER" crontab -l 2>/dev/null | grep -q "kivy-signage-player"; then
|
||||||
CURRENT_CRON="$(sudo -u "$ACTUAL_USER" crontab -l 2>/dev/null || true)"
|
true
|
||||||
CLEANED_CRON="$(printf '%s\n' "$CURRENT_CRON" \
|
else
|
||||||
| grep -v -F "$CRON_MARKER_TAG" \
|
(sudo -u "$ACTUAL_USER" crontab -l 2>/dev/null || true; echo "$CRON_ENTRY") | sudo -u "$ACTUAL_USER" crontab - 2>/dev/null || true
|
||||||
| grep -v -F "$CRON_WRAPPER" \
|
fi
|
||||||
| grep -v -F "# Kiwy signage player" || true)"
|
echo "Cron fallback configured"
|
||||||
{
|
|
||||||
printf '%s\n' "$CLEANED_CRON" | grep -v '^$'
|
|
||||||
echo "# Kiwy signage player autostart fallback (managed by install.sh)"
|
|
||||||
echo "@reboot sleep 20 && $CRON_WRAPPER > /tmp/kivy-player-cron.log 2>&1"
|
|
||||||
} | sudo -u "$ACTUAL_USER" crontab - 2>/dev/null || true
|
|
||||||
echo "Cron fallback configured (single entry, deduplicated)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -554,7 +442,7 @@ write_player_config() {
|
|||||||
if [ -n "$PROVISION_URL" ]; then
|
if [ -n "$PROVISION_URL" ]; then
|
||||||
echo "Fetching provisioning data from: $PROVISION_URL"
|
echo "Fetching provisioning data from: $PROVISION_URL"
|
||||||
local response
|
local response
|
||||||
response=$(fetch_url "$PROVISION_URL" 2>/dev/null || true)
|
response=$(curl -sSf "$PROVISION_URL" 2>&1 || true)
|
||||||
if [ -n "$response" ]; then
|
if [ -n "$response" ]; then
|
||||||
echo "$response" > "$config_file"
|
echo "$response" > "$config_file"
|
||||||
echo "Provisioning data saved"
|
echo "Provisioning data saved"
|
||||||
@@ -604,9 +492,7 @@ edit_val = os.environ.get('EDIT_ENABLED', '')
|
|||||||
if https_val:
|
if https_val:
|
||||||
config['use_https'] = https_val.lower() == 'true'
|
config['use_https'] = https_val.lower() == 'true'
|
||||||
if verify_val:
|
if verify_val:
|
||||||
# VERIFY_SSL holds the requested verification state ("true"/"false").
|
config['verify_ssl'] = verify_val.lower() == 'false'
|
||||||
# (Previously this was inverted: '--no-verify-ssl' set verify_ssl=True.)
|
|
||||||
config['verify_ssl'] = verify_val.lower() != 'false'
|
|
||||||
if edit_val:
|
if edit_val:
|
||||||
config['edit_feature_enabled'] = edit_val.lower() == 'true'
|
config['edit_feature_enabled'] = edit_val.lower() == 'true'
|
||||||
|
|
||||||
@@ -614,16 +500,16 @@ config.setdefault('port', '8080')
|
|||||||
config.setdefault('orientation', 'Landscape')
|
config.setdefault('orientation', 'Landscape')
|
||||||
config.setdefault('touch', 'True')
|
config.setdefault('touch', 'True')
|
||||||
config.setdefault('max_resolution', '1920x1080')
|
config.setdefault('max_resolution', '1920x1080')
|
||||||
config.setdefault('edit_feature_enabled', True)
|
config.setdefault('edit_feature_enabled', False)
|
||||||
config.setdefault('use_https', False)
|
config.setdefault('use_https', False)
|
||||||
config.setdefault('verify_ssl', False)
|
config.setdefault('verify_ssl', True)
|
||||||
|
|
||||||
with open(config_file, 'w') as f:
|
with open(config_file, 'w') as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
print("Configuration written")
|
print("Configuration written")
|
||||||
PYEOF
|
PYEOF
|
||||||
elif [ ! -f "$config_file" ]; then
|
elif [ \! -f "$config_file" ]; then
|
||||||
cat > "$config_file" << 'EOF'
|
cat > "$config_file" << 'EOF'
|
||||||
{
|
{
|
||||||
"server_ip": "",
|
"server_ip": "",
|
||||||
@@ -633,9 +519,9 @@ PYEOF
|
|||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"touch": "True",
|
"touch": "True",
|
||||||
"max_resolution": "1920x1080",
|
"max_resolution": "1920x1080",
|
||||||
"edit_feature_enabled": true,
|
"edit_feature_enabled": false,
|
||||||
"use_https": false,
|
"use_https": false,
|
||||||
"verify_ssl": false
|
"verify_ssl": true
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
echo "Default config template created"
|
echo "Default config template created"
|
||||||
@@ -682,18 +568,18 @@ fi
|
|||||||
|
|
||||||
# Create background runner
|
# Create background runner
|
||||||
NOHUP_WRAPPER="$SCRIPT_DIR/.run-background.sh"
|
NOHUP_WRAPPER="$SCRIPT_DIR/.run-background.sh"
|
||||||
cat > "$NOHUP_WRAPPER" << 'EOF'
|
cat > "$NOHUP_WRAPPER" << EOF
|
||||||
#!/bin/bash
|
#\!/bin/bash
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
LOG_FILE="$SCRIPT_DIR/logs/player-background.log"
|
LOG_FILE="$SCRIPT_DIR/logs/player-background.log"
|
||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting player in background..." >> "$LOG_FILE"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting player in background..." >> "$LOG_FILE"
|
||||||
cd "$SCRIPT_DIR" || exit 1
|
cd "$SCRIPT_DIR" || exit 1
|
||||||
nohup bash start.sh >> "$LOG_FILE" 2>&1 &
|
nohup bash start.sh >> "$LOG_FILE" 2>&1 &
|
||||||
PLAYER_PID=$!
|
PLAYER_PID=\$\!
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Player started with PID: $PLAYER_PID" >> "$LOG_FILE"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Player started with PID: \$PLAYER_PID" >> "$LOG_FILE"
|
||||||
echo "Player started in background (PID: $PLAYER_PID)"
|
echo "Player started in background (PID: \$PLAYER_PID)"
|
||||||
echo "Logs: $LOG_FILE"
|
echo "Logs: \$LOG_FILE"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$NOHUP_WRAPPER"
|
chmod +x "$NOHUP_WRAPPER"
|
||||||
echo "Background runner created"
|
echo "Background runner created"
|
||||||
@@ -703,7 +589,7 @@ echo "Background runner created"
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Installation Complete!"
|
echo "Installation Complete\!"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Installation: $SCRIPT_DIR"
|
echo "Installation: $SCRIPT_DIR"
|
||||||
@@ -750,15 +636,7 @@ if [ -f "$PI_BOOT_PATCH_SCRIPT" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$NO_REBOOT" = true ]; then
|
if [ -z "$SUDO_PASSWORD" ]; then
|
||||||
echo "=========================================="
|
|
||||||
echo "Reboot Skipped (--no-reboot)"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Some changes (HDMI settings, GPU memory) may require a reboot."
|
|
||||||
echo "Reboot manually when convenient: sudo reboot"
|
|
||||||
echo ""
|
|
||||||
elif [ -z "$SUDO_PASSWORD" ]; then
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "System Reboot Recommended"
|
echo "System Reboot Recommended"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"count": 4,
|
||||||
|
"player_id": 1,
|
||||||
|
"player_name": "Test_player1",
|
||||||
|
"playlist": [
|
||||||
|
{
|
||||||
|
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||||
|
"type": "image",
|
||||||
|
"url": "media/sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||||
|
"duration": 14,
|
||||||
|
"edit_on_player": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_name": "weblink-1e4a4d6d885a",
|
||||||
|
"type": "weblink",
|
||||||
|
"url": "https://moto-adv.com/",
|
||||||
|
"duration": 30,
|
||||||
|
"edit_on_player": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_name": "sample-30s.mp4",
|
||||||
|
"type": "video",
|
||||||
|
"url": "media/sample-30s.mp4",
|
||||||
|
"duration": 31,
|
||||||
|
"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_version": 14
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Python 3.12.9
|
||||||
+6
-10
@@ -1,10 +1,6 @@
|
|||||||
# Patterns are relative to THIS file (the repo/ folder), so no "repo/" prefix.
|
# Ignore downloaded packages but keep directory structure
|
||||||
# (The previous patterns used a "repo/" prefix and therefore never matched.)
|
repo/python-wheels/*.whl
|
||||||
|
repo/python-wheels/*.tar.gz
|
||||||
# Downloaded system .deb bundles are large and optional.
|
repo/system-packages/debs/
|
||||||
# Fetch on demand with: bash scripts/download_deb_packages.sh
|
!repo/python-wheels/.gitkeep
|
||||||
# They are intentionally NOT committed.
|
!repo/system-packages/.gitkeep
|
||||||
system-packages/debs/
|
|
||||||
|
|
||||||
# NOTE: Python wheels under python-wheels/ ARE committed on purpose, so that a
|
|
||||||
# plain `git clone` ships a complete offline install bundle.
|
|
||||||
|
|||||||
+8
-10
@@ -13,23 +13,21 @@ The installer script (`install.sh`) will automatically use this repo folder if a
|
|||||||
|
|
||||||
### To populate this folder for offline installation:
|
### To populate this folder for offline installation:
|
||||||
|
|
||||||
Run the download scripts from the project root:
|
Run the download script from the project root:
|
||||||
```bash
|
```bash
|
||||||
bash scripts/download_offline_packages.sh
|
bash download_offline_packages.sh
|
||||||
bash scripts/download_deb_packages.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This will download all necessary Python wheels and system .deb packages.
|
This will download all necessary Python wheels and document system packages.
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
### Python Packages (see requirements.txt for the source of truth)
|
### Python Packages
|
||||||
- kivy==2.3.1
|
- kivy==2.1.0
|
||||||
- requests==2.34.2
|
- requests==2.32.4
|
||||||
- bcrypt==4.2.1
|
- bcrypt==4.2.1
|
||||||
- aiohttp==3.11.16
|
- aiohttp==3.9.1
|
||||||
- ffpyplayer==4.5.3
|
- asyncio==3.4.3
|
||||||
- evdev==1.9.1
|
|
||||||
- All dependencies
|
- All dependencies
|
||||||
|
|
||||||
### System Packages (APT)
|
### System Packages (APT)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,347 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# run_player-afterclone.sh
|
|
||||||
# ============================================================
|
|
||||||
# ONE-TIME post-clone setup for a cloned Raspberry Pi SD card.
|
|
||||||
#
|
|
||||||
# Problem it solves:
|
|
||||||
# The player identifies itself to DigiServer using the "screen_name"
|
|
||||||
# value in config/app_config.json (NOT the OS hostname). A cloned SD
|
|
||||||
# card carries the ORIGINAL card's screen_name + a cached auth token
|
|
||||||
# (src/player_auth.json). Two devices then fight over the same player
|
|
||||||
# session, so the cloned device never connects.
|
|
||||||
#
|
|
||||||
# What this script does:
|
|
||||||
# 1. Stops the running player/watchdog (so the token is not re-written)
|
|
||||||
# 2. Backs up the current identity/config files
|
|
||||||
# 3. Deletes the cached authentication token(s) -> forces re-auth
|
|
||||||
# 4. Sets a NEW unique screen_name and (optionally) server details
|
|
||||||
# 5. Leaves the player ready to boot and register as a BRAND-NEW player
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash run_player-afterclone.sh [OPTIONS]
|
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# -n, --name NAME New unique screen/player name
|
|
||||||
# -s, --server IP Server IP / hostname
|
|
||||||
# -p, --port PORT Server port
|
|
||||||
# -k, --key KEY Quick-connect / auth key
|
|
||||||
# --https Enable HTTPS
|
|
||||||
# --no-https Disable HTTPS
|
|
||||||
# --verify-ssl Enable SSL certificate verification
|
|
||||||
# --no-verify-ssl Disable SSL certificate verification
|
|
||||||
# --set-hostname Also set the OS hostname to the new screen name
|
|
||||||
# -y, --yes Non-interactive; use args/defaults, no prompts
|
|
||||||
# --force Run even if this card was already set up
|
|
||||||
# -h, --help Show this help
|
|
||||||
#
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
CONFIG_DIR="$SCRIPT_DIR/config"
|
|
||||||
CONFIG_FILE="$CONFIG_DIR/app_config.json"
|
|
||||||
VENV_DIR="$SCRIPT_DIR/.venv"
|
|
||||||
MARKER_FILE="$SCRIPT_DIR/.post-clone-setup.done"
|
|
||||||
BACKUP_ROOT="$SCRIPT_DIR/.post-clone-backups"
|
|
||||||
STAMP="$(date '+%Y%m%d-%H%M%S')"
|
|
||||||
|
|
||||||
# ---- Defaults / CLI state ----
|
|
||||||
NEW_NAME=""
|
|
||||||
NEW_SERVER=""
|
|
||||||
NEW_PORT=""
|
|
||||||
NEW_KEY=""
|
|
||||||
NEW_USE_HTTPS=""
|
|
||||||
NEW_VERIFY_SSL=""
|
|
||||||
SET_HOSTNAME=false
|
|
||||||
ASSUME_YES=false
|
|
||||||
FORCE=false
|
|
||||||
|
|
||||||
# ---- Pretty printing ----
|
|
||||||
info() { echo " $1"; }
|
|
||||||
ok() { echo " ✓ $1"; }
|
|
||||||
warn() { echo " ⚠️ $1"; }
|
|
||||||
err() { echo " ✗ $1"; }
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
run_player-afterclone.sh - one-time post-clone setup for a cloned SD card.
|
|
||||||
|
|
||||||
Deletes the cached authentication token and configures a NEW unique
|
|
||||||
player identity so the card registers on the server as a new player.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
bash run_player-afterclone.sh [OPTIONS]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-n, --name NAME New unique screen/player name
|
|
||||||
-s, --server IP Server IP / hostname
|
|
||||||
-p, --port PORT Server port
|
|
||||||
-k, --key KEY Quick-connect / auth key
|
|
||||||
--https Enable HTTPS
|
|
||||||
--no-https Disable HTTPS
|
|
||||||
--verify-ssl Enable SSL certificate verification
|
|
||||||
--no-verify-ssl Disable SSL certificate verification
|
|
||||||
--set-hostname Also set the OS hostname to the new screen name
|
|
||||||
-y, --yes Non-interactive; use args/defaults, no prompts
|
|
||||||
--force Run even if this card was already set up
|
|
||||||
-h, --help Show this help
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Pick a python interpreter (prefer the venv, fall back to system) ----
|
|
||||||
pick_python() {
|
|
||||||
if [ -x "$VENV_DIR/bin/python3" ]; then
|
|
||||||
echo "$VENV_DIR/bin/python3"
|
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
|
||||||
echo "python3"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Parse arguments ----
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-n|--name) NEW_NAME="$2"; shift 2 ;;
|
|
||||||
-s|--server) NEW_SERVER="$2"; shift 2 ;;
|
|
||||||
-p|--port) NEW_PORT="$2"; shift 2 ;;
|
|
||||||
-k|--key) NEW_KEY="$2"; shift 2 ;;
|
|
||||||
--https) NEW_USE_HTTPS="true"; shift ;;
|
|
||||||
--no-https) NEW_USE_HTTPS="false"; shift ;;
|
|
||||||
--verify-ssl) NEW_VERIFY_SSL="true"; shift ;;
|
|
||||||
--no-verify-ssl) NEW_VERIFY_SSL="false"; shift ;;
|
|
||||||
--set-hostname) SET_HOSTNAME=true; shift ;;
|
|
||||||
-y|--yes) ASSUME_YES=true; shift ;;
|
|
||||||
--force) FORCE=true; shift ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) err "Unknown option: $1"; echo ""; usage ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "Kiwy Signage - Post-Clone Setup"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ---- Safety: only run once per card (unless --force) ----
|
|
||||||
if [ -f "$MARKER_FILE" ] && [ "$FORCE" != true ]; then
|
|
||||||
warn "This card looks already set up (marker: $(basename "$MARKER_FILE"))."
|
|
||||||
warn "Re-run with --force if you really want to reset it again."
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PYTHON_BIN="$(pick_python)"
|
|
||||||
if [ -z "$PYTHON_BIN" ]; then
|
|
||||||
err "python3 not found. Run install.sh first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 1) Stop the running player + watchdog so nothing re-writes the token
|
|
||||||
# ============================================================
|
|
||||||
echo "▶ Stopping any running player/watchdog..."
|
|
||||||
pkill -f "bash.*start.sh" 2>/dev/null || true
|
|
||||||
pkill -f "python3 main.py" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
pkill -9 -f "bash.*start.sh" 2>/dev/null || true
|
|
||||||
pkill -9 -f "python3 main.py" 2>/dev/null || true
|
|
||||||
ok "Player stopped"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 2) Read current config (for defaults / display)
|
|
||||||
# ============================================================
|
|
||||||
CUR_NAME=""
|
|
||||||
CUR_SERVER=""
|
|
||||||
CUR_PORT=""
|
|
||||||
CUR_KEY=""
|
|
||||||
if [ -f "$CONFIG_FILE" ]; then
|
|
||||||
eval "$("$PYTHON_BIN" - "$CONFIG_FILE" <<'PYEOF'
|
|
||||||
import json, sys
|
|
||||||
try:
|
|
||||||
c = json.load(open(sys.argv[1]))
|
|
||||||
except Exception:
|
|
||||||
c = {}
|
|
||||||
def q(k):
|
|
||||||
v = c.get(k, "")
|
|
||||||
v = "" if v is None else str(v)
|
|
||||||
return v.replace("'", "'\\''")
|
|
||||||
print(f"CUR_NAME='{q('screen_name')}'")
|
|
||||||
print(f"CUR_SERVER='{q('server_ip')}'")
|
|
||||||
print(f"CUR_PORT='{q('port')}'")
|
|
||||||
print(f"CUR_KEY='{q('quickconnect_key')}'")
|
|
||||||
PYEOF
|
|
||||||
)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
OS_HOSTNAME="$(hostname 2>/dev/null || echo '')"
|
|
||||||
|
|
||||||
# Sensible defaults: prefer an explicit new name, else the (changed) OS hostname,
|
|
||||||
# else the previous screen name.
|
|
||||||
DEF_NAME="$NEW_NAME"
|
|
||||||
[ -z "$DEF_NAME" ] && DEF_NAME="$OS_HOSTNAME"
|
|
||||||
[ -z "$DEF_NAME" ] && DEF_NAME="$CUR_NAME"
|
|
||||||
[ -z "$DEF_NAME" ] && DEF_NAME="player-$(date +%s | tail -c 5)"
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 3) Prompt (unless -y / non-interactive) for the new identity
|
|
||||||
# ============================================================
|
|
||||||
prompt_value() {
|
|
||||||
# $1 = prompt text, $2 = default
|
|
||||||
local answer
|
|
||||||
if [ "$ASSUME_YES" = true ] || [ ! -t 0 ]; then
|
|
||||||
echo "$2"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
read -r -p " $1 [$2]: " answer
|
|
||||||
echo "${answer:-$2}"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "▶ New player identity"
|
|
||||||
info "Note: the server identifies this player by 'screen_name', not the OS hostname."
|
|
||||||
if [ -z "$NEW_NAME" ]; then
|
|
||||||
NEW_NAME="$(prompt_value "New unique screen/player name" "$DEF_NAME")"
|
|
||||||
fi
|
|
||||||
if [ -z "$NEW_SERVER" ]; then
|
|
||||||
NEW_SERVER="$(prompt_value "Server IP/hostname" "$CUR_SERVER")"
|
|
||||||
fi
|
|
||||||
if [ -z "$NEW_PORT" ]; then
|
|
||||||
NEW_PORT="$(prompt_value "Server port" "${CUR_PORT:-8080}")"
|
|
||||||
fi
|
|
||||||
if [ -z "$NEW_KEY" ]; then
|
|
||||||
NEW_KEY="$(prompt_value "Quick-connect key" "$CUR_KEY")"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 4) Back up current identity files
|
|
||||||
# ============================================================
|
|
||||||
BACKUP_DIR="$BACKUP_ROOT/$STAMP"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
echo "▶ Backing up current identity files to:"
|
|
||||||
info "$BACKUP_DIR"
|
|
||||||
[ -f "$CONFIG_FILE" ] && cp -p "$CONFIG_FILE" "$BACKUP_DIR/" 2>/dev/null || true
|
|
||||||
[ -f "$SCRIPT_DIR/src/player_auth.json" ] && cp -p "$SCRIPT_DIR/src/player_auth.json" "$BACKUP_DIR/" 2>/dev/null || true
|
|
||||||
ok "Backup complete"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 5) Delete cached authentication tokens (force re-authentication)
|
|
||||||
# ============================================================
|
|
||||||
echo "▶ Removing cached authentication token(s)..."
|
|
||||||
REMOVED=0
|
|
||||||
while IFS= read -r token; do
|
|
||||||
[ -z "$token" ] && continue
|
|
||||||
rm -f "$token" && REMOVED=$((REMOVED + 1)) && info "deleted: ${token#$SCRIPT_DIR/}"
|
|
||||||
done < <(find "$SCRIPT_DIR" -name 'player_auth.json' -not -path '*/.venv/*' -not -path "$BACKUP_ROOT/*" 2>/dev/null)
|
|
||||||
rm -f /tmp/temp_auth_test.json 2>/dev/null || true
|
|
||||||
rm -f "$SCRIPT_DIR/.player_heartbeat" 2>/dev/null || true
|
|
||||||
rm -f "$SCRIPT_DIR/.player_stop_requested" 2>/dev/null || true
|
|
||||||
if [ "$REMOVED" -eq 0 ]; then
|
|
||||||
ok "No cached token found (already clean)"
|
|
||||||
else
|
|
||||||
ok "Removed $REMOVED token file(s)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 6) Write the new config (screen_name + server details)
|
|
||||||
# ============================================================
|
|
||||||
echo "▶ Writing new player configuration..."
|
|
||||||
mkdir -p "$CONFIG_DIR"
|
|
||||||
CFG_PATH="$CONFIG_FILE" \
|
|
||||||
NEW_SCREEN_NAME="$NEW_NAME" \
|
|
||||||
NEW_SERVER_IP="$NEW_SERVER" \
|
|
||||||
NEW_PORT="$NEW_PORT" \
|
|
||||||
NEW_KEY="$NEW_KEY" \
|
|
||||||
NEW_USE_HTTPS="$NEW_USE_HTTPS" \
|
|
||||||
NEW_VERIFY_SSL="$NEW_VERIFY_SSL" \
|
|
||||||
"$PYTHON_BIN" <<'PYEOF'
|
|
||||||
import json, os
|
|
||||||
|
|
||||||
cfg_path = os.environ['CFG_PATH']
|
|
||||||
try:
|
|
||||||
with open(cfg_path) as f:
|
|
||||||
cfg = json.load(f)
|
|
||||||
except Exception:
|
|
||||||
cfg = {}
|
|
||||||
|
|
||||||
updates = {
|
|
||||||
'screen_name': os.environ.get('NEW_SCREEN_NAME', ''),
|
|
||||||
'server_ip': os.environ.get('NEW_SERVER_IP', ''),
|
|
||||||
'port': os.environ.get('NEW_PORT', ''),
|
|
||||||
'quickconnect_key': os.environ.get('NEW_KEY', ''),
|
|
||||||
}
|
|
||||||
for key, val in updates.items():
|
|
||||||
if val != '':
|
|
||||||
cfg[key] = val
|
|
||||||
|
|
||||||
if os.environ.get('NEW_USE_HTTPS', '') != '':
|
|
||||||
cfg['use_https'] = os.environ['NEW_USE_HTTPS'].lower() == 'true'
|
|
||||||
if os.environ.get('NEW_VERIFY_SSL', '') != '':
|
|
||||||
cfg['verify_ssl'] = os.environ['NEW_VERIFY_SSL'].lower() == 'true'
|
|
||||||
|
|
||||||
# Safe defaults for anything missing
|
|
||||||
cfg.setdefault('port', '8080')
|
|
||||||
cfg.setdefault('orientation', 'Landscape')
|
|
||||||
cfg.setdefault('touch', 'True')
|
|
||||||
cfg.setdefault('max_resolution', '1920x1080')
|
|
||||||
cfg.setdefault('edit_feature_enabled', True)
|
|
||||||
cfg.setdefault('use_https', False)
|
|
||||||
cfg.setdefault('verify_ssl', False)
|
|
||||||
|
|
||||||
with open(cfg_path, 'w') as f:
|
|
||||||
json.dump(cfg, f, indent=2)
|
|
||||||
f.write('\n')
|
|
||||||
|
|
||||||
print(json.dumps(cfg, indent=2))
|
|
||||||
PYEOF
|
|
||||||
ok "Configuration updated"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 7) Optionally align the OS hostname with the screen name
|
|
||||||
# ============================================================
|
|
||||||
if [ "$SET_HOSTNAME" = true ] && [ -n "$NEW_NAME" ]; then
|
|
||||||
SAFE_HOST="$(echo "$NEW_NAME" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/-\+$//')"
|
|
||||||
echo "▶ Setting OS hostname to '$SAFE_HOST' (player identity is still 'screen_name')..."
|
|
||||||
if command -v hostnamectl >/dev/null 2>&1; then
|
|
||||||
sudo hostnamectl set-hostname "$SAFE_HOST" 2>/dev/null \
|
|
||||||
&& ok "OS hostname set to $SAFE_HOST" \
|
|
||||||
|| warn "Could not set OS hostname (needs sudo)"
|
|
||||||
else
|
|
||||||
echo "$SAFE_HOST" | sudo tee /etc/hostname >/dev/null 2>&1 \
|
|
||||||
&& ok "OS hostname file updated" \
|
|
||||||
|| warn "Could not write /etc/hostname (needs sudo)"
|
|
||||||
fi
|
|
||||||
warn "A reboot is recommended for the hostname change to fully apply."
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 8) Mark the card as set up + summary
|
|
||||||
# ============================================================
|
|
||||||
{
|
|
||||||
echo "Post-clone setup completed: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo "screen_name: $NEW_NAME"
|
|
||||||
echo "server_ip: $NEW_SERVER"
|
|
||||||
echo "port: $NEW_PORT"
|
|
||||||
echo "backup: $BACKUP_DIR"
|
|
||||||
} > "$MARKER_FILE"
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo "✓ Card is ready to connect to the server"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo " Player name : $NEW_NAME"
|
|
||||||
echo " Server : $NEW_SERVER:$NEW_PORT"
|
|
||||||
echo " Backup : $BACKUP_DIR"
|
|
||||||
echo ""
|
|
||||||
echo " Next steps:"
|
|
||||||
echo " 1. (Optional) reboot: sudo reboot"
|
|
||||||
echo " 2. Start the player: ./start.sh"
|
|
||||||
echo ""
|
|
||||||
echo " The player will authenticate as a NEW player '$NEW_NAME'"
|
|
||||||
echo " and fetch its own playlist from the server."
|
|
||||||
echo ""
|
|
||||||
+3
-14
@@ -1,16 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Run Kivy Signage Player once (no watchdog / auto-restart)
|
# Start Kivy Signage Player
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
cd "$(dirname "$0")/src"
|
||||||
|
python3 main.py
|
||||||
# Activate the virtual environment created by install.sh (holds Kivy + deps).
|
|
||||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
|
||||||
else
|
|
||||||
echo "WARNING: virtual environment not found at $SCRIPT_DIR/.venv"
|
|
||||||
echo " Run 'bash install.sh' first for a fully offline-capable setup."
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$SCRIPT_DIR/src"
|
|
||||||
exec python3 main.py
|
|
||||||
Binary file not shown.
+15
-1
@@ -354,7 +354,9 @@ def delete_unused_media(playlist_data, media_dir):
|
|||||||
rel_path = os.path.relpath(full_path, media_dir)
|
rel_path = os.path.relpath(full_path, media_dir)
|
||||||
|
|
||||||
# Skip if file is in current playlist
|
# Skip if file is in current playlist
|
||||||
if rel_path in referenced_files:
|
# Normalize paths to handle Windows backslashes vs server forward slashes
|
||||||
|
normalized_rel = rel_path.replace('\\', '/')
|
||||||
|
if normalized_rel in referenced_files or rel_path in referenced_files:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Delete unreferenced file
|
# Delete unreferenced file
|
||||||
@@ -454,6 +456,18 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
|
|||||||
return playlist_file
|
return playlist_file
|
||||||
else:
|
else:
|
||||||
logger.info("✓ Playlist is up to date")
|
logger.info("✓ Playlist is up to date")
|
||||||
|
# Even when the playlist version matches, ensure media files exist locally.
|
||||||
|
# The media folder might be empty (e.g. fresh install or deleted files).
|
||||||
|
logger.info("📥 Checking for missing media files...")
|
||||||
|
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||||
|
server_url = auth.auth_data.get('server_url', '')
|
||||||
|
downloaded = download_media_files(
|
||||||
|
server_data.get('playlist', []), media_dir, ssl_manager, server_url
|
||||||
|
)
|
||||||
|
if downloaded:
|
||||||
|
server_data['playlist'] = downloaded
|
||||||
|
# Re-save playlist with updated URLs if needed
|
||||||
|
save_playlist(server_data, playlist_dir)
|
||||||
return playlist_file
|
return playlist_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+446
-60
@@ -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
|
||||||
|
|
||||||
@@ -60,25 +65,6 @@ from kivy.core.window import Window
|
|||||||
from kivy.properties import BooleanProperty
|
from kivy.properties import BooleanProperty
|
||||||
from kivy.logger import Logger
|
from kivy.logger import Logger
|
||||||
|
|
||||||
# Kivy's Video widget subclasses Image, so its inherited image loader always
|
|
||||||
# runs first and logs a harmless "Image: Error loading <file.mp4>" for every
|
|
||||||
# video (the real decoding is done by the ffpyplayer CoreVideo provider).
|
|
||||||
# Kivy routes every module through the single 'kivy' logger (the "[Image]" tag
|
|
||||||
# is just Kivy's message-prefix formatting), so drop only that one message and
|
|
||||||
# leave all other Kivy log records visible.
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
class _SuppressKivyImageNoise(logging.Filter):
|
|
||||||
def filter(self, record):
|
|
||||||
try:
|
|
||||||
return not record.getMessage().startswith('Image: Error loading')
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
logging.getLogger('kivy').addFilter(_SuppressKivyImageNoise())
|
|
||||||
|
|
||||||
# Try to import evdev for card reader support (after Logger is available)
|
# Try to import evdev for card reader support (after Logger is available)
|
||||||
try:
|
try:
|
||||||
import evdev
|
import evdev
|
||||||
@@ -104,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:
|
||||||
@@ -596,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"""
|
||||||
@@ -628,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:
|
||||||
@@ -674,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)
|
||||||
|
|
||||||
@@ -718,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"""
|
||||||
@@ -948,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
|
||||||
@@ -973,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
|
||||||
@@ -989,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:
|
||||||
@@ -1062,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()
|
||||||
|
|
||||||
@@ -1092,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")
|
||||||
@@ -1314,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
|
||||||
@@ -1323,8 +1452,20 @@ class SignagePlayer(Widget):
|
|||||||
media_item = self.playlist[self.current_index]
|
media_item = self.playlist[self.current_index]
|
||||||
file_name = media_item.get('file_name', '')
|
file_name = media_item.get('file_name', '')
|
||||||
duration = media_item.get('duration', 10)
|
duration = media_item.get('duration', 10)
|
||||||
|
# Track start time + duration so a popup can re-arm the advance
|
||||||
|
# timer with the correct remaining time after dismissal.
|
||||||
|
self._media_started_at = time.monotonic()
|
||||||
|
self._media_duration = duration
|
||||||
|
|
||||||
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
|
Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)")
|
||||||
|
trace("play_current_media",
|
||||||
|
index=self.current_index + 1,
|
||||||
|
total=len(self.playlist),
|
||||||
|
name=file_name,
|
||||||
|
type=media_item.get('type', '?'),
|
||||||
|
duration=duration,
|
||||||
|
after_weblink=_after_weblink,
|
||||||
|
weblink_proc=bool(getattr(self, '_weblink_proc', None)))
|
||||||
|
|
||||||
# ── Weblink → media transition (desktop-flash safe) ──────────────
|
# ── Weblink → media transition (desktop-flash safe) ──────────────
|
||||||
# For web→media transitions, render the next Kivy widget first,
|
# For web→media transitions, render the next Kivy widget first,
|
||||||
@@ -1384,6 +1525,7 @@ class SignagePlayer(Widget):
|
|||||||
# Handle web links before any file/path handling (no local file exists)
|
# Handle web links before any file/path handling (no local file exists)
|
||||||
if media_item.get('type') == 'weblink':
|
if media_item.get('type') == 'weblink':
|
||||||
Logger.debug("SignagePlayer: Media type: WEBLINK")
|
Logger.debug("SignagePlayer: Media type: WEBLINK")
|
||||||
|
trace("enter_weblink_branch", url=media_item.get('url', '')[:80])
|
||||||
self.ids.status_label.opacity = 0
|
self.ids.status_label.opacity = 0
|
||||||
self._remove_current_widget()
|
self._remove_current_widget()
|
||||||
# Hide content_area — Chromium will cover it; avoids stale frame
|
# Hide content_area — Chromium will cover it; avoids stale frame
|
||||||
@@ -1392,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:
|
||||||
@@ -1431,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}")
|
||||||
@@ -1462,10 +1607,12 @@ class SignagePlayer(Widget):
|
|||||||
# If we arrived here from a weblink item, close Chromium after the
|
# If we arrived here from a weblink item, close Chromium after the
|
||||||
# next Kivy frame so the new widget is already visible underneath.
|
# next Kivy frame so the new widget is already visible underneath.
|
||||||
if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None:
|
if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None:
|
||||||
|
trace("closing_weblink_after_frame", name=file_name)
|
||||||
self._kill_weblink_after_frame()
|
self._kill_weblink_after_frame()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.error(f"SignagePlayer: Error playing media: {e}")
|
Logger.error(f"SignagePlayer: Error playing media: {e}")
|
||||||
|
trace("play_current_media_EXCEPTION", error=str(e))
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
|
|
||||||
# Check if we've exceeded max errors
|
# Check if we've exceeded max errors
|
||||||
@@ -1492,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
|
||||||
@@ -1517,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)
|
||||||
@@ -1530,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"""
|
||||||
@@ -1582,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}")
|
||||||
|
try:
|
||||||
self.ids.content_area.remove_widget(self.current_widget)
|
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.
|
||||||
@@ -1895,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
|
||||||
|
|
||||||
@@ -2083,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)
|
||||||
@@ -2303,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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+84
-15
@@ -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,6 +146,72 @@ class NetworkMonitor:
|
|||||||
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
Logger.info("NetworkMonitor: INITIATING WIFI RESTART SEQUENCE")
|
||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
|
|
||||||
|
if IS_WINDOWS:
|
||||||
|
self._restart_wifi_windows()
|
||||||
|
else:
|
||||||
|
self._restart_wifi_linux()
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
||||||
|
|
||||||
|
# Run in separate thread to not block the application
|
||||||
|
import threading
|
||||||
|
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
||||||
|
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)
|
# Turn off WiFi using rfkill (more reliable on Raspberry Pi)
|
||||||
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
Logger.info("NetworkMonitor: Turning WiFi OFF using rfkill...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -143,7 +223,6 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
Logger.info("NetworkMonitor: ✓ WiFi turned OFF successfully (rfkill)")
|
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...")
|
Logger.error(f"NetworkMonitor: rfkill failed, trying ifconfig...")
|
||||||
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
Logger.error(f"NetworkMonitor: rfkill error: {result.stderr}")
|
||||||
@@ -223,13 +302,3 @@ class NetworkMonitor:
|
|||||||
Logger.info("NetworkMonitor: ====================================")
|
Logger.info("NetworkMonitor: ====================================")
|
||||||
else:
|
else:
|
||||||
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
Logger.error(f"NetworkMonitor: Failed to turn WiFi on: {result.stderr}")
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
Logger.error("NetworkMonitor: WiFi restart command timeout")
|
|
||||||
except Exception as e:
|
|
||||||
Logger.error(f"NetworkMonitor: Error during WiFi restart: {e}")
|
|
||||||
|
|
||||||
# Run in separate thread to not block the application
|
|
||||||
import threading
|
|
||||||
thread = threading.Thread(target=wifi_restart_thread, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"hostname": "WINDOWS-PC",
|
||||||
|
"auth_code": "",
|
||||||
|
"player_id": 2,
|
||||||
|
"player_name": "Windows-Player1",
|
||||||
|
"playlist_id": 1,
|
||||||
|
"orientation": "Landscape",
|
||||||
|
"authenticated": true,
|
||||||
|
"server_url": "http://192.168.0.107:8080"
|
||||||
|
}
|
||||||
@@ -121,9 +121,6 @@ class PlayerAuth:
|
|||||||
self.auth_data = {
|
self.auth_data = {
|
||||||
'hostname': hostname,
|
'hostname': hostname,
|
||||||
'auth_code': data.get('auth_code'),
|
'auth_code': data.get('auth_code'),
|
||||||
# Persist the quick-connect code: the feedback endpoint
|
|
||||||
# requires it in the request body (see send_feedback).
|
|
||||||
'quickconnect_code': quickconnect_code,
|
|
||||||
'player_id': data.get('player_id'),
|
'player_id': data.get('player_id'),
|
||||||
'player_name': data.get('player_name'),
|
'player_name': data.get('player_name'),
|
||||||
'playlist_id': data.get('playlist_id'),
|
'playlist_id': data.get('playlist_id'),
|
||||||
@@ -357,7 +354,6 @@ class PlayerAuth:
|
|||||||
self.auth_data = {
|
self.auth_data = {
|
||||||
'hostname': self.auth_data.get('hostname', ''),
|
'hostname': self.auth_data.get('hostname', ''),
|
||||||
'auth_code': '',
|
'auth_code': '',
|
||||||
'quickconnect_code': self.auth_data.get('quickconnect_code', ''),
|
|
||||||
'player_id': None,
|
'player_id': None,
|
||||||
'player_name': '',
|
'player_name': '',
|
||||||
'playlist_id': None,
|
'playlist_id': None,
|
||||||
|
|||||||
+75
-51
@@ -352,20 +352,27 @@
|
|||||||
# 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)
|
||||||
|
|
||||||
|
ScrollView:
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'vertical'
|
||||||
|
spacing: dp(8)
|
||||||
|
size_hint_y: None
|
||||||
|
height: self.minimum_height
|
||||||
|
|
||||||
# Server configuration
|
# Server configuration
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Server IP:'
|
text: 'Server IP:'
|
||||||
@@ -378,7 +385,7 @@
|
|||||||
id: server_input
|
id: server_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -386,8 +393,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Port:'
|
text: 'Port:'
|
||||||
@@ -400,7 +407,7 @@
|
|||||||
id: port_input
|
id: port_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
hint_text: '80 or 8080 (leave empty for default)'
|
hint_text: '80 or 8080 (leave empty for default)'
|
||||||
input_filter: 'int'
|
input_filter: 'int'
|
||||||
write_tab: False
|
write_tab: False
|
||||||
@@ -410,8 +417,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Screen Name:'
|
text: 'Screen Name:'
|
||||||
@@ -424,7 +431,7 @@
|
|||||||
id: screen_input
|
id: screen_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -432,8 +439,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Quickconnect:'
|
text: 'Quickconnect:'
|
||||||
@@ -446,7 +453,7 @@
|
|||||||
id: quickconnect_input
|
id: quickconnect_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -454,8 +461,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Orientation:'
|
text: 'Orientation:'
|
||||||
@@ -468,7 +475,7 @@
|
|||||||
id: orientation_input
|
id: orientation_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -476,8 +483,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Touch:'
|
text: 'Touch:'
|
||||||
@@ -490,7 +497,7 @@
|
|||||||
id: touch_input
|
id: touch_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
@@ -498,8 +505,8 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Max Resolution:'
|
text: 'Max Resolution:'
|
||||||
@@ -512,7 +519,7 @@
|
|||||||
id: resolution_input
|
id: resolution_input
|
||||||
size_hint_x: 0.7
|
size_hint_x: 0.7
|
||||||
multiline: False
|
multiline: False
|
||||||
font_size: sp(14)
|
font_size: sp(13)
|
||||||
hint_text: '1920x1080 or auto'
|
hint_text: '1920x1080 or auto'
|
||||||
write_tab: False
|
write_tab: False
|
||||||
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
@@ -521,11 +528,11 @@
|
|||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(36)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: 'Enable Edit Feature:'
|
text: 'Enable Edit:'
|
||||||
size_hint_x: 0.3
|
size_hint_x: 0.3
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'left'
|
halign: 'left'
|
||||||
@@ -534,87 +541,107 @@
|
|||||||
CheckBox:
|
CheckBox:
|
||||||
id: edit_enabled_checkbox
|
id: edit_enabled_checkbox
|
||||||
size_hint_x: None
|
size_hint_x: None
|
||||||
width: dp(40)
|
width: dp(36)
|
||||||
active: True
|
active: True
|
||||||
on_active: root.on_edit_feature_toggle(self.active)
|
on_active: root.on_edit_feature_toggle(self.active)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
text: '(Allow editing images on this player)'
|
text: '(Allow editing images)'
|
||||||
size_hint_x: 0.4
|
size_hint_x: 0.4
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'left'
|
halign: 'left'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
color: 0.7, 0.7, 0.7, 1
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
Widget:
|
Widget:
|
||||||
size_hint_y: 0.05
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
# Reset Buttons Section
|
# Reset Buttons Section
|
||||||
Label:
|
Label:
|
||||||
text: 'Reset Options:'
|
text: 'Reset Options:'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(30)
|
height: dp(26)
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'left'
|
halign: 'left'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
bold: True
|
bold: True
|
||||||
font_size: sp(16)
|
font_size: sp(14)
|
||||||
|
|
||||||
# Reset Buttons Row
|
# Reset Buttons Row
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(50)
|
height: dp(44)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: reset_auth_btn
|
id: reset_auth_btn
|
||||||
text: 'Reset Player Auth'
|
text: 'Reset Player Auth'
|
||||||
background_color: 0.8, 0.4, 0.2, 1
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
on_press: root.reset_player_auth()
|
on_press: root.reset_player_auth()
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: reset_playlist_btn
|
id: reset_playlist_btn
|
||||||
text: 'Reset Playlist to v0'
|
text: 'Reset Playlist to v0'
|
||||||
background_color: 0.8, 0.4, 0.2, 1
|
background_color: 0.8, 0.4, 0.2, 1
|
||||||
|
font_size: sp(12)
|
||||||
on_press: root.reset_playlist_version()
|
on_press: root.reset_playlist_version()
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: restart_player_btn
|
id: restart_player_btn
|
||||||
text: 'Restart Player'
|
text: 'Restart Player'
|
||||||
background_color: 0.2, 0.6, 0.8, 1
|
background_color: 0.2, 0.6, 0.8, 1
|
||||||
|
font_size: sp(12)
|
||||||
on_press: root.restart_player()
|
on_press: root.restart_player()
|
||||||
|
|
||||||
# Test Connection Button
|
# Test Connection + Production Mode Buttons
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(44)
|
||||||
|
spacing: dp(8)
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
id: test_connection_btn
|
id: test_connection_btn
|
||||||
text: 'Test Server Connection'
|
text: 'Test Server Connection'
|
||||||
size_hint_y: None
|
|
||||||
height: dp(50)
|
|
||||||
background_color: 0.2, 0.4, 0.8, 1
|
background_color: 0.2, 0.4, 0.8, 1
|
||||||
|
font_size: sp(13)
|
||||||
on_press: root.test_connection()
|
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
|
# Connection Status Label
|
||||||
Label:
|
Label:
|
||||||
id: connection_status
|
id: connection_status
|
||||||
text: 'Click button to test connection'
|
text: 'Click button to test connection'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(40)
|
height: dp(32)
|
||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
|
font_size: sp(11)
|
||||||
color: 0.7, 0.7, 0.7, 1
|
color: 0.7, 0.7, 0.7, 1
|
||||||
|
|
||||||
|
# Separator
|
||||||
Widget:
|
Widget:
|
||||||
size_hint_y: 0.05
|
size_hint_y: None
|
||||||
|
height: dp(5)
|
||||||
|
|
||||||
# Status information row
|
# Status information row
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(30)
|
height: dp(26)
|
||||||
spacing: dp(10)
|
spacing: dp(8)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
id: playlist_info
|
id: playlist_info
|
||||||
@@ -622,7 +649,7 @@
|
|||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
id: media_count_info
|
id: media_count_info
|
||||||
@@ -630,7 +657,7 @@
|
|||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
id: status_info
|
id: status_info
|
||||||
@@ -638,17 +665,14 @@
|
|||||||
text_size: self.size
|
text_size: self.size
|
||||||
halign: 'center'
|
halign: 'center'
|
||||||
valign: 'middle'
|
valign: 'middle'
|
||||||
font_size: sp(12)
|
font_size: sp(11)
|
||||||
|
|
||||||
Widget:
|
# Action buttons (always visible, outside scroll)
|
||||||
size_hint_y: 0.05
|
|
||||||
|
|
||||||
# Action buttons
|
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(50)
|
height: dp(44)
|
||||||
spacing: dp(20)
|
spacing: dp(15)
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
text: 'Save & Close'
|
text: 'Save & Close'
|
||||||
|
|||||||
@@ -59,13 +59,6 @@ load_user_environment() {
|
|||||||
# Fallback: detect display manually
|
# Fallback: detect display manually
|
||||||
log_message "Falling back to manual display detection..."
|
log_message "Falling back to manual display detection..."
|
||||||
|
|
||||||
# Ensure XDG_RUNTIME_DIR is set. Wayland clients (and Kivy) need it, and a
|
|
||||||
# cron-launched instance may not have it. Default to the standard location.
|
|
||||||
if [ -z "$XDG_RUNTIME_DIR" ]; then
|
|
||||||
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
|
|
||||||
log_message "Set XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then
|
if [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ]; then
|
||||||
# Try to detect Wayland display
|
# Try to detect Wayland display
|
||||||
if [ -S "/run/user/$(id -u)/wayland-0" ]; then
|
if [ -S "/run/user/$(id -u)/wayland-0" ]; then
|
||||||
@@ -103,27 +96,8 @@ load_user_environment() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load the user environment early. If there is no usable display, exit WITHOUT
|
# Load the user environment early
|
||||||
# taking the lock — otherwise a headless instance (e.g. cron before login) would
|
load_user_environment
|
||||||
# block the real desktop-autostart instance from ever running.
|
|
||||||
if ! load_user_environment; then
|
|
||||||
log_message "No display server available — exiting so another instance can start"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Single-instance guard (prevents parallel watchdogs)
|
|
||||||
# ============================================================
|
|
||||||
# Only ONE watchdog may run at a time. If XDG autostart, the cron fallback, and
|
|
||||||
# a manual start all fire, the first one to reach this point wins; every other
|
|
||||||
# instance exits immediately instead of spawning a second player.
|
|
||||||
LOCK_FILE="$SCRIPT_DIR/.player_watchdog.lock"
|
|
||||||
exec 200>"$LOCK_FILE"
|
|
||||||
if ! flock -n 200; then
|
|
||||||
log_message "Another watchdog is already running (lock: $LOCK_FILE) — exiting"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
log_message "Single-instance lock acquired: $LOCK_FILE"
|
|
||||||
|
|
||||||
# Function to configure display output to Full HD (1920x1080)
|
# Function to configure display output to Full HD (1920x1080)
|
||||||
configure_display_resolution() {
|
configure_display_resolution() {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pkill -9 -f "python3 main.py" 2>/dev/null
|
|||||||
# Clean up heartbeat and stop flag files
|
# Clean up heartbeat and stop flag files
|
||||||
rm -f "$SCRIPT_DIR/.player_heartbeat"
|
rm -f "$SCRIPT_DIR/.player_heartbeat"
|
||||||
rm -f "$SCRIPT_DIR/.player_stop_requested"
|
rm -f "$SCRIPT_DIR/.player_stop_requested"
|
||||||
rm -f "$SCRIPT_DIR/.player_watchdog.lock"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✓ Player and watchdog stopped"
|
echo "✓ Player and watchdog stopped"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
Requirement already satisfied: ffpyplayer in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (4.5.3)
|
||||||
|
Requirement already satisfied: requests in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.34.2)
|
||||||
|
Requirement already satisfied: aiohttp in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (3.14.3)
|
||||||
|
Requirement already satisfied: bcrypt in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (5.0.0)
|
||||||
|
Requirement already satisfied: certifi in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2026.7.22)
|
||||||
|
Requirement already satisfied: pyinstaller in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (6.21.0)
|
||||||
|
Requirement already satisfied: kivy[base] in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (2.3.1)
|
||||||
|
Requirement already satisfied: Kivy-Garden>=0.1.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.1.5)
|
||||||
|
Requirement already satisfied: docutils in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.23)
|
||||||
|
Requirement already satisfied: pygments in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (2.20.0)
|
||||||
|
Requirement already satisfied: filetype in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (1.2.0)
|
||||||
|
Requirement already satisfied: kivy-deps.angle~=0.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.4.0)
|
||||||
|
Requirement already satisfied: kivy-deps.sdl2~=0.8.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.8.0)
|
||||||
|
Requirement already satisfied: kivy-deps.glew~=0.3.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (0.3.1)
|
||||||
|
Requirement already satisfied: pypiwin32 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (223)
|
||||||
|
Requirement already satisfied: pillow<11,>=9.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from kivy[base]) (10.4.0)
|
||||||
|
Requirement already satisfied: charset_normalizer<4,>=2 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.4.9)
|
||||||
|
Requirement already satisfied: idna<4,>=2.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (3.18)
|
||||||
|
Requirement already satisfied: urllib3<3,>=1.26 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from requests) (2.7.0)
|
||||||
|
Requirement already satisfied: aiohappyeyeballs>=2.5.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (2.7.1)
|
||||||
|
Requirement already satisfied: aiosignal>=1.4.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.4.0)
|
||||||
|
Requirement already satisfied: attrs>=17.3.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (26.1.0)
|
||||||
|
Requirement already satisfied: frozenlist>=1.1.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.8.0)
|
||||||
|
Requirement already satisfied: multidict<7.0,>=4.5 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (6.7.1)
|
||||||
|
Requirement already satisfied: propcache>=0.2.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (0.5.2)
|
||||||
|
Requirement already satisfied: typing_extensions>=4.4 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (4.16.0)
|
||||||
|
Requirement already satisfied: yarl<2.0,>=1.17.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from aiohttp) (1.24.5)
|
||||||
|
Requirement already satisfied: altgraph in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.17.5)
|
||||||
|
Requirement already satisfied: packaging>=22.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (26.2)
|
||||||
|
Requirement already satisfied: pefile>=2022.5.30 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2024.8.26)
|
||||||
|
Requirement already satisfied: pyinstaller-hooks-contrib>=2026.6 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (2026.6)
|
||||||
|
Requirement already satisfied: pywin32-ctypes>=0.2.1 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (0.2.3)
|
||||||
|
Requirement already satisfied: setuptools>=42.0.0 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pyinstaller) (83.0.0)
|
||||||
|
Requirement already satisfied: pywin32>=223 in c:\users\dell-pc\desktop\kiwy-signage\windows\venv\lib\site-packages (from pypiwin32->kivy[base]) (312)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Kiwy Signage Player - Windows Edition
|
||||||
|
|
||||||
|
Build and run the Kiwy digital signage player on Windows as a standalone `.exe`.
|
||||||
|
|
||||||
|
## 📋 Requirements Analysis
|
||||||
|
|
||||||
|
The original app was built for **Raspberry Pi (Linux)**, using these technologies:
|
||||||
|
|
||||||
|
| Component | Original (RPi/Linux) | Windows Equivalent |
|
||||||
|
|-----------|---------------------|-------------------|
|
||||||
|
| **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) |
|
||||||
|
| **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) |
|
||||||
|
| **Card Reader** | evdev (Linux input) | **Not available** — gracefully disabled |
|
||||||
|
| **Screen Keep-Awake** | xset, xdotool, Wayland | `SetThreadExecutionState` (Win32 API) |
|
||||||
|
| **Weblink** | chromium-browser (kiosk) | Chrome/Edge (--kiosk mode) |
|
||||||
|
| **Audio** | ALSA/PulseAudio | DirectSound |
|
||||||
|
| **Window Backend** | SDL2 (Wayland/X11) | SDL2 (Windows native) |
|
||||||
|
| **OpenGL** | Desktop GL | ANGLE (DirectX wrapper) |
|
||||||
|
|
||||||
|
### What works on Windows
|
||||||
|
- ✅ Media playback (images, videos via ffpyplayer)
|
||||||
|
- ✅ Playlist sync from DigiServer (HTTP/HTTPS)
|
||||||
|
- ✅ Touch & mouse controls
|
||||||
|
- ✅ Settings popup
|
||||||
|
- ✅ Image editing/annotation
|
||||||
|
- ✅ Password-protected exit
|
||||||
|
- ✅ Web links (opens in Chrome/Edge kiosk)
|
||||||
|
- ✅ Network monitoring
|
||||||
|
- ✅ Auto-update playlist
|
||||||
|
|
||||||
|
### What is disabled on Windows
|
||||||
|
- ❌ Card reader (evdev is Linux-only; `EVDEV_AVAILABLE = False`)
|
||||||
|
- ❌ HDMI power management (tvservice is RPi-specific)
|
||||||
|
- ❌ WiFi restart (uses Linux `nmcli`)
|
||||||
|
|
||||||
|
## 🚀 Quick Start (Development)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
1. **Python 3.12+** (64-bit) — [python.org](https://python.org)
|
||||||
|
- ⚠️ **Python 3.13+ is NOT supported** — Kivy 2.3.1 does not have pre-built wheels for it
|
||||||
|
- ⚠️ **Python 3.14 is NOT supported** — no Kivy wheels available
|
||||||
|
- ✅ **Python 3.12.9** is the recommended version (confirmed working)
|
||||||
|
2. **FFmpeg** — for video codec support
|
||||||
|
- Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||||
|
- Add `bin\` folder to your PATH
|
||||||
|
3. **Visual C++ Redistributable** — [latest](https://aka.ms/vs/17/release/vc_redist.x64.exe)
|
||||||
|
|
||||||
|
### Install & Run
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
|
||||||
|
REM Create virtual environment with Python 3.12
|
||||||
|
py -3.12 -m venv venv
|
||||||
|
:: OR specify full path:
|
||||||
|
:: "C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe" -m venv venv
|
||||||
|
|
||||||
|
venv\Scripts\activate
|
||||||
|
|
||||||
|
REM Install dependencies
|
||||||
|
pip install -r requirements_win.txt
|
||||||
|
|
||||||
|
REM Run in development mode
|
||||||
|
python run_win.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Building the .exe
|
||||||
|
|
||||||
|
### One-Command Build
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
build_win.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Build
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r requirements_win.txt
|
||||||
|
pyinstaller build.spec --clean --noconfirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output
|
||||||
|
```
|
||||||
|
windows\dist\KiwySignagePlayer\
|
||||||
|
├── KiwySignagePlayer.exe # Main executable
|
||||||
|
├── config/ # Config files (auto-copied)
|
||||||
|
├── resources/ # Icons, intro video
|
||||||
|
└── ... (supporting DLLs)
|
||||||
|
```
|
||||||
|
|
||||||
|
For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` section and comment out the `coll = COLLECT(...)` section.
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
1. On first run, config files are created in the **same folder as the executable** (not in `%APPDATA%`)
|
||||||
|
- The .exe creates: `config/`, `media/`, `playlists/`, `logs/` directories locally
|
||||||
|
- This allows you to copy the entire `dist\KiwySignagePlayer\` folder anywhere and it works
|
||||||
|
2. Edit `config\app_config.json` (next to the .exe) to set your server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server_ip": "192.168.0.109",
|
||||||
|
"port": "8080",
|
||||||
|
"screen_name": "Birou_IT",
|
||||||
|
"quickconnect_key": "8887779",
|
||||||
|
"orientation": "Landscape",
|
||||||
|
"touch": "True",
|
||||||
|
"max_resolution": "1920x1080",
|
||||||
|
"edit_feature_enabled": true,
|
||||||
|
"use_https": false,
|
||||||
|
"verify_ssl": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```batch
|
||||||
|
cd windows
|
||||||
|
venv\Scripts\activate
|
||||||
|
python run_win.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| **"ffpyplayer not found"** | Install: `pip install ffpyplayer` |
|
||||||
|
| **"No video" / black screen** | Install FFmpeg and add to PATH. Try `KIVY_GL_BACKEND=angle_sdl2` or `KIVY_GL_BACKEND=gl` |
|
||||||
|
| **Kivy window doesn't open** | Run from command prompt to see error messages. Ensure GPU drivers are up to date. |
|
||||||
|
| **Weblinks not opening** | Install Google Chrome or Microsoft Edge |
|
||||||
|
| **Can't connect to server** | Check firewall. Try `use_https: false` and `verify_ssl: false` for testing |
|
||||||
|
| **Antivirus flags .exe** | Add the output folder to antivirus exclusions. This is a false positive common with PyInstaller. |
|
||||||
|
|
||||||
|
## 📁 Project Structure (Build)
|
||||||
|
|
||||||
|
```
|
||||||
|
Kiwy-Signage/
|
||||||
|
├── windows/
|
||||||
|
│ ├── run_win.py # Windows entry point (patches platform differences)
|
||||||
|
│ ├── build.spec # PyInstaller configuration
|
||||||
|
│ ├── build_win.bat # One-click build script
|
||||||
|
│ ├── pyi_runtime_hook.py # PyInstaller runtime hook
|
||||||
|
│ ├── requirements_win.txt # Windows Python dependencies
|
||||||
|
│ └── README_WINDOWS_BUILD.md # This file
|
||||||
|
├── src/
|
||||||
|
│ ├── main.py # Main application (original)
|
||||||
|
│ ├── get_playlists_v2.py # Playlist sync
|
||||||
|
│ ├── player_auth.py # Authentication
|
||||||
|
│ ├── ssl_utils.py # SSL/HTTPS
|
||||||
|
│ ├── keyboard_widget.py # On-screen keyboard
|
||||||
|
│ ├── network_monitor.py # Network monitoring
|
||||||
|
│ ├── edit_popup.py # Image editing
|
||||||
|
│ └── signage_player.kv # Kivy UI layout
|
||||||
|
├── config/
|
||||||
|
│ ├── app_config.json # Player configuration
|
||||||
|
│ └── resources/ # Icons, images, intro video
|
||||||
|
├── media/ # Downloaded media (created at runtime)
|
||||||
|
├── playlists/ # Playlist files (created at runtime)
|
||||||
|
└── logs/ # Log files (created at runtime)
|
||||||
|
```
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Quick syntax check for build files."""
|
||||||
|
import ast, sys
|
||||||
|
|
||||||
|
files = [
|
||||||
|
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\run_win.py',
|
||||||
|
r'c:\Users\Dell-PC\Desktop\Kiwy-Signage\windows\pyi_runtime_hook.py',
|
||||||
|
]
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
with open(f, encoding='utf-8') as fh:
|
||||||
|
ast.parse(fh.read())
|
||||||
|
print(f"OK: {f}")
|
||||||
|
except SyntaxError as e:
|
||||||
|
print(f"SYNTAX ERROR in {f}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("All files OK")
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,302 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
PyInstaller spec file for Kiwy Signage Player (Windows .exe)
|
||||||
|
|
||||||
|
Build command (from windows/ directory):
|
||||||
|
pyinstaller build.spec --clean --noconfirm
|
||||||
|
|
||||||
|
OR use the build script:
|
||||||
|
build_win.bat
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# --- Paths -----------------------------------------------------------
|
||||||
|
# This spec file is in windows/build.spec, so the project root is
|
||||||
|
# always two levels up from this file's real location.
|
||||||
|
# __file__ may not be available in PyInstaller spec context fallback to cwd.
|
||||||
|
try:
|
||||||
|
_spec_dir = Path(__file__).resolve().parent
|
||||||
|
except NameError:
|
||||||
|
_spec_dir = Path(os.getcwd()).resolve()
|
||||||
|
# _spec_dir is now the absolute path to the windows/ directory
|
||||||
|
BUILD_DIR = _spec_dir
|
||||||
|
ROOT_DIR = BUILD_DIR.parent
|
||||||
|
SRC_DIR = ROOT_DIR / 'src'
|
||||||
|
CONFIG_DIR = ROOT_DIR / 'config'
|
||||||
|
RESOURCES_DIR = CONFIG_DIR / 'resources'
|
||||||
|
|
||||||
|
# --- Determine hidden imports that PyInstaller might miss -------------
|
||||||
|
hidden_imports = [
|
||||||
|
# Kivy core modules
|
||||||
|
'kivy.core.window',
|
||||||
|
'kivy.core.video',
|
||||||
|
'kivy.core.audio',
|
||||||
|
'kivy.core.text',
|
||||||
|
'kivy.core.image',
|
||||||
|
'kivy.core.gl',
|
||||||
|
'kivy.core.camera',
|
||||||
|
'kivy.core.clipboard',
|
||||||
|
'kivy.core.spelling',
|
||||||
|
'kivy.core.text.markup',
|
||||||
|
'kivy.core.window.window_sdl2',
|
||||||
|
'kivy.core.image.img_sdl2',
|
||||||
|
'kivy.core.video.video_ffpyplayer',
|
||||||
|
'kivy.core.audio.audio_ffpyplayer',
|
||||||
|
# Kivy modules
|
||||||
|
'kivy.uix.video',
|
||||||
|
'kivy.uix.vkeyboard',
|
||||||
|
'kivy.uix.popup',
|
||||||
|
'kivy.uix.image',
|
||||||
|
'kivy.uix.button',
|
||||||
|
'kivy.uix.label',
|
||||||
|
'kivy.uix.textinput',
|
||||||
|
'kivy.uix.boxlayout',
|
||||||
|
'kivy.uix.floatlayout',
|
||||||
|
'kivy.uix.slider',
|
||||||
|
'kivy.uix.widget',
|
||||||
|
'kivy.uix.checkbox',
|
||||||
|
'kivy.graphics',
|
||||||
|
'kivy.graphics.texture',
|
||||||
|
'kivy.graphics.vertex_instructions',
|
||||||
|
'kivy.graphics.context_instructions',
|
||||||
|
'kivy.clock',
|
||||||
|
'kivy.loader',
|
||||||
|
'kivy.animation',
|
||||||
|
'kivy.lang',
|
||||||
|
'kivy.logger',
|
||||||
|
'kivy.config',
|
||||||
|
'kivy.properties',
|
||||||
|
'kivy.metrics',
|
||||||
|
'kivy.factory',
|
||||||
|
# Graphics providers
|
||||||
|
'kivy.graphics.opengl',
|
||||||
|
'kivy.graphics.opengl_utils',
|
||||||
|
'kivy.graphics.fbo',
|
||||||
|
'kivy.graphics.gl_instructions',
|
||||||
|
'kivy.graphics.stencil_instructions',
|
||||||
|
'kivy.graphics.scissor_instructions',
|
||||||
|
'kivy.graphics.buffer',
|
||||||
|
'kivy.graphics.vbo',
|
||||||
|
'kivy.graphics.shader',
|
||||||
|
'kivy.graphics.compiler',
|
||||||
|
# ffpyplayer
|
||||||
|
'ffpyplayer',
|
||||||
|
'ffpyplayer.player',
|
||||||
|
'ffpyplayer.pic',
|
||||||
|
'ffpyplayer.writer',
|
||||||
|
# Networking
|
||||||
|
'requests',
|
||||||
|
'aiohttp',
|
||||||
|
'urllib3',
|
||||||
|
'certifi',
|
||||||
|
'bcrypt',
|
||||||
|
# Platform
|
||||||
|
'ctypes',
|
||||||
|
'ctypes.wintypes',
|
||||||
|
'subprocess',
|
||||||
|
'shutil',
|
||||||
|
'glob',
|
||||||
|
'selectors',
|
||||||
|
'tempfile',
|
||||||
|
# Windows-specific
|
||||||
|
'cef_browser',
|
||||||
|
'win32gui',
|
||||||
|
'win32con',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Exclude Linux-only modules
|
||||||
|
excluded_imports = [
|
||||||
|
'gi', # GTK introspection (Linux)
|
||||||
|
'gi.repository',
|
||||||
|
'evdev', # We inject a fake evdev module in run_win.py
|
||||||
|
# GStreamer — we use ffpyplayer, not GStreamer
|
||||||
|
'kivy.lib.gstplayer',
|
||||||
|
# cefpython3: keep only Python 3.12 .pyd, exclude other version .pyd files
|
||||||
|
'cefpython3.cefpython_py27',
|
||||||
|
'cefpython3.cefpython_py34',
|
||||||
|
'cefpython3.cefpython_py35',
|
||||||
|
'cefpython3.cefpython_py36',
|
||||||
|
'cefpython3.cefpython_py37',
|
||||||
|
'cefpython3.cefpython_py38',
|
||||||
|
'cefpython3.cefpython_py39',
|
||||||
|
'cefpython3.cefpython_py310',
|
||||||
|
'cefpython3.cefpython_py311',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Application data files to bundle --------------------------------
|
||||||
|
# Resources (icons, intro video, etc.)
|
||||||
|
resources_data = []
|
||||||
|
for item in RESOURCES_DIR.iterdir():
|
||||||
|
if item.is_file():
|
||||||
|
target_dir = 'config/resources'
|
||||||
|
resources_data.append((str(item), target_dir))
|
||||||
|
|
||||||
|
# Config directory (app_config.json)
|
||||||
|
config_data = []
|
||||||
|
config_file = CONFIG_DIR / 'app_config.json'
|
||||||
|
if config_file.exists():
|
||||||
|
config_data.append((str(config_file), 'config'))
|
||||||
|
|
||||||
|
# Source files - .kv file
|
||||||
|
kv_file = SRC_DIR / 'signage_player.kv'
|
||||||
|
kv_data = []
|
||||||
|
if kv_file.exists():
|
||||||
|
kv_data.append((str(kv_file), '.'))
|
||||||
|
|
||||||
|
# Bundle the entire src directory as a tree
|
||||||
|
source_tree = Tree(str(SRC_DIR), prefix='', excludes=['*.pyc', '__pycache__', '*.ini'])
|
||||||
|
|
||||||
|
# --- Collect binary DLLs from kivy_deps and ffpyplayer ----------------
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
|
||||||
|
def _site_packages_dir(package_path):
|
||||||
|
"""Climb up from a package's __init__.py to its site-packages dir."""
|
||||||
|
d = _Path(package_path).parent
|
||||||
|
while d.name != 'site-packages' and d.parent != d:
|
||||||
|
d = d.parent
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _find_share_dlls(package_name, share_name=None):
|
||||||
|
"""Find .dll files under venv_root/share/<share_name>/.
|
||||||
|
|
||||||
|
kivy_deps.sdl2/angle/glew and ffpyplayer install their DLLs into
|
||||||
|
<venv>/share/<pkg>/... NOT inside the package dir. The share folder is
|
||||||
|
named after the *short* dep name (e.g. 'sdl2', 'angle', 'glew'), not the
|
||||||
|
dotted package name ('kivy_deps.sdl2'), so pass share_name explicitly.
|
||||||
|
"""
|
||||||
|
if share_name is None:
|
||||||
|
share_name = package_name
|
||||||
|
spec = importlib.util.find_spec(package_name)
|
||||||
|
if spec is None or not spec.origin:
|
||||||
|
return []
|
||||||
|
sp = _site_packages_dir(spec.origin)
|
||||||
|
# Climb from site-packages up until we find a sibling 'share' dir
|
||||||
|
# (site-packages -> Lib -> venv, where venv/share lives).
|
||||||
|
d = sp
|
||||||
|
while d.parent != d:
|
||||||
|
if (d.parent / 'share').is_dir():
|
||||||
|
share = d.parent / 'share' / share_name
|
||||||
|
break
|
||||||
|
d = d.parent
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
if not share.is_dir():
|
||||||
|
return []
|
||||||
|
results = []
|
||||||
|
for root, dirs, files in os.walk(share):
|
||||||
|
for f in files:
|
||||||
|
if f.endswith('.dll'):
|
||||||
|
results.append((os.path.join(root, f), '.'))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _find_ffpyplayer_bins():
|
||||||
|
"""Return ffpyplayer's own dependency DLL dirs (FFmpeg + bundled SDL).
|
||||||
|
|
||||||
|
ffpyplayer ships a `dep_bins` list that already points at the correct
|
||||||
|
share/ffpyplayer/ffmpeg/bin and share/ffpyplayer/sdl/bin directories.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import ffpyplayer
|
||||||
|
bins = getattr(ffpyplayer, 'dep_bins', None)
|
||||||
|
if not bins:
|
||||||
|
return []
|
||||||
|
results = []
|
||||||
|
for b in bins:
|
||||||
|
bpath = _Path(b)
|
||||||
|
if bpath.is_dir():
|
||||||
|
for f in bpath.glob('*.dll'):
|
||||||
|
results.append((str(f), '.'))
|
||||||
|
return results
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# SDL2 / ANGLE / GLEW DLLs (kivy_deps share dirs)
|
||||||
|
_sdl2_dlls = _find_share_dlls('kivy_deps.sdl2', 'sdl2')
|
||||||
|
_angle_dlls = _find_share_dlls('kivy_deps.angle', 'angle')
|
||||||
|
_glew_dlls = _find_share_dlls('kivy_deps.glew', 'glew')
|
||||||
|
|
||||||
|
# ffpyplayer FFmpeg + bundled SDL DLLs (via dep_bins)
|
||||||
|
_ffpy_dlls = _find_ffpyplayer_bins()
|
||||||
|
|
||||||
|
_all_binaries = _sdl2_dlls + _angle_dlls + _glew_dlls + _ffpy_dlls
|
||||||
|
|
||||||
|
if not _all_binaries:
|
||||||
|
print("=" * 70)
|
||||||
|
print("WARNING: No Kivy/ffpyplayer DLLs found via share/ directories.")
|
||||||
|
print("PyInstaller may still auto-detect them, but if the .exe")
|
||||||
|
print("fails with 'SDL2.dll not found' or similar, you will need")
|
||||||
|
print("to manually add the DLL paths to the spec file.")
|
||||||
|
print("=" * 70)
|
||||||
|
else:
|
||||||
|
print(f"[spec] Bundling {len(_all_binaries)} DLLs:")
|
||||||
|
for _p, _t in sorted(_all_binaries):
|
||||||
|
print(f" {_Path(_p).name} <- {_p}")
|
||||||
|
|
||||||
|
# --- Build the .exe --------------------------------------------------
|
||||||
|
a = Analysis(
|
||||||
|
['run_win.py'], # Entry point (relative to this spec)
|
||||||
|
pathex=[str(BUILD_DIR), str(SRC_DIR)], # Where to find modules
|
||||||
|
binaries=_all_binaries,
|
||||||
|
datas=resources_data + config_data + kv_data,
|
||||||
|
hiddenimports=hidden_imports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[str(BUILD_DIR / 'pyi_runtime_hook.py')],
|
||||||
|
excludes=excluded_imports,
|
||||||
|
noarchive=False,
|
||||||
|
module_collection_mode={
|
||||||
|
'kivy': 'pyz',
|
||||||
|
'kivy.core': 'pyz',
|
||||||
|
'kivy.uix': 'pyz',
|
||||||
|
'kivy.graphics': 'pyz',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the source tree (main.py, etc.)
|
||||||
|
a.datas += source_tree
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='KiwySignagePlayer',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True, # Show console for debugging startup errors
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=str(BUILD_DIR / 'app_icon.ico') if (BUILD_DIR / 'app_icon.ico').exists() else None,
|
||||||
|
version=str(BUILD_DIR / 'version_info.txt') if (BUILD_DIR / 'version_info.txt').exists() else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- COLLECT everything into a single folder -------------------------
|
||||||
|
coll = COLLECT(
|
||||||
|
exe,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
name='KiwySignagePlayer',
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
@echo off
|
||||||
|
REM =====================================================================
|
||||||
|
REM Kiwy Signage Player - Windows Build Script
|
||||||
|
REM =====================================================================
|
||||||
|
REM This script builds a standalone Windows .exe using PyInstaller.
|
||||||
|
REM
|
||||||
|
REM Prerequisites:
|
||||||
|
REM 1. Python 3.10+ installed (with "Add to PATH" checked)
|
||||||
|
REM 2. Visual C++ Redistributable (for ffpyplayer)
|
||||||
|
REM 3. FFmpeg binaries in PATH (optional, for video codec support)
|
||||||
|
REM
|
||||||
|
REM Steps:
|
||||||
|
REM 1. Run this script from the project root or the windows\ folder
|
||||||
|
REM 2. The .exe will be created in windows\dist\KiwySignagePlayer\
|
||||||
|
REM =====================================================================
|
||||||
|
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Kiwy Signage Player - Windows Build
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM ---- Check Python ----
|
||||||
|
where python >nul 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Python not found! Please install Python 3.10+ and add it to PATH.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [INFO] Using Python:
|
||||||
|
python --version
|
||||||
|
|
||||||
|
REM ---- Create virtual environment (if not exists) ----
|
||||||
|
if not exist "venv\Scripts\python.exe" (
|
||||||
|
echo.
|
||||||
|
echo [STEP] Creating virtual environment...
|
||||||
|
python -m venv venv
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Failed to create virtual environment.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [INFO] Virtual environment already exists.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Activate virtual environment ----
|
||||||
|
call venv\Scripts\activate.bat
|
||||||
|
|
||||||
|
REM ---- Install/upgrade pip ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Upgrading pip...
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
|
||||||
|
REM ---- Install dependencies ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Installing Windows dependencies...
|
||||||
|
pip install -r requirements_win.txt
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Failed to install dependencies.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Verify Kivy installation ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Verifying Kivy installation...
|
||||||
|
python -c "import kivy; print(f'Kivy {kivy.__version__}')" 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [WARNING] Kivy check failed. Build may still work but test carefully.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Check PyInstaller ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Verifying PyInstaller...
|
||||||
|
python -c "import PyInstaller; print(f'PyInstaller {PyInstaller.__version__}')" 2>&1
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] PyInstaller not found.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Create app icon (from PNG if possible) ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Checking for app icon...
|
||||||
|
if not exist "..\config\resources\app_icon.ico" (
|
||||||
|
echo [INFO] No .ico icon found. Will use default PyInstaller icon.
|
||||||
|
echo [INFO] To add a custom icon, place app_icon.ico in config\resources\
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Run PyInstaller ----
|
||||||
|
echo.
|
||||||
|
echo [STEP] Building executable with PyInstaller...
|
||||||
|
echo This may take several minutes. Please wait...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
pyinstaller build.spec --clean --noconfirm
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] PyInstaller build failed!
|
||||||
|
echo Check the output above for error details.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ---- Success ----
|
||||||
|
echo.
|
||||||
|
echo ============================================
|
||||||
|
echo BUILD COMPLETE!
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Output: %~dp0dist\KiwySignagePlayer\
|
||||||
|
echo.
|
||||||
|
echo The executable is:
|
||||||
|
echo %~dp0dist\KiwySignagePlayer\KiwySignagePlayer.exe
|
||||||
|
echo.
|
||||||
|
echo To run: Double-click KiwySignagePlayer.exe
|
||||||
|
echo.
|
||||||
|
echo Note: The first run may take a while as Windows Defender
|
||||||
|
echo scans the executable. This is normal.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
cef_browser.py v2 — Embedded Chromium INSIDE Kivy's SDL2 window
|
||||||
|
|
||||||
|
v1 created a separate Win32 window (same as external Chrome).
|
||||||
|
v2 creates CEF as a **child window** of Kivy's SDL_app window:
|
||||||
|
- No separate taskbar entry
|
||||||
|
- No z-order fighting
|
||||||
|
- No desktop flash
|
||||||
|
- CEF message loop pumped via Kivy Clock (main thread)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cefpython3 import cefpython as cef
|
||||||
|
CEF_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
CEF_AVAILABLE = False
|
||||||
|
|
||||||
|
WS_CHILD = 0x40000000
|
||||||
|
WS_VISIBLE = 0x10000000
|
||||||
|
WS_CLIPSIBLINGS = 0x04000000
|
||||||
|
WS_CLIPCHILDREN = 0x02000000
|
||||||
|
SW_HIDE = 0
|
||||||
|
SW_SHOWNORMAL = 1
|
||||||
|
|
||||||
|
|
||||||
|
class CefBrowser:
|
||||||
|
def __init__(self):
|
||||||
|
self._browser = None
|
||||||
|
self._cef_initialized = False
|
||||||
|
self._child_hwnd = None
|
||||||
|
self._kivy_hwnd = None
|
||||||
|
self._clock_event = None
|
||||||
|
self._showing = False
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def show(self, url):
|
||||||
|
if not CEF_AVAILABLE:
|
||||||
|
return False
|
||||||
|
if not self._cef_initialized:
|
||||||
|
self._init_cef()
|
||||||
|
if self._browser is not None:
|
||||||
|
self._browser.Navigate(url)
|
||||||
|
self._show_in_kivy()
|
||||||
|
return True
|
||||||
|
return self._create_embedded(url)
|
||||||
|
|
||||||
|
def hide(self):
|
||||||
|
self._showing = False
|
||||||
|
if self._clock_event is not None:
|
||||||
|
try:
|
||||||
|
from kivy.clock import Clock
|
||||||
|
Clock.unschedule(self._clock_event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._clock_event = None
|
||||||
|
if self._child_hwnd:
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_HIDE)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._browser is not None:
|
||||||
|
try:
|
||||||
|
self._browser.CloseBrowser(True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._browser = None
|
||||||
|
if self._child_hwnd:
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.DestroyWindow(self._child_hwnd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._child_hwnd = None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.hide()
|
||||||
|
if self._cef_initialized:
|
||||||
|
try:
|
||||||
|
cef.Shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._cef_initialized = False
|
||||||
|
|
||||||
|
def navigate(self, url):
|
||||||
|
if self._browser is not None:
|
||||||
|
self._browser.Navigate(url)
|
||||||
|
|
||||||
|
def is_showing(self):
|
||||||
|
return self._showing
|
||||||
|
|
||||||
|
def resize(self, width, height):
|
||||||
|
"""Called when Kivy window resizes — repositions CEF child."""
|
||||||
|
if self._child_hwnd:
|
||||||
|
ctypes.windll.user32.SetWindowPos(
|
||||||
|
self._child_hwnd, 0, 0, 0, width, height, 0x0004
|
||||||
|
)
|
||||||
|
if self._browser:
|
||||||
|
self._browser.SetBounds(0, 0, width, height)
|
||||||
|
|
||||||
|
# ── Internal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _init_cef(self):
|
||||||
|
settings = {
|
||||||
|
"multi_threaded_message_loop": False,
|
||||||
|
"single_process": True,
|
||||||
|
"log_severity": cef.LOGSEVERITY_WARNING,
|
||||||
|
"user_agent": "Mozilla/5.0 KiwySignage/1.0",
|
||||||
|
"cache_path": str(
|
||||||
|
Path(os.environ.get("KIWY_DATA_DIR", ".")) / ".cef_cache"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
cef.Initialize(settings=settings)
|
||||||
|
self._cef_initialized = True
|
||||||
|
|
||||||
|
def _get_kivy_hwnd(self):
|
||||||
|
if self._kivy_hwnd is not None:
|
||||||
|
return self._kivy_hwnd
|
||||||
|
try:
|
||||||
|
import win32gui
|
||||||
|
hwnd = win32gui.FindWindow("SDL_app", None)
|
||||||
|
if hwnd:
|
||||||
|
self._kivy_hwnd = hwnd
|
||||||
|
return hwnd
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_embedded(self, url):
|
||||||
|
kivy_hwnd = self._get_kivy_hwnd()
|
||||||
|
if not kivy_hwnd:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
rect = (ctypes.c_long * 4)()
|
||||||
|
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||||
|
w, h = rect[2], rect[3]
|
||||||
|
|
||||||
|
hinstance = ctypes.windll.kernel32.GetModuleHandleW(None)
|
||||||
|
self._child_hwnd = user32.CreateWindowExW(
|
||||||
|
0, b'#32770', b'',
|
||||||
|
WS_CHILD | WS_CLIPSIBLINGS | WS_CLIPCHILDREN,
|
||||||
|
0, 0, w, h, kivy_hwnd, 0, hinstance, 0,
|
||||||
|
)
|
||||||
|
if not self._child_hwnd:
|
||||||
|
return False
|
||||||
|
|
||||||
|
winfo = cef.WindowInfo()
|
||||||
|
winfo.SetAsChild(self._child_hwnd, [0, 0, w, h])
|
||||||
|
self._browser = cef.CreateBrowserSync(
|
||||||
|
window_info=winfo,
|
||||||
|
settings={"background_color": 0x00000000},
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
self._showing = True
|
||||||
|
self._show_in_kivy()
|
||||||
|
self._start_clock_pump()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _show_in_kivy(self):
|
||||||
|
if not self._child_hwnd:
|
||||||
|
return
|
||||||
|
kivy_hwnd = self._get_kivy_hwnd()
|
||||||
|
if kivy_hwnd:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
rect = (ctypes.c_long * 4)()
|
||||||
|
user32.GetClientRect(kivy_hwnd, ctypes.byref(rect))
|
||||||
|
user32.SetWindowPos(
|
||||||
|
self._child_hwnd, 0, 0, 0, rect[2], rect[3], 0x0004
|
||||||
|
)
|
||||||
|
ctypes.windll.user32.ShowWindow(self._child_hwnd, SW_SHOWNORMAL)
|
||||||
|
self._showing = True
|
||||||
|
|
||||||
|
def _start_clock_pump(self):
|
||||||
|
if self._clock_event is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _pump(dt):
|
||||||
|
if self._cef_initialized:
|
||||||
|
try:
|
||||||
|
cef.MessageLoopWork()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._showing:
|
||||||
|
from kivy.clock import Clock
|
||||||
|
self._clock_event = Clock.schedule_once(_pump, 0.01)
|
||||||
|
|
||||||
|
from kivy.clock import Clock
|
||||||
|
self._clock_event = Clock.schedule_once(_pump, 0)
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# 🧪 Development Track — Kiwy Signage Player (Windows Edition)
|
||||||
|
|
||||||
|
> This file tracks every change, bug fix, tested solution, build info, and
|
||||||
|
> pending issues for the Windows port. Read this FIRST before starting any
|
||||||
|
> debugging or coding session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 Current Session — 2026-07-31
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Branch** | `Windows-Player` |
|
||||||
|
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||||
|
| **Kivy** | 2.3.1 |
|
||||||
|
| **PyInstaller** | 6.21.0 |
|
||||||
|
| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||||
|
| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||||
|
|
||||||
|
### 📋 Cross-platform audit — Linux commands → Windows handling
|
||||||
|
|
||||||
|
Every Linux-only command in `src/` was cross-referenced against the patches
|
||||||
|
in `windows/run_win.py`. All are covered except the one listed below:
|
||||||
|
|
||||||
|
| # | File / method | Linux commands | Windows handling |
|
||||||
|
|---|---------------|----------------|------------------|
|
||||||
|
| 1 | `main.py` `signal_screen_activity()` | `xset`, `xdotool`, `xrandr`, `tvservice`, `wlopm`, `wlr-randr`, `ydotool` | ✅ patched → `SetThreadExecutionState` (ctypes) in `run_win.py` |
|
||||||
|
| 2 | `main.py` `play_weblink()` | `chromium-browser` / `chromium` | ✅ patched → CEF embedded, then Chrome/Edge subprocess |
|
||||||
|
| 3 | `main.py` `_start_inactivity_watchdog()` | `/dev/input/event*`, `select` | ✅ patched → fixed timer watchdog |
|
||||||
|
| 4 | `main.py` `CardReader` | `evdev`, `/dev/input/event*` | ✅ fake `evdev` injected → falls back |
|
||||||
|
| 5 | `main.py` `SettingsPopup.test_connection` | `/tmp/temp_auth_test.json` | ✅ patched → `tempfile.gettempdir()` |
|
||||||
|
| 6 | `main.py` weblink kill/prewarm wrappers | `proc.terminate()` only | ✅ patched → `taskkill /F /T` + `_Win32Overlay` |
|
||||||
|
| 7 | `network_monitor.py` `_test_server_connection()` | `ping -c 3 -W 3` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||||
|
| 8 | `network_monitor.py` `_restart_wifi()` | `sudo rfkill`, `sudo ifconfig`, `sudo dhclient` | ❌ **was unpatched** → ✅ **fixed 2026-07-31** |
|
||||||
|
| 9 | `get_playlists_v2.py`, `player_auth.py`, `ssl_utils.py`, `edit_popup.py`, `keyboard_widget.py` | none | ✅ no Linux commands |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Bug Tracker
|
||||||
|
|
||||||
|
### [BUG-010] NetworkMonitor uses Linux-only ping + rfkill commands
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** `network_monitor.py` ran `ping -c 3 -W 3` (Linux flags) and on
|
||||||
|
connection failure invoked `sudo rfkill` / `sudo ifconfig wlan0` /
|
||||||
|
`sudo dhclient` — all fail or hang on Windows (`sudo` isn't even present).
|
||||||
|
- **Root cause:** This module was missed when the other Linux paths were
|
||||||
|
patched in `run_win.py`.
|
||||||
|
- **Fix:** Made `network_monitor.py` self-contained cross-platform:
|
||||||
|
1. Added `IS_WINDOWS = platform.system() == 'Windows'`
|
||||||
|
2. `_test_server_connection()` uses `ping -n 3 -w 3000` on Windows
|
||||||
|
3. `_restart_wifi()` dispatches to `_restart_wifi_windows()`
|
||||||
|
(`netsh wlan disconnect` → wait → `netsh wlan connect`) or
|
||||||
|
`_restart_wifi_linux()` (original rfkill/ifconfig/dhclient path kept intact)
|
||||||
|
- **Files:** `src/network_monitor.py`
|
||||||
|
- **Test:** Windows `ping -n 3 -w 3000 localhost` returns 0; AST parse OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [BUG-011] Weblink never displays on Windows (opens behind Kivy / exits instantly)
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** Web link items don't show. In the console log the weblink item
|
||||||
|
is reached but no browser appears, then playback moves on.
|
||||||
|
- **Root causes (two compounding):**
|
||||||
|
1. **Chrome re-used an existing instance.** `subprocess.Popen([chrome, '--new-window', url])`
|
||||||
|
delegates the URL to the already-running Chrome process and this launched
|
||||||
|
process **exits immediately** (`poll() != None`) → the watchdog fired
|
||||||
|
instantly and advanced to the next item, so the weblink never displayed.
|
||||||
|
2. **Overlay-hide raised Kivy over Chrome.** `_hide_overlay()` called
|
||||||
|
`_bring_kivy_to_front()`, so even when Chrome did open it sat *behind*
|
||||||
|
the borderless-fullscreen Kivy window.
|
||||||
|
- **Fix (in `windows/run_win.py`):**
|
||||||
|
1. Launch Chrome/Edge with a **dedicated `--user-data-dir`** (`<data>/.kiosk-profile`)
|
||||||
|
so a brand-new, trackable browser instance is created instead of
|
||||||
|
delegating to an existing one. Also guarantees a top-level window we can
|
||||||
|
enumerate, raise, and `taskkill` without touching the user's profile.
|
||||||
|
2. `_hide_overlay()` now calls **`_bring_chrome_to_front(proc)`** (new helper
|
||||||
|
that enumerates `Chrome_WidgetWin_1/0` windows owned by the launched PID)
|
||||||
|
instead of raising Kivy.
|
||||||
|
- **Update (2026-07-31 15:42):** added **`--kiosk`** flag to the weblink launch
|
||||||
|
args so the browser opens in true kiosk mode (no UI/chrome, locks to screen).
|
||||||
|
Safe with the dedicated `--user-data-dir` — does not affect the user's normal
|
||||||
|
browser session.
|
||||||
|
- **Update (2026-07-31 16:04):** replaced the fixed 1.0s overlay-hide timer with
|
||||||
|
**adaptive polling** (`_hide_overlay_when_chrome_ready`). The black overlay now
|
||||||
|
stays up until Chrome's window is actually detected on screen
|
||||||
|
(`_find_chrome_hwnd`), so the host desktop is never exposed during cold
|
||||||
|
starts / slow disk / GPU init. Falls back to Kivy after a 6s timeout.
|
||||||
|
- **Update (2026-07-31 16:19):** added a **persistent `_Win32Backdrop`** — a
|
||||||
|
fullscreen black window created at player startup (`_Win32Backdrop.show()`)
|
||||||
|
placed at `HWND_BOTTOM` (below Kivy & the kiosk browser, above the desktop),
|
||||||
|
destroyed only on clean exit. Any browser load/unload gap now reveals clean
|
||||||
|
black instead of the host desktop.
|
||||||
|
- **Test:** exe rebuilt 2026-07-31 16:19; DLL set intact (28 DLLs incl. FFmpeg).
|
||||||
|
|
||||||
|
### [BUG-012] Next widget never comes to foreground after weblink ends
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-31**
|
||||||
|
- **Symptom:** After a weblink finishes, the next media/widget renders but the
|
||||||
|
Kivy window stays behind (or the window focus is lost) — user sees the wrong
|
||||||
|
window / frozen view.
|
||||||
|
- **Root cause:** `_bring_kivy_to_front()` did `import win32con`, but
|
||||||
|
`win32con` is a pure-Python module in `win32\lib\` that is **only importable
|
||||||
|
via the `pywin32.pth` file**. `.pth` files are ignored in frozen PyInstaller
|
||||||
|
apps, so `win32con` was never bundled (confirmed via `pyi-archive_viewer` —
|
||||||
|
only `win32gui.pyd` / `win32api.pyd` / `win32process.pyd` present). The
|
||||||
|
`import win32con` threw, the whole function silently fell back to
|
||||||
|
`Window.raise_window()`, and the Kivy window was never reliably raised.
|
||||||
|
- **Fix (in `windows/run_win.py`):**
|
||||||
|
1. Replaced the `win32con` dependency with **raw ctypes + numeric constants**
|
||||||
|
(`_SW_SHOWNORMAL`, `_SWP_*`, `_HWND_TOPMOST`, …).
|
||||||
|
2. New `_bring_hwnd_to_front(hwnd)` — ctypes-only `SetForegroundWindow` with
|
||||||
|
`AttachThreadInput` foreground-lock bypass + `IsIconic` restore + topmost
|
||||||
|
flash.
|
||||||
|
3. `_bring_kivy_to_front()` now uses `_find_kivy_hwnd()` (win32gui.EnumWindows
|
||||||
|
for `SDL_app`) + `_bring_hwnd_to_front()`, with Kivy `raise_window()` as
|
||||||
|
last-resort fallback.
|
||||||
|
- **Test:** exe rebuilt; no `win32con` import remains in `run_win.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [BUG-001] RecursionError: play_current_media ↔ restart_playlist
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** Pressing "Restart Player" in settings with empty playlist causes
|
||||||
|
infinite recursion: `play_current_media → restart_playlist → play_current_media → ...`
|
||||||
|
- **Fix:** Added empty-playlist guard in both `play_current_media()` and
|
||||||
|
`restart_playlist()` → they return early instead of calling each other.
|
||||||
|
- **Files:** `src/main.py` — lines ~1304 and ~2073
|
||||||
|
- **Test:** Verified no Python syntax errors via `ast.parse`.
|
||||||
|
|
||||||
|
### [BUG-002] Settings fields cut off on small screens
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** "Screen Name", "Quickconnect" and other fields at the top of
|
||||||
|
the settings popup are invisible on smaller resolutions because content
|
||||||
|
overflows the popup.
|
||||||
|
- **Fix:** Wrapped settings content in a `ScrollView`. Moved "Save & Close" /
|
||||||
|
"Cancel" buttons outside the scroll (always visible). Reduced row heights.
|
||||||
|
- **Files:** `src/signage_player.kv` — `<SettingsPopup@Popup>` block
|
||||||
|
|
||||||
|
### [BUG-003] Chromium not fullscreen on Windows
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** Web links open in a small window instead of fullscreen.
|
||||||
|
- **Fix:** Changed launch args from `--kiosk` to `--start-maximized --app=URL`
|
||||||
|
+ explicit `--window-size=WxH`. `--kiosk` uses Wayland exclusive-fullscreen
|
||||||
|
protocol which doesn't work on Windows.
|
||||||
|
- **Tested rejected solutions:**
|
||||||
|
- ❌ `--kiosk` alone → small window, no fullscreen
|
||||||
|
- ❌ `--start-fullscreen` alone → not reliable
|
||||||
|
- ✅ `--start-maximized --app=URL --window-size=...` → works
|
||||||
|
- **Files:** `windows/run_win.py` — `_windows_play_weblink()`
|
||||||
|
|
||||||
|
### [BUG-004] Desktop flash when switching between Chromium and Kivy
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** When Chrome closes, the desktop is briefly visible before Kivy
|
||||||
|
reappears. Also when Chrome opens, there's a flash.
|
||||||
|
- **Fix:** Added `_Win32Overlay` class — a fullscreen black Win32 window that
|
||||||
|
covers the screen during transitions. Shown BEFORE closing Chrome / opening
|
||||||
|
Chrome, hidden AFTER Kivy is ready.
|
||||||
|
- **Tested rejected solutions:**
|
||||||
|
- ❌ `Window.raise_window()` alone → still shows flash
|
||||||
|
- ✅ Win32 black overlay → smooth masking
|
||||||
|
- **Files:** `windows/run_win.py` — `_Win32Overlay` class
|
||||||
|
|
||||||
|
### [BUG-005] Chrome processes linger after closing weblink
|
||||||
|
- **Status:** ✅ Fixed 2026-07-24
|
||||||
|
- **Symptom:** After a weblink item ends, Chrome child processes (GPU,
|
||||||
|
renderer) remain running → blank windows accumulate.
|
||||||
|
- **Fix:** Use `taskkill /F /T /PID <pid>` to kill the entire process tree.
|
||||||
|
- **Tested rejected solutions:**
|
||||||
|
- ❌ `proc.terminate()` → leaves children running
|
||||||
|
- ❌ `proc.kill()` → same problem
|
||||||
|
- ✅ `taskkill /F /T` → kills everything
|
||||||
|
- **Files:** `windows/run_win.py` — `_windows_kill_process_tree()`
|
||||||
|
|
||||||
|
### [BUG-007] Video plays behind Chromium on weblink→media transition
|
||||||
|
- **Status:** ✅ **Fixed — 2026-07-26 (final)**
|
||||||
|
- **Symptom:** When a weblink ends and the next media starts, the media plays
|
||||||
|
*behind* Chromium. Audio is heard but user sees Chrome.
|
||||||
|
- **Root cause (Windows):** Linux renders Kivy widget UNDER Chromium → closes
|
||||||
|
Chrome → widget visible. On Windows Chrome stays ON TOP.
|
||||||
|
`Window.raise_window()` is unreliable. Three compounding issues:
|
||||||
|
1. `KivyWindow.minimize()` made Kivy impossible to bring back reliably
|
||||||
|
2. `_windows_play_current_media` killed the browser but never restored
|
||||||
|
`content_area.opacity = 1`, so next widget rendered invisible
|
||||||
|
3. `_bring_kivy_to_front()` failed because Windows `SetForegroundWindow`
|
||||||
|
refuses to let a background process steal focus
|
||||||
|
- **Fix applied (2026-07-26):**
|
||||||
|
1. **Removed `KivyWindow.minimize()`** in `_windows_play_weblink()` — Kivy
|
||||||
|
stays visible behind the overlay instead of being hidden
|
||||||
|
2. **Restored `content_area.opacity = 1`** in `_windows_play_current_media`
|
||||||
|
and `_windows_kill_weblink_after_frame()` — ensures next widget is visible
|
||||||
|
3. **`_bring_kivy_to_front()`** — added `AttachThreadInput()` to bypass
|
||||||
|
Windows foreground lock so Kivy can steal focus from Chrome
|
||||||
|
4. **Overlay hide** now calls `_bring_kivy_to_front()` instead of
|
||||||
|
`Window.raise_window()`
|
||||||
|
5. **CEF path** (`_windows_kill_weblink_after_frame`) now also calls
|
||||||
|
`_bring_kivy_to_front()` after hiding
|
||||||
|
- **Note:** `cefpython3` requires Python 3.10 — falls back to subprocess
|
||||||
|
Chrome/Edge on 3.12.9. Transition now works reliably with subprocess path.
|
||||||
|
- **Files:** `windows/run_win.py`
|
||||||
|
|
||||||
|
### [BUG-008] Intro video and media files not found at runtime
|
||||||
|
- **Status:** ✅ **Fixed** 2026-07-24
|
||||||
|
- **Symptom:** `[ERROR] [Image] Error loading <...intro1.mp4>` — intro
|
||||||
|
broken. Also `❌ Media file not found` for playlist items.
|
||||||
|
- **Root cause:** Media download only ran when `server_version > local_version`.
|
||||||
|
When versions matched (v16 == v16), `download_media_files` was never called
|
||||||
|
→ media folder stayed empty.
|
||||||
|
- **Fix:** Added download check in the "up to date" branch — now downloads
|
||||||
|
missing media files even when playlist version hasn't changed.
|
||||||
|
|
||||||
|
### [BUG-009] Video never advances to next item (EOS handler empty)
|
||||||
|
- **Status:** ✅ **Fixed** 2026-07-24
|
||||||
|
- **Symptom:** Video plays but never advances to the next playlist item.
|
||||||
|
- **Root cause:** `_on_video_eos()` callback was a stub — just logged
|
||||||
|
"Video finished playing (EOS)" but never called `next_media()`.
|
||||||
|
- **Fix:** Added `Clock.unschedule(self.next_media)` + `Clock.schedule_once`
|
||||||
|
to advance after 0.5s when a video reaches end of stream.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Tested & Rejected Solutions Log
|
||||||
|
|
||||||
|
> Keep a record of approaches that were tried and didn't work, so we don't
|
||||||
|
> waste time re-testing them.
|
||||||
|
|
||||||
|
| Date | What was tested | Result | Reason it failed |
|
||||||
|
|------|----------------|--------|-----------------|
|
||||||
|
| 2026-07-24 | Python 3.14 with Kivy | ❌ | `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel |
|
||||||
|
| 2026-07-24 | `--kiosk` Chrome flag on Windows | ❌ | Not fullscreen, Wayland exclusive-fullscreen not available |
|
||||||
|
| 2026-07-24 | `--start-fullscreen` alone | ❌ | Inconsistent, sometimes not full |
|
||||||
|
| 2026-07-24 | `proc.terminate()` for Chrome | ❌ | Leaves child processes running |
|
||||||
|
| 2026-07-24 | `proc.kill()` for Chrome | ❌ | Same as terminate — children survive |
|
||||||
|
| 2026-07-24 | `Window.raise_window()` for transition | ❌ | Brief desktop flash visible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Data Directory Behaviour
|
||||||
|
|
||||||
|
When the .exe runs:
|
||||||
|
1. Runtime hook (`pyi_runtime_hook.py`) sets `KIWY_DATA_DIR = exe_dir`
|
||||||
|
2. `run_win.py` patches `SignagePlayer.__init__` to use `KIWY_DATA_DIR`
|
||||||
|
3. Local folders created next to the .exe:
|
||||||
|
```
|
||||||
|
KiwySignagePlayer.exe
|
||||||
|
config/
|
||||||
|
app_config.json
|
||||||
|
resources/ (icons, intro video)
|
||||||
|
certs/ (SSL certificates)
|
||||||
|
media/
|
||||||
|
edited_media/
|
||||||
|
playlists/
|
||||||
|
logs/
|
||||||
|
.kivy/ (Kivy home)
|
||||||
|
.player_heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Build Cheatsheet
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Build the .exe (from windows/ directory)
|
||||||
|
Set-Location windows
|
||||||
|
& .\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||||
|
|
||||||
|
# Run in dev mode (no build needed)
|
||||||
|
& .\venv\Scripts\python.exe run_win.py
|
||||||
|
|
||||||
|
# Test imports only
|
||||||
|
& .\venv\Scripts\python.exe test_import_fix.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes for the Next Session
|
||||||
|
|
||||||
|
- [x] ~~Investigate [BUG-006]~~ → merged into [BUG-007], fixed with CEF + win32gui
|
||||||
|
- [x] ~~Test `SetForegroundWindow`~~ → `_bring_kivy_to_front()` uses `win32gui`
|
||||||
|
- [x] Install `cefpython3` — embedded Chromium, no more subprocess
|
||||||
|
- [x] ~~Verify CEF embedded browser actually works at runtime~~ → CEF needs Python 3.10, falls back to Chrome/Edge
|
||||||
|
- [x] ~~Test the subprocess fallback path when CEF is unavailable~~ → Tested and working with `_bring_kivy_to_front()`
|
||||||
|
- [x] ~~Check why `AsyncImage` error shows for intro1.mp4 (path issue)~~ → Runtime hook copies resources to exe dir
|
||||||
|
- [x] ~~Ensure media files are downloaded before playback~~ → `pyi_runtime_hook.py` copies config/resources on first run
|
||||||
|
- [x] ~~Add `cef_browser.py` to PyInstaller hidden imports~~ → Already in `build.spec`
|
||||||
|
- [x] ~~Make `network_monitor.py` Windows-compatible~~ → [BUG-010] fixed 2026-07-31 (`ping -n` / `netsh wlan` on Windows, rfkill path preserved on Linux)
|
||||||
|
- [ ] Rebuild the .exe to pick up the `network_monitor.py` fix
|
||||||
|
- [ ] Clean `cefpython3` from `venv/` (Python 3.12 won't use it anyway)
|
||||||
|
- [ ] Verify the .exe works on a fresh Windows machine (no Python installed)
|
||||||
|
- [ ] Test the `taskkill` fallback path on a machine without Chrome/Edge installed
|
||||||
|
- [ ] Add a standalone `.bat` launcher for development mode
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
REM ============================================================
|
||||||
|
REM Kiwy Signage Player - Windows Launcher
|
||||||
|
REM ============================================================
|
||||||
|
REM This batch file launches the Kiwy Signage Player executable.
|
||||||
|
REM It creates local folders for playlist, media, config, and logs
|
||||||
|
REM next to the executable.
|
||||||
|
REM ============================================================
|
||||||
|
|
||||||
|
cd /d "%~dp0dist\KiwySignagePlayer"
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Kiwy Signage Player - Windows Edition
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Launching player...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
start "" "KiwySignagePlayer.exe"
|
||||||
|
|
||||||
|
echo Player started.
|
||||||
|
echo.
|
||||||
|
echo If the player window does not appear, check:
|
||||||
|
echo dist\KiwySignagePlayer\logs\crash.log
|
||||||
|
echo dist\KiwySignagePlayer\logs\fatal_crash.log
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
PyInstaller Runtime Hook for Kiwy Signage Player
|
||||||
|
------------------------------------------------
|
||||||
|
Runs at startup of the packaged .exe to fix paths and environment.
|
||||||
|
Creates all necessary folders LOCAL to the executable's directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── IMPORTANT: Set Windows environment BEFORE any Kivy code runs ──
|
||||||
|
# This must happen before main.py's top-level code executes, because
|
||||||
|
# main.py sets SDL_VIDEODRIVER=wayland,x11,dummy which would crash on Windows.
|
||||||
|
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||||
|
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||||
|
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||||
|
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||||
|
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||||
|
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||||
|
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||||
|
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||||
|
os.environ['KIVY_NO_FILELOG'] = '1'
|
||||||
|
os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect on Windows
|
||||||
|
|
||||||
|
# ── Capture ALL early output to a crash log ─────────────────────────
|
||||||
|
# Ensure we catch any exception that happens before Logger is available.
|
||||||
|
_startup_log_path = None
|
||||||
|
try:
|
||||||
|
_exe_dir = Path(sys.executable).parent
|
||||||
|
_startup_log_path = _exe_dir / 'logs' / 'startup_crash.log'
|
||||||
|
(_startup_log_path.parent).mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(_startup_log_path, 'w') as _f:
|
||||||
|
_f.write("pyi_runtime_hook.py started\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_paths():
|
||||||
|
"""Ensure the app can find its bundled files at runtime.
|
||||||
|
|
||||||
|
All data folders (config, media, playlists, logs) are created
|
||||||
|
LOCAL to the executable's directory — NOT in %%APPDATA%%.
|
||||||
|
"""
|
||||||
|
# In PyInstaller, sys.executable is the .exe path.
|
||||||
|
# sys._MEIPASS is the extraction directory (i.e. _internal/ folder).
|
||||||
|
exe_dir = Path(sys.executable).parent
|
||||||
|
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||||
|
|
||||||
|
# ── Change cwd to _internal so Builder.load_file('signage_player.kv')
|
||||||
|
# and other relative file references from main.py resolve ─────
|
||||||
|
os.chdir(str(internal_dir))
|
||||||
|
|
||||||
|
# Add bundled src directory to Python path
|
||||||
|
src_dir = str(internal_dir / 'src')
|
||||||
|
if os.path.isdir(src_dir) and src_dir not in sys.path:
|
||||||
|
sys.path.insert(0, src_dir)
|
||||||
|
|
||||||
|
# Add internal directory for config/media/playlists access
|
||||||
|
if str(internal_dir) not in sys.path:
|
||||||
|
sys.path.insert(0, str(internal_dir))
|
||||||
|
|
||||||
|
# ── Local folders next to the .exe ──────────────────────────────
|
||||||
|
# All data lives in the SAME folder as the executable so the user
|
||||||
|
# can copy/move the whole directory and everything still works.
|
||||||
|
os.environ['KIWY_DATA_DIR'] = str(exe_dir)
|
||||||
|
|
||||||
|
# Set KIVY_HOME to a local .kivy folder next to the .exe
|
||||||
|
kivy_home = exe_dir / '.kivy'
|
||||||
|
os.environ.setdefault('KIVY_HOME', str(kivy_home))
|
||||||
|
kivy_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create local data folders next to the .exe
|
||||||
|
for sub in ['config', 'config/resources', 'media', 'playlists', 'logs']:
|
||||||
|
(exe_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_bundled_resources():
|
||||||
|
"""Copy bundled resource/config files to the local folders on first run."""
|
||||||
|
exe_dir = Path(sys.executable).parent
|
||||||
|
internal_dir = Path(getattr(sys, '_MEIPASS', exe_dir))
|
||||||
|
|
||||||
|
# Files to copy (source in bundle -> destination next to .exe)
|
||||||
|
files_to_copy = [
|
||||||
|
('config/app_config.json', 'config/app_config.json'),
|
||||||
|
('config/resources/access-card.png', 'config/resources/access-card.png'),
|
||||||
|
('config/resources/arrow.png', 'config/resources/arrow.png'),
|
||||||
|
('config/resources/backward.png', 'config/resources/backward.png'),
|
||||||
|
('config/resources/card-checked.png', 'config/resources/card-checked.png'),
|
||||||
|
('config/resources/edit-pen.png', 'config/resources/edit-pen.png'),
|
||||||
|
('config/resources/exit.png', 'config/resources/exit.png'),
|
||||||
|
('config/resources/forward.png', 'config/resources/forward.png'),
|
||||||
|
('config/resources/intro1.mp4', 'config/resources/intro1.mp4'),
|
||||||
|
('config/resources/pause.png', 'config/resources/pause.png'),
|
||||||
|
('config/resources/pencil.png', 'config/resources/pencil.png'),
|
||||||
|
('config/resources/play.png', 'config/resources/play.png'),
|
||||||
|
('config/resources/settings.png', 'config/resources/settings.png'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for src_rel, dest_rel in files_to_copy:
|
||||||
|
src_path = internal_dir / src_rel
|
||||||
|
dest_path = exe_dir / dest_rel
|
||||||
|
if src_path.is_file() and not dest_path.exists():
|
||||||
|
try:
|
||||||
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(str(src_path), str(dest_path))
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical; app can still run
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wrap everything in try/except to capture early crashes ──────────
|
||||||
|
try:
|
||||||
|
_setup_paths()
|
||||||
|
_copy_bundled_resources()
|
||||||
|
# If we reach here, the hook finished successfully
|
||||||
|
try:
|
||||||
|
with open(_startup_log_path, 'a') as _f:
|
||||||
|
_f.write("pyi_runtime_hook.py completed successfully\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as _hook_exc:
|
||||||
|
import traceback as _tb
|
||||||
|
try:
|
||||||
|
with open(_startup_log_path, 'a') as _f:
|
||||||
|
_f.write(f"pyi_runtime_hook.py CRASHED: {_hook_exc}\n")
|
||||||
|
_tb.print_exc(file=_f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise # Re-raise so the .exe still fails visibly
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# =====================================================================
|
||||||
|
# Kiwy Signage Player - Windows Dependencies
|
||||||
|
# =====================================================================
|
||||||
|
# Install with: pip install -r requirements_win.txt
|
||||||
|
|
||||||
|
# --- Core GUI Framework ---
|
||||||
|
# Kivy 2.3+ with SDL2 backend (best for Windows)
|
||||||
|
kivy[base]>=2.3.0
|
||||||
|
|
||||||
|
# --- Video Playback ---
|
||||||
|
# ffpyplayer for video decoding
|
||||||
|
ffpyplayer>=4.5
|
||||||
|
|
||||||
|
# --- HTTP / Networking ---
|
||||||
|
requests>=2.32.0,<3.0.0
|
||||||
|
aiohttp>=3.9.0,<4.0.0
|
||||||
|
certifi>=2024.0.0
|
||||||
|
|
||||||
|
# --- Password / Auth ---
|
||||||
|
bcrypt>=4.2.0,<5.0.0
|
||||||
|
|
||||||
|
# --- Packaging ---
|
||||||
|
# PyInstaller for building the .exe
|
||||||
|
pyinstaller>=6.0
|
||||||
|
|
||||||
|
# --- Windows-specific Libraries ---
|
||||||
|
# cefpython3: Embedded Chromium browser (replaces subprocess Chrome/Edge)
|
||||||
|
# Installed separately because it's a large package (69 MB):
|
||||||
|
# pip install cefpython3
|
||||||
|
# cefpython3>=66.1
|
||||||
|
# Note: Uncomment above line to bundle cefpython3 in the .exe.
|
||||||
|
# Without it, weblinks fall back to subprocess Chrome/Edge.
|
||||||
|
|
||||||
|
# pywin32: Windows API bindings (win32gui for SetForegroundWindow etc.)
|
||||||
|
# Already installed as a dependency of kivy[base]
|
||||||
|
|
||||||
|
# --- Optional: DirectShow filters for better video on Windows ---
|
||||||
|
# ffmpeg (install via chocolatey or manual download)
|
||||||
|
# https://ffmpeg.org/download.html
|
||||||
+1469
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
"""Test that setting env vars before importing main.py fixes the crash."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# This is the KEY fix: set Windows env vars BEFORE main.py is imported
|
||||||
|
os.environ['SDL_VIDEODRIVER'] = 'windows'
|
||||||
|
os.environ['SDL_AUDIODRIVER'] = 'directsound'
|
||||||
|
os.environ['KIVY_WINDOW'] = 'sdl2'
|
||||||
|
# Use 'angle_sdl2' on Windows for better DirectX compatibility
|
||||||
|
os.environ['KIVY_GL_BACKEND'] = 'angle_sdl2'
|
||||||
|
# Let Kivy auto-detect input providers on Windows
|
||||||
|
os.environ['KIVY_INPUTPROVIDERS'] = ''
|
||||||
|
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||||
|
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||||
|
os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||||
|
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, r'C:\Users\Dell-PC\Desktop\Kiwy-Signage\src')
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Testing main.py import with Windows env vars...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import main
|
||||||
|
print("SUCCESS: main.py imported without crashing!")
|
||||||
|
print(f" SDL_VIDEODRIVER = {os.environ.get('SDL_VIDEODRIVER')}")
|
||||||
|
print(f" KIVY_WINDOW = {os.environ.get('KIVY_WINDOW')}")
|
||||||
|
print(f" KIVY_GL_BACKEND = {os.environ.get('KIVY_GL_BACKEND')}")
|
||||||
|
print(f" KIVY_INPUTPROVIDERS = {os.environ.get('KIVY_INPUTPROVIDERS')}")
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"FAILED: SystemExit({e}) - Kivy window provider still not loading")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAILED with exception: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# UTF-8
|
||||||
|
#
|
||||||
|
# Windows version resource for KiwySignagePlayer.exe
|
||||||
|
# This file is used by PyInstaller (version=) to embed publisher/product
|
||||||
|
# metadata into the executable so Windows Smart App Control / SmartScreen
|
||||||
|
# can identify the app instead of flagging it as "Unknown publisher".
|
||||||
|
#
|
||||||
|
# Note: A code-signing certificate is still required for a fully trusted
|
||||||
|
# publisher name; this metadata at least names the product/company and
|
||||||
|
# supplies a version number.
|
||||||
|
#
|
||||||
|
VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(
|
||||||
|
filevers=(1, 2, 0, 0),
|
||||||
|
prodvers=(1, 2, 0, 0),
|
||||||
|
mask=0x3f,
|
||||||
|
flags=0x0,
|
||||||
|
OS=0x40004,
|
||||||
|
fileType=0x1,
|
||||||
|
subtype=0x0,
|
||||||
|
date=(0, 0)
|
||||||
|
),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo(
|
||||||
|
[
|
||||||
|
StringTable(
|
||||||
|
'040904B0',
|
||||||
|
[
|
||||||
|
StringStruct('CompanyName', 'Kiwy Signage'),
|
||||||
|
StringStruct('FileDescription', 'Kiwy Signage Player - Digital Signage Player'),
|
||||||
|
StringStruct('FileVersion', '1.2.0.0'),
|
||||||
|
StringStruct('InternalName', 'KiwySignagePlayer'),
|
||||||
|
StringStruct('LegalCopyright', 'Copyright (c) 2026 Kiwy Signage'),
|
||||||
|
StringStruct('OriginalFilename', 'KiwySignagePlayer.exe'),
|
||||||
|
StringStruct('ProductName', 'Kiwy Signage Player'),
|
||||||
|
StringStruct('ProductVersion', '1.2.0.0'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
VarFileInfo([VarStruct('Translation', [1033, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
+2
-2
@@ -75,7 +75,7 @@ If no card is swiped (timeout), `user_card_data` will be `null`.
|
|||||||
A test utility is provided to verify card reader functionality:
|
A test utility is provided to verify card reader functionality:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/pi/Desktop/Kiwy-Signage/dev/tests
|
cd /home/pi/Desktop/Kiwy-Signage/working_files
|
||||||
python3 test_card_reader.py
|
python3 test_card_reader.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -178,5 +178,5 @@ Potential improvements for the card reader system:
|
|||||||
|
|
||||||
- `/src/main.py` - Main implementation (CardReader class, authentication flow)
|
- `/src/main.py` - Main implementation (CardReader class, authentication flow)
|
||||||
- `/src/edit_drowing.py` - Drawing/editing interface (uses authenticated user)
|
- `/src/edit_drowing.py` - Drawing/editing interface (uses authenticated user)
|
||||||
- `/dev/tests/test_card_reader.py` - Card reader test utility
|
- `/working_files/test_card_reader.py` - Card reader test utility
|
||||||
- `/requirements.txt` - Dependencies (includes evdev)
|
- `/requirements.txt` - Dependencies (includes evdev)
|
||||||
@@ -54,7 +54,7 @@ The system now logs:
|
|||||||
Run the enhanced test script to identify your card reader:
|
Run the enhanced test script to identify your card reader:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/pi/Desktop/Kiwy-Signage/dev/tests
|
cd /home/pi/Desktop/Kiwy-Signage/working_files
|
||||||
python3 test_card_reader.py
|
python3 test_card_reader.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ sudo usermod -a -G input $USER
|
|||||||
- Implemented priority-based search
|
- Implemented priority-based search
|
||||||
- Enhanced logging
|
- Enhanced logging
|
||||||
|
|
||||||
2. **dev/tests/test_card_reader.py**
|
2. **working_files/test_card_reader.py**
|
||||||
- Updated `list_input_devices()` to show device classifications
|
- Updated `list_input_devices()` to show device classifications
|
||||||
- Updated `test_card_reader()` to use same logic as main app
|
- Updated `test_card_reader()` to use same logic as main app
|
||||||
- Added visual indicators for device types
|
- Added visual indicators for device types
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
INFO ] [Kivy ] Installed at "/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/kivy/__init__.py"
|
||||||
|
[INFO ] [Python ] v3.13.5 (main, Jun 25 2025, 18:55:22) [GCC 14.2.0]
|
||||||
|
[INFO ] [Python ] Interpreter at "/home/pi/Desktop/Kiwy-Signage/.venv/bin/python3"
|
||||||
|
[INFO ] [Logger ] Purge log fired. Processing...
|
||||||
|
[INFO ] [Logger ] Purge finished!
|
||||||
|
[DEBUG ] [Using selector] EpollSelector
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
|
||||||
|
[ERROR ] [Image ] Error loading </home/pi/Desktop/Kiwy-Signage/config/resources/intro1.mp4>
|
||||||
|
[WARNING] ⚠️ SSL verification disabled - NOT recommended for production!
|
||||||
|
[DEBUG ] [Starting new HTTPS connection (1)] 192.168.0.121:443
|
||||||
|
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||||
|
warnings.warn(
|
||||||
|
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/auth/verify HTTP/1.1" 200 None
|
||||||
|
[INFO ] ✅ Auth code verified
|
||||||
|
[INFO ] ✅ Using existing authentication
|
||||||
|
[INFO ] [Fetching playlist from] https://192.168.0.121:443/api/playlists/1
|
||||||
|
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||||
|
warnings.warn(
|
||||||
|
[DEBUG ] [https ]//192.168.0.121:443 "GET /api/playlists/1 HTTP/1.1" 200 None
|
||||||
|
[INFO ] [✅ Playlist received (version] 34)
|
||||||
|
[INFO ] [📊 Playlist versions - Server] v34, Local: v34
|
||||||
|
[INFO ] ✓ Playlist is up to date
|
||||||
|
[WARNING] Deprecated property "<BooleanProperty name=allow_stretch>" of object "<kivy.uix.image.AsyncImage object at 0x7fa5f79ef0>" has been set, it will be removed in a future version
|
||||||
|
[WARNING] Deprecated property "<BooleanProperty name=keep_ratio>" of object "<kivy.uix.image.AsyncImage object at 0x7fa5f79ef0>" was accessed, it will be removed in a future version
|
||||||
|
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||||
|
warnings.warn(
|
||||||
|
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/auth/verify HTTP/1.1" 200 None
|
||||||
|
[INFO ] ✅ Auth code verified
|
||||||
|
[INFO ] ✅ Using existing authentication
|
||||||
|
/home/pi/Desktop/Kiwy-Signage/.venv/lib/python3.13/site-packages/urllib3/connectionpool.py:1097: InsecureRequestWarning: Unverified HTTPS request is being made to host '192.168.0.121'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
|
||||||
|
warnings.warn(
|
||||||
|
[DEBUG ] [https ]//192.168.0.121:443 "POST /api/player-feedback HTTP/1.1" 200 None
|
||||||
|
^C[2026-01-17 22:09:12] 🛑 Watchdog received stop signal
|
||||||
|
pi@rpi-tvcanba1:~/Desktop/Kiwy-Signage $
|
||||||
|
|
||||||
+6
-7
@@ -16,9 +16,8 @@ Kiwy-Signage/
|
|||||||
│ │ ├── apt-packages.txt # List of required apt packages
|
│ │ ├── apt-packages.txt # List of required apt packages
|
||||||
│ │ └── debs/ # Downloaded .deb files (optional)
|
│ │ └── debs/ # Downloaded .deb files (optional)
|
||||||
│ └── README.md
|
│ └── README.md
|
||||||
├── scripts/
|
├── download_offline_packages.sh # Download Python packages
|
||||||
│ ├── download_offline_packages.sh # Download Python packages
|
├── download_deb_packages.sh # Download system .deb packages
|
||||||
│ └── download_deb_packages.sh # Download system .deb packages
|
|
||||||
└── install.sh # Smart installer (online/offline)
|
└── install.sh # Smart installer (online/offline)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,10 +33,10 @@ git clone <repository-url>
|
|||||||
cd Kiwy-Signage
|
cd Kiwy-Signage
|
||||||
|
|
||||||
# Download Python packages
|
# Download Python packages
|
||||||
bash scripts/download_offline_packages.sh
|
bash download_offline_packages.sh
|
||||||
|
|
||||||
# (Optional) Download system .deb packages
|
# (Optional) Download system .deb packages
|
||||||
bash scripts/download_deb_packages.sh
|
bash download_deb_packages.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will populate the `repo/` folder with all necessary packages.
|
This will populate the `repo/` folder with all necessary packages.
|
||||||
@@ -105,7 +104,7 @@ To update the offline package cache:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On a connected system
|
# On a connected system
|
||||||
bash scripts/download_offline_packages.sh
|
bash download_offline_packages.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will download the latest versions of all packages.
|
This will download the latest versions of all packages.
|
||||||
@@ -118,7 +117,7 @@ If installation fails due to missing dependencies:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download .deb packages with dependencies
|
# Download .deb packages with dependencies
|
||||||
bash scripts/download_deb_packages.sh
|
bash download_deb_packages.sh
|
||||||
|
|
||||||
# Install with dependency resolution
|
# Install with dependency resolution
|
||||||
sudo apt install -f
|
sudo apt install -f
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Download Python packages (required)
|
# 1. Download Python packages (required)
|
||||||
bash scripts/download_offline_packages.sh
|
bash download_offline_packages.sh
|
||||||
|
|
||||||
# 2. Download system .deb packages (optional, for fully offline)
|
# 2. Download system .deb packages (optional, for fully offline)
|
||||||
bash scripts/download_deb_packages.sh
|
bash download_deb_packages.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## For Offline System (Installation)
|
## For Offline System (Installation)
|
||||||
@@ -22,13 +22,12 @@ bash install.sh --offline
|
|||||||
|
|
||||||
## What's Included
|
## What's Included
|
||||||
|
|
||||||
### Python Packages
|
### Python Packages (18 wheels)
|
||||||
✅ Kivy 2.3.1
|
✅ Kivy 2.1.0
|
||||||
✅ Requests 2.34.2
|
✅ Requests 2.32.4
|
||||||
✅ Bcrypt 4.2.1
|
✅ Bcrypt 4.2.1
|
||||||
✅ Aiohttp 3.11.16 (async HTTP)
|
✅ Aiohttp 3.9.1 (async HTTP)
|
||||||
✅ ffpyplayer 4.5.3 (video)
|
✅ Asyncio 3.4.3 (async framework)
|
||||||
✅ evdev 1.9.1 (card reader)
|
|
||||||
✅ All dependencies
|
✅ All dependencies
|
||||||
|
|
||||||
### System Packages
|
### System Packages
|
||||||
@@ -6,8 +6,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
SYSTEM_DIR="$SCRIPT_DIR/repo/system-packages"
|
||||||
SYSTEM_DIR="$PROJECT_ROOT/repo/system-packages"
|
|
||||||
DEB_DIR="$SYSTEM_DIR/debs"
|
DEB_DIR="$SYSTEM_DIR/debs"
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
@@ -6,8 +6,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_DIR="$SCRIPT_DIR/repo"
|
||||||
REPO_DIR="$PROJECT_ROOT/repo"
|
|
||||||
WHEELS_DIR="$REPO_DIR/python-wheels"
|
WHEELS_DIR="$REPO_DIR/python-wheels"
|
||||||
SYSTEM_DIR="$REPO_DIR/system-packages"
|
SYSTEM_DIR="$REPO_DIR/system-packages"
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ fi
|
|||||||
# Download Python packages
|
# Download Python packages
|
||||||
echo "Step 1: Downloading Python wheels..."
|
echo "Step 1: Downloading Python wheels..."
|
||||||
echo "--------------------"
|
echo "--------------------"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# Check if pip is installed
|
# Check if pip is installed
|
||||||
if ! command -v pip3 &> /dev/null; then
|
if ! command -v pip3 &> /dev/null; then
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"hostname": "tv-terasa",
|
||||||
|
"auth_code": "iiSyZDLWGyqNIxeRt54XYREgvAio11RwwU1_oJev6WI",
|
||||||
|
"player_id": 1,
|
||||||
|
"player_name": "TV-acasa 1",
|
||||||
|
"playlist_id": 1,
|
||||||
|
"orientation": "Landscape",
|
||||||
|
"authenticated": true,
|
||||||
|
"server_url": "http://digi-signage.moto-adv.com"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user