Compare commits
7 Commits
abbd51a787
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b889be11 | |||
| 39565c5761 | |||
| fb9f46a94f | |||
| 9d8eb0cf21 | |||
| 64e49886df | |||
| f30f97683b | |||
| 609d9169d2 |
@@ -1 +0,0 @@
|
|||||||
1768682062.8464386
|
|
||||||
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"
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/bin/bash
|
#\!/bin/bash
|
||||||
# Wait for desktop environment to be ready
|
|
||||||
sleep 15
|
sleep 15
|
||||||
|
cd /home/pi/kiwy-signage && bash start.sh
|
||||||
# Start the player
|
|
||||||
cd "/home/pi/Desktop/Kiwy-Signage" && bash start.sh
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"server_ip": "192.168.0.121",
|
"server_ip": "192.168.0.109",
|
||||||
"port": "443",
|
"port": "8080",
|
||||||
"screen_name": "rpi-tvcanba1",
|
"screen_name": "Birou_IT",
|
||||||
"quickconnect_key": "8887779",
|
"quickconnect_key": "8887779",
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"touch": "True",
|
"touch": "True",
|
||||||
"max_resolution": "1920x1080",
|
"max_resolution": "1920x1080",
|
||||||
"edit_feature_enabled": true,
|
"edit_feature_enabled": true,
|
||||||
"use_https": true,
|
"use_https": false,
|
||||||
"verify_ssl": false
|
"verify_ssl": false
|
||||||
}
|
}
|
||||||
Regular → Executable
+583
-555
File diff suppressed because it is too large
Load Diff
Executable
+1056
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"hostname": "rpi-tvcanba1",
|
|
||||||
"auth_code": "LhfERILw4cFxejhbIUuQ72QddisRgHMAm7kUSty64LA",
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "TVacasa",
|
|
||||||
"playlist_id": 1,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"authenticated": true,
|
|
||||||
"server_url": "https://192.168.0.121:443"
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,37 @@
|
|||||||
{
|
{
|
||||||
"count": 3,
|
"count": 4,
|
||||||
"player_id": 1,
|
"player_id": 1,
|
||||||
"player_name": "TVacasa",
|
"player_name": "Test_player1",
|
||||||
"playlist": [
|
"playlist": [
|
||||||
{
|
{
|
||||||
"file_name": "2026efvev-1428673176.jpg",
|
"file_name": "sean-oulashin-KMn4VEeEPR8-unsplash.jpg",
|
||||||
"url": "media/2026efvev-1428673176.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,
|
"duration": 50,
|
||||||
"edit_on_player": true
|
"edit_on_player": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "4k1.jpg",
|
|
||||||
"url": "media/4k1.jpg",
|
|
||||||
"duration": 30,
|
|
||||||
"edit_on_player": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_name": "1416529-hd_1920_1080_30fps.mp4",
|
|
||||||
"url": "media/1416529-hd_1920_1080_30fps.mp4",
|
|
||||||
"duration": 13,
|
|
||||||
"edit_on_player": false
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"playlist_id": 1,
|
"playlist_id": 1,
|
||||||
"playlist_version": 34
|
"playlist_version": 14
|
||||||
}
|
}
|
||||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
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.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
-4
@@ -1,7 +1,6 @@
|
|||||||
kivy>=2.3.0
|
kivy>=2.3.0
|
||||||
ffpyplayer
|
ffpyplayer
|
||||||
requests==2.32.4
|
requests>=2.32.0,<3.0.0
|
||||||
bcrypt==4.2.1
|
bcrypt>=4.2.0,<5.0.0
|
||||||
aiohttp==3.9.1
|
aiohttp>=3.9.0,<4.0.0
|
||||||
asyncio==3.4.3
|
|
||||||
evdev>=1.6.0
|
evdev>=1.6.0
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Start Kivy Signage Player
|
# Start Kivy Signage Player
|
||||||
cd "$(dirname "$0")" && source .venv/bin/activate
|
|
||||||
cd "$(dirname "$0")/src"
|
cd "$(dirname "$0")/src"
|
||||||
python3 main.py
|
python3 main.py
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MARKER_FILE="/etc/kiwy-signage/pi_boot_config_patched.marker"
|
||||||
|
|
||||||
|
is_pi() {
|
||||||
|
if [[ -f /proc/device-tree/model ]] && grep -qi "raspberry pi" /proc/device-tree/model; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qi "raspberry" /proc/cpuinfo 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
find_boot_config() {
|
||||||
|
if [[ -f /boot/firmware/config.txt ]]; then
|
||||||
|
echo "/boot/firmware/config.txt"
|
||||||
|
elif [[ -f /boot/config.txt ]]; then
|
||||||
|
echo "/boot/config.txt"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert_key() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local file="$3"
|
||||||
|
|
||||||
|
if grep -Eq "^[#[:space:]]*${key}=" "$file"; then
|
||||||
|
sed -i -E "s|^[#[:space:]]*${key}=.*|${key}=${value}|" "$file"
|
||||||
|
else
|
||||||
|
echo "${key}=${value}" >> "$file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
echo "[pi-boot-patch] Please run as root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_pi; then
|
||||||
|
echo "[pi-boot-patch] Not a Raspberry Pi. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$MARKER_FILE" ]]; then
|
||||||
|
echo "[pi-boot-patch] Patch already applied. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
BOOT_CONFIG="$(find_boot_config)"
|
||||||
|
if [[ -z "$BOOT_CONFIG" ]]; then
|
||||||
|
echo "[pi-boot-patch] Boot config not found. Skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[pi-boot-patch] Applying patch to ${BOOT_CONFIG}"
|
||||||
|
|
||||||
|
# Graphics and memory settings for smoother Chromium playback on Pi.
|
||||||
|
upsert_key "dtoverlay" "vc4-kms-v3d" "$BOOT_CONFIG"
|
||||||
|
upsert_key "gpu_mem" "256" "$BOOT_CONFIG"
|
||||||
|
upsert_key "disable_overscan" "1" "$BOOT_CONFIG"
|
||||||
|
|
||||||
|
# Keep HDMI and display active for kiosk use.
|
||||||
|
upsert_key "hdmi_blanking" "0" "$BOOT_CONFIG"
|
||||||
|
upsert_key "hdmi_ignore_cec_init" "1" "$BOOT_CONFIG"
|
||||||
|
upsert_key "hdmi_force_hotplug" "1" "$BOOT_CONFIG"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$MARKER_FILE")"
|
||||||
|
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$MARKER_FILE"
|
||||||
|
|
||||||
|
echo "[pi-boot-patch] Patch complete. Marker written to ${MARKER_FILE}"
|
||||||
@@ -74,7 +74,7 @@ def ensure_authenticated(config):
|
|||||||
# Use HTTPS for IP addresses
|
# Use HTTPS for IP addresses
|
||||||
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
|
server_url = f'https://{server_ip}:{port}' if port else f'https://{server_ip}'
|
||||||
else:
|
else:
|
||||||
server_url = f'http://{server_ip}:{port}'
|
server_url = f'http://{server_ip}:{port}' if port else f'http://{server_ip}'
|
||||||
else:
|
else:
|
||||||
# For domain names, use HTTPS by default
|
# For domain names, use HTTPS by default
|
||||||
if use_https:
|
if use_https:
|
||||||
|
|||||||
+357
-11
@@ -3,6 +3,8 @@ Kivy Signage Player - Main Application
|
|||||||
Displays content from DigiServer playlists using Kivy framework
|
Displays content from DigiServer playlists using Kivy framework
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
PLAYER_VERSION = "1.2.0"
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import platform
|
import platform
|
||||||
@@ -640,6 +642,7 @@ class SettingsPopup(Popup):
|
|||||||
|
|
||||||
# Populate current values
|
# Populate current values
|
||||||
self.ids.server_input.text = self.player.config.get('server_ip', 'localhost')
|
self.ids.server_input.text = self.player.config.get('server_ip', 'localhost')
|
||||||
|
self.ids.port_input.text = str(self.player.config.get('port', ''))
|
||||||
self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player')
|
self.ids.screen_input.text = self.player.config.get('screen_name', 'kivy-player')
|
||||||
self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567')
|
self.ids.quickconnect_input.text = self.player.config.get('quickconnect_key', '1234567')
|
||||||
self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape')
|
self.ids.orientation_input.text = self.player.config.get('orientation', 'Landscape')
|
||||||
@@ -723,7 +726,7 @@ class SettingsPopup(Popup):
|
|||||||
server_ip = self.ids.server_input.text.strip()
|
server_ip = self.ids.server_input.text.strip()
|
||||||
screen_name = self.ids.screen_input.text.strip()
|
screen_name = self.ids.screen_input.text.strip()
|
||||||
quickconnect = self.ids.quickconnect_input.text.strip()
|
quickconnect = self.ids.quickconnect_input.text.strip()
|
||||||
port = self.player.config.get('port', '443')
|
port = self.ids.port_input.text.strip() or self.player.config.get('port', '')
|
||||||
use_https = self.player.config.get('use_https', True)
|
use_https = self.player.config.get('use_https', True)
|
||||||
verify_ssl = self.player.config.get('verify_ssl', True)
|
verify_ssl = self.player.config.get('verify_ssl', True)
|
||||||
|
|
||||||
@@ -741,7 +744,11 @@ class SettingsPopup(Popup):
|
|||||||
server_url = f"{server_ip}:{port}"
|
server_url = f"{server_ip}:{port}"
|
||||||
else:
|
else:
|
||||||
protocol = "https" if use_https else "http"
|
protocol = "https" if use_https else "http"
|
||||||
server_url = f"{protocol}://{server_ip}:{port}"
|
if ':' in server_ip:
|
||||||
|
# server_ip already contains a port (e.g. 192.168.0.230:8080)
|
||||||
|
server_url = f"{protocol}://{server_ip}"
|
||||||
|
else:
|
||||||
|
server_url = f"{protocol}://{server_ip}:{port}" if port else f"{protocol}://{server_ip}"
|
||||||
|
|
||||||
Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})")
|
Logger.info(f"SettingsPopup: Testing connection to {server_url} (HTTPS: {use_https}, Verify SSL: {verify_ssl})")
|
||||||
|
|
||||||
@@ -875,6 +882,7 @@ class SettingsPopup(Popup):
|
|||||||
"""Save configuration and close popup"""
|
"""Save configuration and close popup"""
|
||||||
# Update config
|
# Update config
|
||||||
self.player.config['server_ip'] = self.ids.server_input.text
|
self.player.config['server_ip'] = self.ids.server_input.text
|
||||||
|
self.player.config['port'] = self.ids.port_input.text.strip()
|
||||||
self.player.config['screen_name'] = self.ids.screen_input.text
|
self.player.config['screen_name'] = self.ids.screen_input.text
|
||||||
self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text
|
self.player.config['quickconnect_key'] = self.ids.quickconnect_input.text
|
||||||
self.player.config['orientation'] = self.ids.orientation_input.text
|
self.player.config['orientation'] = self.ids.orientation_input.text
|
||||||
@@ -913,6 +921,9 @@ class SignagePlayer(Widget):
|
|||||||
self.current_index = 0
|
self.current_index = 0
|
||||||
self.current_widget = None
|
self.current_widget = None
|
||||||
self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items
|
self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items
|
||||||
|
self._weblink_preload_proc = None # Hidden Chromium pre-warming the next weblink URL
|
||||||
|
self._watchdog_stop = None # threading.Event to stop the inactivity watchdog
|
||||||
|
self._weblink_watchdog_thread = None # Background thread monitoring touch inactivity
|
||||||
self.is_playing = False
|
self.is_playing = False
|
||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
self.auto_resume_event = None # Track scheduled auto-resume
|
self.auto_resume_event = None # Track scheduled auto-resume
|
||||||
@@ -1271,11 +1282,13 @@ class SignagePlayer(Widget):
|
|||||||
self.current_index = 0
|
self.current_index = 0
|
||||||
self.play_current_media()
|
self.play_current_media()
|
||||||
|
|
||||||
def play_current_media(self, force_reload=False):
|
def play_current_media(self, force_reload=False, _after_weblink=False):
|
||||||
"""Play the current media item
|
"""Play the current media item
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force_reload: If True, clears image cache before loading (for edited images)
|
force_reload: If True, clears image cache before loading (for edited images)
|
||||||
|
_after_weblink: Internal flag — True when called after the weblink
|
||||||
|
dismiss delay so we skip the delay logic a second time.
|
||||||
"""
|
"""
|
||||||
# Don't play if paused (unless we're explicitly resuming)
|
# Don't play if paused (unless we're explicitly resuming)
|
||||||
if self.is_paused:
|
if self.is_paused:
|
||||||
@@ -1294,14 +1307,71 @@ class SignagePlayer(Widget):
|
|||||||
|
|
||||||
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)")
|
||||||
|
|
||||||
# Close any kiosk browser left over from a previous web-link item
|
# ── Weblink → media transition (desktop-flash safe) ──────────────
|
||||||
self._kill_weblink_process()
|
# For web→media transitions, render the next Kivy widget first,
|
||||||
|
# then close Chromium on the next frame. This prevents a brief
|
||||||
|
# desktop exposure while the compositor removes Chromium.
|
||||||
|
if not _after_weblink:
|
||||||
|
proc = getattr(self, '_weblink_proc', None)
|
||||||
|
if proc is not None:
|
||||||
|
next_is_weblink = media_item.get('type') == 'weblink'
|
||||||
|
if next_is_weblink:
|
||||||
|
# Weblink -> weblink: close current Chromium first.
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
self._weblink_proc = None
|
||||||
|
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception as exc:
|
||||||
|
Logger.warning(f"SignagePlayer: weblink terminate: {exc}")
|
||||||
|
|
||||||
|
def _resume(dt):
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
Window.show()
|
||||||
|
Window.raise_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.play_current_media(
|
||||||
|
force_reload=force_reload, _after_weblink=True
|
||||||
|
)
|
||||||
|
|
||||||
|
Clock.schedule_once(_resume, 0.2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Weblink -> non-weblink: keep Chromium visible while we
|
||||||
|
# prepare next Kivy frame, then close Chromium deferred.
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
self._weblink_proc = proc
|
||||||
|
try:
|
||||||
|
self.ids.content_area.opacity = 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
Window.show()
|
||||||
|
Window.raise_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
Logger.debug("SignagePlayer: Deferred Chromium close after next frame render")
|
||||||
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Handle web links before any file/path handling (no local file exists)
|
# Handle web links before any file/path handling (no local file exists)
|
||||||
if media_item.get('type') == 'weblink':
|
if media_item.get('type') == 'weblink':
|
||||||
Logger.debug("SignagePlayer: Media type: WEBLINK")
|
Logger.debug("SignagePlayer: Media type: WEBLINK")
|
||||||
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
|
||||||
|
try:
|
||||||
|
self.ids.content_area.opacity = 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
started = self.play_weblink(media_item.get('url', ''), duration)
|
started = self.play_weblink(media_item.get('url', ''), duration)
|
||||||
if started:
|
if started:
|
||||||
self.consecutive_errors = 0
|
self.consecutive_errors = 0
|
||||||
@@ -1370,6 +1440,11 @@ class SignagePlayer(Widget):
|
|||||||
self.consecutive_errors = 0
|
self.consecutive_errors = 0
|
||||||
Logger.debug(f"SignagePlayer: Media started successfully")
|
Logger.debug(f"SignagePlayer: Media started successfully")
|
||||||
|
|
||||||
|
# If we arrived here from a weblink item, close Chromium after the
|
||||||
|
# next Kivy frame so the new widget is already visible underneath.
|
||||||
|
if media_item.get('type') != 'weblink' and getattr(self, '_weblink_proc', None) is not None:
|
||||||
|
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}")
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
@@ -1531,11 +1606,24 @@ class SignagePlayer(Widget):
|
|||||||
self._skip_to_next_media()
|
self._skip_to_next_media()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
target_width, target_height = self._get_browser_target_size()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Logger.info(f"SignagePlayer: Opening weblink in kiosk browser for {duration}s: {url}")
|
Logger.info(f"SignagePlayer: Opening weblink in kiosk browser: {url}")
|
||||||
|
Logger.info(f"SignagePlayer: Inactivity timeout set to {duration}s (touch resets the countdown)")
|
||||||
|
Logger.info(f"SignagePlayer: Chromium target launch size: {target_width}x{target_height}")
|
||||||
|
# Kill the hidden pre-warm instance first; its work (binary in RAM,
|
||||||
|
# page in disk cache) makes the kiosk relaunch below near-instant.
|
||||||
|
self._kill_weblink_preload()
|
||||||
self._weblink_proc = subprocess.Popen([
|
self._weblink_proc = subprocess.Popen([
|
||||||
browser,
|
browser,
|
||||||
'--kiosk',
|
# Use --start-fullscreen instead of --kiosk.
|
||||||
|
# --kiosk requests Wayland's exclusive-fullscreen protocol which
|
||||||
|
# prevents Labwc from restoring the previous window on close.
|
||||||
|
# --start-fullscreen is a normal maximised window that the
|
||||||
|
# compositor can un-stack without issues.
|
||||||
|
'--start-fullscreen',
|
||||||
|
'--start-maximized',
|
||||||
'--app=' + url,
|
'--app=' + url,
|
||||||
'--noerrdialogs',
|
'--noerrdialogs',
|
||||||
'--disable-infobars',
|
'--disable-infobars',
|
||||||
@@ -1543,25 +1631,203 @@ class SignagePlayer(Widget):
|
|||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--disable-session-crashed-bubble',
|
'--disable-session-crashed-bubble',
|
||||||
'--check-for-update-interval=31536000',
|
'--check-for-update-interval=31536000',
|
||||||
|
# Suppress the GNOME keyring / wallet unlock popup
|
||||||
|
'--password-store=basic',
|
||||||
|
'--use-mock-keychain',
|
||||||
|
# Prevent any other credential / sync dialogs
|
||||||
|
'--disable-sync',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--window-position=0,0',
|
||||||
|
f'--window-size={target_width},{target_height}',
|
||||||
|
'--force-device-scale-factor=1',
|
||||||
])
|
])
|
||||||
|
|
||||||
# Advance after the configured duration. The kiosk browser is closed
|
# Unschedule any previous fixed timer — the watchdog thread takes
|
||||||
# at the start of the next play_current_media() via _kill_weblink_process().
|
# over and only advances after 'duration' seconds of NO touch activity.
|
||||||
Clock.unschedule(self.next_media)
|
Clock.unschedule(self.next_media)
|
||||||
Clock.schedule_once(self.next_media, duration)
|
self._start_inactivity_watchdog(duration)
|
||||||
|
|
||||||
# Preload the next image so the transition after the weblink is smooth.
|
# Preload the next image so the transition after the weblink is smooth.
|
||||||
self.preload_next_media()
|
self.preload_next_media()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.error(f"SignagePlayer: Error opening weblink {url}: {e}")
|
Logger.error(f"SignagePlayer: Error opening weblink {url}: {e}")
|
||||||
|
# Restore content area if launch failed
|
||||||
|
try:
|
||||||
|
self.ids.content_area.opacity = 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.consecutive_errors += 1
|
self.consecutive_errors += 1
|
||||||
self._weblink_proc = None
|
self._weblink_proc = None
|
||||||
self._skip_to_next_media()
|
self._skip_to_next_media()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _get_browser_target_size(self):
|
||||||
|
"""Return a stable browser launch size to avoid 800x600 first-paint flash."""
|
||||||
|
default_width, default_height = 1920, 1080
|
||||||
|
try:
|
||||||
|
width, height = Window.size
|
||||||
|
width = int(width)
|
||||||
|
height = int(height)
|
||||||
|
except Exception:
|
||||||
|
return default_width, default_height
|
||||||
|
|
||||||
|
# Chromium sometimes first-paints at 800x600 before fullscreen;
|
||||||
|
# force at least Full HD when reported dimensions are too small.
|
||||||
|
if width < 1280 or height < 720:
|
||||||
|
return default_width, default_height
|
||||||
|
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
def _start_inactivity_watchdog(self, duration):
|
||||||
|
"""Start a background thread that monitors /dev/input/* for touch/key
|
||||||
|
activity. The thread resets the idle counter on every event; when the
|
||||||
|
screen has been idle for *duration* seconds it fires next_media().
|
||||||
|
Works even while Chromium owns the display (raw device reads bypass the
|
||||||
|
window-manager focus).
|
||||||
|
Also fires immediately if Chromium exits on its own (e.g. user closes it).
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import select
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Stop any previous watchdog cleanly before starting a new one.
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
self._watchdog_stop = stop_event
|
||||||
|
weblink_proc = self._weblink_proc # snapshot so thread sees the right process
|
||||||
|
|
||||||
|
def watchdog():
|
||||||
|
# Open every available input event device (touchscreen, mouse, kbd).
|
||||||
|
devices = []
|
||||||
|
for path in sorted(glob.glob('/dev/input/event*')):
|
||||||
|
try:
|
||||||
|
devices.append(open(path, 'rb')) # noqa: WPS515
|
||||||
|
except (PermissionError, OSError) as exc:
|
||||||
|
Logger.debug(f"SignagePlayer: Watchdog cannot open {path}: {exc}")
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
# No input devices accessible → fall back to a plain fixed timer.
|
||||||
|
Logger.warning(
|
||||||
|
"SignagePlayer: Watchdog — no /dev/input devices accessible; "
|
||||||
|
"falling back to fixed timer"
|
||||||
|
)
|
||||||
|
stop_event.wait(timeout=duration)
|
||||||
|
if not stop_event.is_set():
|
||||||
|
Clock.schedule_once(self.next_media, 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
f"SignagePlayer: Watchdog watching {len(devices)} input device(s), "
|
||||||
|
f"idle threshold = {duration}s"
|
||||||
|
)
|
||||||
|
last_activity = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
# If Chromium exited on its own (user closed it), advance immediately.
|
||||||
|
if weblink_proc is not None and weblink_proc.poll() is not None:
|
||||||
|
Logger.info(
|
||||||
|
"SignagePlayer: Chromium exited — advancing to next media"
|
||||||
|
)
|
||||||
|
Clock.schedule_once(self.next_media, 0)
|
||||||
|
break
|
||||||
|
|
||||||
|
idle = time.monotonic() - last_activity
|
||||||
|
if idle >= duration:
|
||||||
|
Logger.info(
|
||||||
|
f"SignagePlayer: No touch for {duration}s — advancing to next media"
|
||||||
|
)
|
||||||
|
Clock.schedule_once(self.next_media, 0)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Wait up to 0.5 s for any raw input event.
|
||||||
|
timeout = min(0.5, duration - idle)
|
||||||
|
readable, _, _ = select.select(devices, [], [], timeout)
|
||||||
|
|
||||||
|
if readable:
|
||||||
|
# Drain the data so the buffer doesn't fill up.
|
||||||
|
for fd in readable:
|
||||||
|
try:
|
||||||
|
fd.read(24) # struct input_event = 24 bytes on 64-bit Linux
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
last_activity = time.monotonic()
|
||||||
|
Logger.debug(
|
||||||
|
"SignagePlayer: Touch/key detected — inactivity timer reset"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for fd in devices:
|
||||||
|
try:
|
||||||
|
fd.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._weblink_watchdog_thread = threading.Thread(
|
||||||
|
target=watchdog, daemon=True, name='weblink-watchdog'
|
||||||
|
)
|
||||||
|
self._weblink_watchdog_thread.start()
|
||||||
|
|
||||||
|
def _stop_inactivity_watchdog(self):
|
||||||
|
"""Signal the watchdog thread to exit without firing next_media."""
|
||||||
|
stop_event = getattr(self, '_watchdog_stop', None)
|
||||||
|
if stop_event is not None:
|
||||||
|
stop_event.set()
|
||||||
|
self._watchdog_stop = None
|
||||||
|
self._weblink_watchdog_thread = None
|
||||||
|
|
||||||
|
def _kill_weblink_after_frame(self):
|
||||||
|
"""Gracefully transition away from a weblink item.
|
||||||
|
|
||||||
|
Stops the watchdog and preload immediately (so nothing fires a spurious
|
||||||
|
next_media), clears self._weblink_proc so the slot is free for the next
|
||||||
|
item, then schedules the actual Chromium termination for the *next Kivy
|
||||||
|
frame*. By that time Kivy has already rendered the new media widget
|
||||||
|
underneath Chromium, so when the browser window disappears the player
|
||||||
|
content is instantly visible — no black flash.
|
||||||
|
"""
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
|
||||||
|
proc = self._weblink_proc # snapshot
|
||||||
|
self._weblink_proc = None # free the slot right away
|
||||||
|
|
||||||
|
if proc is None or proc.poll() is not None:
|
||||||
|
# Nothing to kill — still raise the window in case it got buried.
|
||||||
|
try:
|
||||||
|
Window.raise_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
def _do_kill(dt):
|
||||||
|
if proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
Logger.debug("SignagePlayer: Closed weblink kiosk browser (deferred)")
|
||||||
|
except Exception as exc:
|
||||||
|
Logger.warning(f"SignagePlayer: Error in deferred weblink kill: {exc}")
|
||||||
|
# Bring Kivy window to front now that Chromium is gone.
|
||||||
|
try:
|
||||||
|
Window.raise_window()
|
||||||
|
except Exception as exc:
|
||||||
|
Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}")
|
||||||
|
|
||||||
|
Clock.schedule_once(_do_kill, 0)
|
||||||
|
|
||||||
def _kill_weblink_process(self):
|
def _kill_weblink_process(self):
|
||||||
"""Terminate the kiosk browser process if one is running."""
|
"""Terminate the kiosk browser, inactivity watchdog, and any pre-warm process."""
|
||||||
|
# Stop watchdog first so it cannot fire next_media after we've moved on.
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
|
||||||
proc = getattr(self, '_weblink_proc', None)
|
proc = getattr(self, '_weblink_proc', None)
|
||||||
if proc is not None and proc.poll() is None:
|
if proc is not None and proc.poll() is None:
|
||||||
try:
|
try:
|
||||||
@@ -1575,6 +1841,14 @@ class SignagePlayer(Widget):
|
|||||||
Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}")
|
Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}")
|
||||||
self._weblink_proc = None
|
self._weblink_proc = None
|
||||||
|
|
||||||
|
# Raise the Kivy window back to the front — when Chromium ran in kiosk
|
||||||
|
# mode it covered the Kivy window entirely; the window manager won't
|
||||||
|
# automatically bring it back on all compositors/WMs.
|
||||||
|
try:
|
||||||
|
Window.raise_window()
|
||||||
|
except Exception as exc:
|
||||||
|
Logger.debug(f"SignagePlayer: raise_window failed (non-fatal): {exc}")
|
||||||
|
|
||||||
def _skip_to_next_media(self):
|
def _skip_to_next_media(self):
|
||||||
"""Advance past a failed item WITHOUT recursing.
|
"""Advance past a failed item WITHOUT recursing.
|
||||||
|
|
||||||
@@ -1623,6 +1897,73 @@ class SignagePlayer(Widget):
|
|||||||
Clock.unschedule(self.next_media)
|
Clock.unschedule(self.next_media)
|
||||||
self.play_current_media()
|
self.play_current_media()
|
||||||
|
|
||||||
|
def _prewarm_weblink(self, url):
|
||||||
|
"""Launch Chromium off-screen to warm up the binary and page cache.
|
||||||
|
|
||||||
|
The window is placed far outside the visible area so the user never
|
||||||
|
sees it. When play_weblink() fires for real it kills this hidden
|
||||||
|
instance first, then relaunches in kiosk mode. Because the Chromium
|
||||||
|
binary is already resident in RAM and the page is in the disk cache,
|
||||||
|
the visible kiosk window appears almost immediately.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
scheme = urlparse(url).scheme.lower()
|
||||||
|
if scheme not in ('http', 'https'):
|
||||||
|
return
|
||||||
|
|
||||||
|
browser = shutil.which('chromium-browser') or shutil.which('chromium')
|
||||||
|
if not browser:
|
||||||
|
return
|
||||||
|
|
||||||
|
target_width, target_height = self._get_browser_target_size()
|
||||||
|
|
||||||
|
# Kill any stale preload first.
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
|
||||||
|
try:
|
||||||
|
Logger.debug(f"SignagePlayer: Pre-warming weblink off-screen: {url}")
|
||||||
|
self._weblink_preload_proc = subprocess.Popen([
|
||||||
|
browser,
|
||||||
|
'--app=' + url,
|
||||||
|
# Place window completely outside the visible display area.
|
||||||
|
'--window-position=-9999,-9999',
|
||||||
|
f'--window-size={target_width},{target_height}', # pre-render at target resolution
|
||||||
|
'--noerrdialogs',
|
||||||
|
'--disable-infobars',
|
||||||
|
'--incognito',
|
||||||
|
'--no-first-run',
|
||||||
|
'--disable-session-crashed-bubble',
|
||||||
|
'--check-for-update-interval=31536000',
|
||||||
|
'--password-store=basic',
|
||||||
|
'--use-mock-keychain',
|
||||||
|
'--disable-sync',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
])
|
||||||
|
except Exception as exc:
|
||||||
|
Logger.debug(f"SignagePlayer: Pre-warm launch failed (non-fatal): {exc}")
|
||||||
|
self._weblink_preload_proc = None
|
||||||
|
|
||||||
|
def _kill_weblink_preload(self):
|
||||||
|
"""Terminate the hidden pre-warm Chromium process if running."""
|
||||||
|
proc = getattr(self, '_weblink_preload_proc', None)
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
Logger.debug("SignagePlayer: Killed weblink pre-warm process")
|
||||||
|
except Exception as exc:
|
||||||
|
Logger.debug(f"SignagePlayer: Error killing pre-warm process: {exc}")
|
||||||
|
self._weblink_preload_proc = None
|
||||||
|
|
||||||
def preload_next_media(self):
|
def preload_next_media(self):
|
||||||
"""Preload the next media item asynchronously to improve transition smoothness
|
"""Preload the next media item asynchronously to improve transition smoothness
|
||||||
|
|
||||||
@@ -1662,6 +2003,11 @@ class SignagePlayer(Widget):
|
|||||||
Logger.debug(f"SignagePlayer: Preloading next image: {file_name}")
|
Logger.debug(f"SignagePlayer: Preloading next image: {file_name}")
|
||||||
Loader.image(media_path)
|
Loader.image(media_path)
|
||||||
|
|
||||||
|
elif next_media_item.get('type') == 'weblink':
|
||||||
|
# Pre-warm Chromium in a hidden off-screen window so the binary
|
||||||
|
# and page content are in OS cache before the slot arrives.
|
||||||
|
self._prewarm_weblink(next_media_item.get('url', ''))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
Logger.debug(f"SignagePlayer: Error preloading next media: {e}")
|
Logger.debug(f"SignagePlayer: Error preloading next media: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"hostname": "rpi-tvcanba1",
|
"hostname": "Birou_IT",
|
||||||
"auth_code": "LhfERILw4cFxejhbIUuQ72QddisRgHMAm7kUSty64LA",
|
"auth_code": "CZncd_2dlTZGieBEdqAbUTjf3qNyEUPDXr8jLVx7NLs",
|
||||||
"player_id": 1,
|
"player_id": 1,
|
||||||
"player_name": "TVacasa",
|
"player_name": "Test_player1",
|
||||||
"playlist_id": 1,
|
"playlist_id": 1,
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"authenticated": true,
|
"authenticated": true,
|
||||||
"server_url": "https://192.168.0.121:443"
|
"server_url": "http://192.168.0.109:8080"
|
||||||
}
|
}
|
||||||
@@ -382,6 +382,30 @@
|
|||||||
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
|
||||||
|
|
||||||
|
# Server port
|
||||||
|
BoxLayout:
|
||||||
|
orientation: 'horizontal'
|
||||||
|
size_hint_y: None
|
||||||
|
height: dp(40)
|
||||||
|
spacing: dp(10)
|
||||||
|
|
||||||
|
Label:
|
||||||
|
text: 'Port:'
|
||||||
|
size_hint_x: 0.3
|
||||||
|
text_size: self.size
|
||||||
|
halign: 'left'
|
||||||
|
valign: 'middle'
|
||||||
|
|
||||||
|
TextInput:
|
||||||
|
id: port_input
|
||||||
|
size_hint_x: 0.7
|
||||||
|
multiline: False
|
||||||
|
font_size: sp(14)
|
||||||
|
hint_text: '80 or 8080 (leave empty for default)'
|
||||||
|
input_filter: 'int'
|
||||||
|
write_tab: False
|
||||||
|
on_touch_down: root.on_input_touch(self, args[1]) if self.collide_point(*args[1].pos) else None
|
||||||
|
|
||||||
# Screen name
|
# Screen name
|
||||||
BoxLayout:
|
BoxLayout:
|
||||||
orientation: 'horizontal'
|
orientation: 'horizontal'
|
||||||
|
|||||||
@@ -218,19 +218,6 @@ rm -f "$STOP_FLAG_FILE"
|
|||||||
# Change to the project directory
|
# Change to the project directory
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
# Check if virtual environment exists
|
|
||||||
if [ -d ".venv" ]; then
|
|
||||||
log_message "✓ Virtual environment found"
|
|
||||||
source .venv/bin/activate
|
|
||||||
else
|
|
||||||
log_message "⚠️ Creating virtual environment..."
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
log_message "📦 Installing dependencies..."
|
|
||||||
pip3 install -r requirements.txt
|
|
||||||
log_message "✓ Virtual environment ready"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if configuration exists
|
# Check if configuration exists
|
||||||
if [ ! -f "config/app_config.json" ]; then
|
if [ ! -f "config/app_config.json" ]; then
|
||||||
log_message "⚠️ WARNING: Configuration file not found!"
|
log_message "⚠️ WARNING: Configuration file not found!"
|
||||||
@@ -250,6 +237,11 @@ while true; do
|
|||||||
# Clean old heartbeat
|
# Clean old heartbeat
|
||||||
rm -f "$HEARTBEAT_FILE"
|
rm -f "$HEARTBEAT_FILE"
|
||||||
|
|
||||||
|
# Activate virtual environment if it exists
|
||||||
|
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||||
|
source "$SCRIPT_DIR/.venv/bin/activate"
|
||||||
|
fi
|
||||||
|
|
||||||
# Start the player
|
# Start the player
|
||||||
cd "$SCRIPT_DIR/src"
|
cd "$SCRIPT_DIR/src"
|
||||||
python3 main.py &
|
python3 main.py &
|
||||||
@@ -292,8 +284,8 @@ while true; do
|
|||||||
log_message "⏳ Waiting ${RESTART_DELAY}s before restart..."
|
log_message "⏳ Waiting ${RESTART_DELAY}s before restart..."
|
||||||
sleep $RESTART_DELAY
|
sleep $RESTART_DELAY
|
||||||
|
|
||||||
# Cleanup any zombie processes
|
# Ensure old player process is gone before restarting
|
||||||
pkill -9 -f "python3 main.py" 2>/dev/null
|
kill -9 $PLAYER_PID 2>/dev/null
|
||||||
|
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -301,6 +293,3 @@ log_message ""
|
|||||||
log_message "=========================================="
|
log_message "=========================================="
|
||||||
log_message "Watchdog stopped"
|
log_message "Watchdog stopped"
|
||||||
log_message "=========================================="
|
log_message "=========================================="
|
||||||
|
|
||||||
# Deactivate virtual environment (this line is never reached in watchdog mode)
|
|
||||||
deactivate
|
|
||||||
|
|||||||
Reference in New Issue
Block a user