Compare commits
11 Commits
main
..
d0ea94447a
| Author | SHA1 | Date | |
|---|---|---|---|
| d0ea94447a | |||
| 12f2880201 | |||
| 5a030671a2 | |||
| a2add88f04 | |||
| c4e8381898 | |||
| ced6e10919 | |||
| 844e5eeebb | |||
| 7efc023327 | |||
| 6abde5a767 | |||
| 362f5096a0 | |||
| 3845830a86 |
@@ -25,6 +25,7 @@ wheels/
|
|||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
env/
|
env/
|
||||||
|
windows/venv312/
|
||||||
|
|
||||||
# Kivy
|
# Kivy
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -59,3 +60,5 @@ playlists/server_playlist_*.json
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
.player_heartbear
|
.player_heartbear
|
||||||
|
|
||||||
|
windows/venv_build/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"server_ip": "192.168.0.109",
|
"server_ip": "192.168.0.107",
|
||||||
"port": "8080",
|
"port": "8080",
|
||||||
"screen_name": "Birou_IT",
|
"screen_name": "WINDOWS-PC",
|
||||||
"quickconnect_key": "8887779",
|
"quickconnect_key": "8887779",
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"touch": "True",
|
"touch": "True",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Python 3.12.9
|
||||||
+15
-1
@@ -354,7 +354,9 @@ def delete_unused_media(playlist_data, media_dir):
|
|||||||
rel_path = os.path.relpath(full_path, media_dir)
|
rel_path = os.path.relpath(full_path, media_dir)
|
||||||
|
|
||||||
# Skip if file is in current playlist
|
# Skip if file is in current playlist
|
||||||
if rel_path in referenced_files:
|
# Normalize paths to handle Windows backslashes vs server forward slashes
|
||||||
|
normalized_rel = rel_path.replace('\\', '/')
|
||||||
|
if normalized_rel in referenced_files or rel_path in referenced_files:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Delete unreferenced file
|
# Delete unreferenced file
|
||||||
@@ -454,6 +456,18 @@ def update_playlist_if_needed(config, playlist_dir, media_dir):
|
|||||||
return playlist_file
|
return playlist_file
|
||||||
else:
|
else:
|
||||||
logger.info("✓ Playlist is up to date")
|
logger.info("✓ Playlist is up to date")
|
||||||
|
# Even when the playlist version matches, ensure media files exist locally.
|
||||||
|
# The media folder might be empty (e.g. fresh install or deleted files).
|
||||||
|
logger.info("📥 Checking for missing media files...")
|
||||||
|
ssl_manager = auth.ssl_manager if config.get('use_https', True) else None
|
||||||
|
server_url = auth.auth_data.get('server_url', '')
|
||||||
|
downloaded = download_media_files(
|
||||||
|
server_data.get('playlist', []), media_dir, ssl_manager, server_url
|
||||||
|
)
|
||||||
|
if downloaded:
|
||||||
|
server_data['playlist'] = downloaded
|
||||||
|
# Re-save playlist with updated URLs if needed
|
||||||
|
save_playlist(server_data, playlist_dir)
|
||||||
return playlist_file
|
return playlist_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+31
-16
@@ -14,21 +14,21 @@ 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
|
||||||
@@ -86,8 +86,12 @@ 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
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -1295,7 +1299,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
|
||||||
@@ -1514,6 +1522,9 @@ class SignagePlayer(Widget):
|
|||||||
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"""
|
||||||
Logger.debug("SignagePlayer: Video finished playing (EOS)")
|
Logger.debug("SignagePlayer: Video finished playing (EOS)")
|
||||||
|
# Unschedule any pending timer and advance to next media
|
||||||
|
Clock.unschedule(self.next_media)
|
||||||
|
Clock.schedule_once(self.next_media, 0.5)
|
||||||
|
|
||||||
def _on_video_loaded(self, instance, value):
|
def _on_video_loaded(self, instance, value):
|
||||||
"""Callback when video is loaded - log video information"""
|
"""Callback when video is loaded - log video information"""
|
||||||
@@ -2064,6 +2075,10 @@ class SignagePlayer(Widget):
|
|||||||
|
|
||||||
def restart_playlist(self):
|
def restart_playlist(self):
|
||||||
"""Restart playlist from beginning"""
|
"""Restart playlist from beginning"""
|
||||||
|
if not self.playlist:
|
||||||
|
Logger.warning("SignagePlayer: Cannot restart - playlist is empty")
|
||||||
|
return
|
||||||
|
|
||||||
Logger.info("SignagePlayer: Restarting playlist")
|
Logger.info("SignagePlayer: Restarting playlist")
|
||||||
|
|
||||||
# Send restart feedback asynchronously (non-blocking)
|
# Send restart feedback asynchronously (non-blocking)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"hostname": "Birou_IT",
|
"hostname": "WINDOWS-PC",
|
||||||
"auth_code": "CZncd_2dlTZGieBEdqAbUTjf3qNyEUPDXr8jLVx7NLs",
|
"auth_code": "",
|
||||||
"player_id": 1,
|
"player_id": 2,
|
||||||
"player_name": "Test_player1",
|
"player_name": "Windows-Player1",
|
||||||
"playlist_id": 1,
|
"playlist_id": 1,
|
||||||
"orientation": "Landscape",
|
"orientation": "Landscape",
|
||||||
"authenticated": true,
|
"authenticated": true,
|
||||||
"server_url": "http://192.168.0.109:8080"
|
"server_url": "http://192.168.0.107:8080"
|
||||||
}
|
}
|
||||||
+62
-49
@@ -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,56 +541,61 @@
|
|||||||
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 Button
|
||||||
@@ -591,8 +603,9 @@
|
|||||||
id: test_connection_btn
|
id: test_connection_btn
|
||||||
text: 'Test Server Connection'
|
text: 'Test Server Connection'
|
||||||
size_hint_y: None
|
size_hint_y: None
|
||||||
height: dp(50)
|
height: dp(44)
|
||||||
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()
|
||||||
|
|
||||||
# Connection Status Label
|
# Connection Status Label
|
||||||
@@ -600,21 +613,24 @@
|
|||||||
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 +638,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 +646,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 +654,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'
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# -*- 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 _find_share_dlls(package_path, subdir='bin'):
|
||||||
|
"""Find .dll files under a package's share/ directory."""
|
||||||
|
if not package_path:
|
||||||
|
return []
|
||||||
|
base = _Path(package_path).parent
|
||||||
|
# Check: share/<pkg>/bin/ relative to parent
|
||||||
|
share = base / 'share'
|
||||||
|
if share.is_dir():
|
||||||
|
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
|
||||||
|
return []
|
||||||
|
|
||||||
|
# SDL2 DLLs
|
||||||
|
_sdl2_spec = importlib.util.find_spec('kivy_deps.sdl2')
|
||||||
|
_sdl2_dlls = _find_share_dlls(_sdl2_spec.origin if _sdl2_spec else None)
|
||||||
|
|
||||||
|
# ANGLE DLLs
|
||||||
|
_angle_spec = importlib.util.find_spec('kivy_deps.angle')
|
||||||
|
_angle_dlls = _find_share_dlls(_angle_spec.origin if _angle_spec else None)
|
||||||
|
|
||||||
|
# GLEW DLLs
|
||||||
|
_glew_spec = importlib.util.find_spec('kivy_deps.glew')
|
||||||
|
_glew_dlls = _find_share_dlls(_glew_spec.origin if _glew_spec else None)
|
||||||
|
|
||||||
|
# ffpyplayer FFmpeg DLLs
|
||||||
|
_ffpy_spec = importlib.util.find_spec('ffpyplayer')
|
||||||
|
_ffpy_dlls = _find_share_dlls(_ffpy_spec.origin if _ffpy_spec else None)
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|
||||||
|
# --- 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(RESOURCES_DIR / 'app_icon.ico') if (RESOURCES_DIR / 'app_icon.ico').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,186 @@
|
|||||||
|
# 🧪 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-24
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Branch** | `Windows-Player` |
|
||||||
|
| **Python** | 3.12.9 — `C:\Users\Dell-PC\AppData\Local\Programs\Python\Python312\python.exe` |
|
||||||
|
| **Venv** | `windows\venv312\` (pre-built, all deps installed) |
|
||||||
|
| **Kivy** | 2.3.1 |
|
||||||
|
| **PyInstaller** | 6.21.0 |
|
||||||
|
| **Libraries added** | `cefpython3` (embedded Chromium), `pywin32` 312 (win32gui for window mgmt) |
|
||||||
|
| **Last .exe build** | 2026-07-24 13:47 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||||
|
| **Build command** | `Set-Location windows; venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||||
|
|
||||||
|
### ⚠️ Python Version Constraints
|
||||||
|
- **Python 3.12.9** — ✅ Confirmed working. Has pre-built Kivy 2.3.1 wheels.
|
||||||
|
- **Python 3.13** — ❌ Kivy wheels NOT available for Windows.
|
||||||
|
- **Python 3.14** — ❌ Tested 2026-07-24. `kivy_deps.sdl2_dev~=0.8.0` has no cp314 wheel.
|
||||||
|
→ Solution: removed Python 3.14 from system, keeping only 3.12.9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Bug Tracker
|
||||||
|
|
||||||
|
### [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:** 🔧 **Fix in progress** 2026-07-24
|
||||||
|
- **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.
|
||||||
|
- **Fix applied (2026-07-24):**
|
||||||
|
1. **`_bring_kivy_to_front()`** — uses `win32gui.SetForegroundWindow(hwnd)`
|
||||||
|
to reliably bring Kivy/SDL window to front (replaces `raise_window`)
|
||||||
|
2. **`_windows_kill_weblink_after_frame()`** — kills Chrome IMMEDIATELY
|
||||||
|
(not deferred one frame later) before next media starts
|
||||||
|
3. **CEF browser** (`cefpython3`) — embedded Chromium widget replaces
|
||||||
|
subprocess entirely. No process management, no z-order fights.
|
||||||
|
- **Files:** `windows/run_win.py`, `windows/cef_browser.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 project root or windows/)
|
||||||
|
Set-Location windows
|
||||||
|
& .\venv312\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm
|
||||||
|
|
||||||
|
# Run in dev mode (no build needed)
|
||||||
|
& .\venv312\Scripts\python.exe run_win.py
|
||||||
|
|
||||||
|
# Test imports only
|
||||||
|
& .\venv312\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
|
||||||
|
- [ ] Verify CEF embedded browser actually works at runtime
|
||||||
|
- [ ] Test the subprocess fallback path when CEF is unavailable
|
||||||
|
- [ ] Check why `AsyncImage` error shows for intro1.mp4 (path issue)
|
||||||
|
- [ ] Ensure media files are downloaded before playback
|
||||||
|
- [ ] Consider adding a startup `.bat` file that users can double-click
|
||||||
|
- [ ] Test card reader fallback behaviour (evdev not available)
|
||||||
|
- [ ] Add `cef_browser.py` to PyInstaller hidden imports in `build.spec`
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
"""
|
||||||
|
Kiwy Signage Player - Windows Entry Point
|
||||||
|
------------------------------------------
|
||||||
|
Patches platform-specific code and environment for Windows before launching
|
||||||
|
the original Kivy-based signage player application.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python run_win.py (for development/testing)
|
||||||
|
run_win.exe (after PyInstaller build)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _show_error_box(title, message):
|
||||||
|
"""Show a Windows message box with the error (visible even without console)."""
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.MessageBoxW(0, message, title, 0x10) # MB_ICONERROR
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Ensure we are on Windows; warn if not ---
|
||||||
|
if platform.system() != 'Windows':
|
||||||
|
print(f"WARNING: This entry point is designed for Windows. Detected: {platform.system()}")
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 1. Set Windows-compatible environment variables BEFORE Kivy imports
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# Video driver: Use 'windib' or 'angle' (DirectX via ANGLE) for Windows
|
||||||
|
os.environ.setdefault('SDL_VIDEODRIVER', 'windows')
|
||||||
|
# Audio driver: DirectSound for Windows
|
||||||
|
os.environ.setdefault('SDL_AUDIODRIVER', 'directsound')
|
||||||
|
# Prevent screensaver
|
||||||
|
os.environ.setdefault('SDL_VIDEO_ALLOW_SCREENSAVER', '0')
|
||||||
|
# Video backend via ffpyplayer
|
||||||
|
os.environ.setdefault('KIVY_VIDEO', 'ffpyplayer')
|
||||||
|
os.environ.setdefault('KIVY_AUDIO', 'ffpyplayer')
|
||||||
|
os.environ.setdefault('FFPYPLAYER_CODECS', 'h264,h265,vp9,vp8')
|
||||||
|
# Kivy window backend: prefer SDL2 on Windows
|
||||||
|
os.environ.setdefault('KIVY_WINDOW', 'sdl2')
|
||||||
|
# OpenGL
|
||||||
|
os.environ.setdefault('KIVY_GL_BACKEND', 'angle_sdl2')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 2. Patch the evdev import — it is Linux-only. We provide a dummy
|
||||||
|
# module so that `from evdev import ...` will not crash on Windows.
|
||||||
|
# =====================================================================
|
||||||
|
class _DummyEvdev:
|
||||||
|
"""Fake evdev module that raises ImportError for all meaningful uses."""
|
||||||
|
|
||||||
|
class InputDevice:
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
raise ImportError("evdev is not available on Windows")
|
||||||
|
|
||||||
|
class ecodes:
|
||||||
|
EV_KEY = 1
|
||||||
|
EV_ABS = 3
|
||||||
|
|
||||||
|
def categorize(self, *a, **kw):
|
||||||
|
raise ImportError("evdev is not available on Windows")
|
||||||
|
|
||||||
|
def list_devices(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeEvdevInputDevice:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Inject the fake evdev module into sys.modules so that main.py's
|
||||||
|
# `try: import evdev` succeeds but EVDEV_AVAILABLE stays False.
|
||||||
|
_evdev_dummy = _DummyEvdev()
|
||||||
|
sys.modules['evdev'] = _evdev_dummy
|
||||||
|
sys.modules['evdev.InputDevice'] = _FakeEvdevInputDevice
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 3. Provide a Windows implementation of screen activity signaling
|
||||||
|
# We monkey-patch the SignagePlayer.signal_screen_activity method
|
||||||
|
# after the class is defined but before it's used, by hooking into
|
||||||
|
# the import machinery.
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# We'll store a reference to the original module's signal_screen_activity
|
||||||
|
# so we can replace it after import. This is done inside _patch_main().
|
||||||
|
|
||||||
|
def _windows_screen_activity(self, dt):
|
||||||
|
"""Windows alternative to Linux screen-keep-awake commands.
|
||||||
|
|
||||||
|
Uses SetThreadExecutionState via ctypes to tell Windows to keep
|
||||||
|
the display and system awake.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED | ES_CONTINUOUS
|
||||||
|
ES_CONTINUOUS = 0x80000000
|
||||||
|
ES_SYSTEM_REQUIRED = 0x00000001
|
||||||
|
ES_DISPLAY_REQUIRED = 0x00000002
|
||||||
|
|
||||||
|
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||||
|
ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # non-critical
|
||||||
|
|
||||||
|
|
||||||
|
# ── Try to import the embedded CEF browser ──────────────────────────
|
||||||
|
_CEF_BROWSER = None
|
||||||
|
|
||||||
|
def _get_cef_browser():
|
||||||
|
"""Return the shared CefBrowser singleton, or None if unavailable.
|
||||||
|
|
||||||
|
v2 embeds CEF as a CHILD WINDOW inside Kivy's SDL_app window.
|
||||||
|
This means: no separate taskbar entry, no z-order fighting,
|
||||||
|
no desktop flash, no taskkill needed.
|
||||||
|
"""
|
||||||
|
global _CEF_BROWSER
|
||||||
|
if _CEF_BROWSER is None:
|
||||||
|
try:
|
||||||
|
from cef_browser import CefBrowser, CEF_AVAILABLE
|
||||||
|
if CEF_AVAILABLE:
|
||||||
|
_CEF_BROWSER = CefBrowser()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return _CEF_BROWSER
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_find_browser():
|
||||||
|
"""Find Chrome or Edge executable on Windows for weblink support.
|
||||||
|
|
||||||
|
Returns the path to the browser or None.
|
||||||
|
"""
|
||||||
|
# Common install locations
|
||||||
|
candidates = [
|
||||||
|
# Chrome
|
||||||
|
os.path.expandvars(r'%PROGRAMFILES%\Google\Chrome\Application\chrome.exe'),
|
||||||
|
os.path.expandvars(r'%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe'),
|
||||||
|
os.path.expandvars(r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'),
|
||||||
|
# Edge
|
||||||
|
os.path.expandvars(r'%PROGRAMFILES%\Microsoft\Edge\Application\msedge.exe'),
|
||||||
|
os.path.expandvars(r'%PROGRAMFILES(X86)%\Microsoft\Edge\Application\msedge.exe'),
|
||||||
|
os.path.expandvars(r'%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe'),
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Fallback: try PATH
|
||||||
|
which = shutil.which('chrome') or shutil.which('msedge') or shutil.which('google-chrome')
|
||||||
|
if which:
|
||||||
|
return which
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Win32 API helpers via ctypes ─────────────────────────────────────
|
||||||
|
class _Win32Overlay:
|
||||||
|
"""Fullscreen black overlay window to mask desktop during transitions.
|
||||||
|
|
||||||
|
When switching away from Chromium, the browser window disappears and
|
||||||
|
there is a brief moment where the desktop is visible before Kivy
|
||||||
|
manages to bring its window to the front. This overlay covers that
|
||||||
|
flash with a pure-black borderless always-on-top Win32 window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_hwnd = None
|
||||||
|
_class_atom = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def show(cls):
|
||||||
|
"""Create a fullscreen black overlay on top of everything."""
|
||||||
|
if cls._hwnd is not None:
|
||||||
|
return # already showing
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
|
||||||
|
# Register a simple window class
|
||||||
|
WNDPROC = ctypes.WINFUNCTYPE(
|
||||||
|
ctypes.c_int64, ctypes.c_int64, ctypes.c_uint,
|
||||||
|
ctypes.c_uint64, ctypes.c_int64
|
||||||
|
)
|
||||||
|
|
||||||
|
@WNDPROC
|
||||||
|
def wnd_proc(hwnd, msg, wparam, lparam):
|
||||||
|
if msg == 0x0002: # WM_DESTROY
|
||||||
|
user32.PostQuitMessage(0)
|
||||||
|
if msg == 0x0014: # WM_ERASEBKGND
|
||||||
|
return 1 # tell Windows we erased it
|
||||||
|
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||||
|
|
||||||
|
hinstance = kernel32.GetModuleHandleW(None)
|
||||||
|
|
||||||
|
# Register class
|
||||||
|
class_name = 'KiwyOverlay_' + str(ctypes.c_uint64(int(kernel32.GetTickCount64())).value)
|
||||||
|
wc = ctypes.create_unicode_buffer(256)
|
||||||
|
|
||||||
|
_WNDCLASS = ctypes.c_byte * (6 * 8) # rough size
|
||||||
|
buf = _WNDCLASS()
|
||||||
|
# Simple approach: use RegisterClassExW
|
||||||
|
user32.RegisterClassExW.restype = ctypes.c_uint16
|
||||||
|
user32.RegisterClassExW.argtypes = [ctypes.c_void_p]
|
||||||
|
|
||||||
|
# We'll use a simpler method: just create a MessageBox-style window
|
||||||
|
# Actually, let's use the simplest possible approach:
|
||||||
|
|
||||||
|
# Get screen dimensions
|
||||||
|
screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN
|
||||||
|
screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN
|
||||||
|
|
||||||
|
# Create a borderless always-on-top window
|
||||||
|
cls._hwnd = user32.CreateWindowExW(
|
||||||
|
0x00000008, # WS_EX_TOPMOST | WS_EX_TOOLWINDOW
|
||||||
|
b'#32770', # Dialog class - always available
|
||||||
|
b'', # no title
|
||||||
|
0x80000000 | 0x10000000, # WS_POPUP | WS_VISIBLE
|
||||||
|
0, 0, screen_w, screen_h,
|
||||||
|
0, 0, hinstance, 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if cls._hwnd:
|
||||||
|
# Make it black
|
||||||
|
from ctypes import wintypes
|
||||||
|
gdi32 = ctypes.windll.gdi32
|
||||||
|
hdc = user32.GetDC(cls._hwnd)
|
||||||
|
rect = (ctypes.c_long * 4)(0, 0, screen_w, screen_h)
|
||||||
|
brush = gdi32.CreateSolidBrush(0x00000000) # black brush
|
||||||
|
gdi32.FillRect(hdc, ctypes.byref(rect), brush)
|
||||||
|
gdi32.DeleteObject(brush)
|
||||||
|
user32.ReleaseDC(cls._hwnd, hdc)
|
||||||
|
|
||||||
|
# Force it to the top
|
||||||
|
user32.SetWindowPos(cls._hwnd, -1, 0, 0, screen_w, screen_h, 0x0002 | 0x0040)
|
||||||
|
user32.ShowWindow(cls._hwnd, 1) # SW_SHOWNORMAL
|
||||||
|
user32.UpdateWindow(cls._hwnd)
|
||||||
|
except Exception:
|
||||||
|
cls._hwnd = None # failed gracefully
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def hide(cls):
|
||||||
|
"""Destroy the overlay window."""
|
||||||
|
if cls._hwnd is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
user32.DestroyWindow(cls._hwnd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cls._hwnd = None
|
||||||
|
|
||||||
|
|
||||||
|
def _bring_kivy_to_front():
|
||||||
|
"""Bring the Kivy/SDL window to foreground using win32gui.
|
||||||
|
|
||||||
|
Unlike Window.raise_window(), win32gui.SetForegroundWindow
|
||||||
|
actually works reliably on Windows — it uses the same Win32
|
||||||
|
API that the Task Manager uses.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import win32gui
|
||||||
|
import win32con
|
||||||
|
|
||||||
|
def _enum_cb(hwnd, hwnd_list):
|
||||||
|
cls = win32gui.GetClassName(hwnd)
|
||||||
|
title = win32gui.GetWindowText(hwnd)
|
||||||
|
if cls == "SDL_app":
|
||||||
|
hwnd_list.append(hwnd)
|
||||||
|
elif "Kiwy" in title or "Signage" in title:
|
||||||
|
hwnd_list.append(hwnd)
|
||||||
|
|
||||||
|
hwnd_list = []
|
||||||
|
win32gui.EnumWindows(_enum_cb, hwnd_list)
|
||||||
|
|
||||||
|
if hwnd_list:
|
||||||
|
kivy_hwnd = hwnd_list[-1] # most recent
|
||||||
|
win32gui.ShowWindow(kivy_hwnd, win32con.SW_SHOWNORMAL)
|
||||||
|
win32gui.SetForegroundWindow(kivy_hwnd)
|
||||||
|
win32gui.BringWindowToTop(kivy_hwnd)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to Kivy's built-in raise
|
||||||
|
try:
|
||||||
|
from kivy.core.window import Window
|
||||||
|
Window.show()
|
||||||
|
Window.raise_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_kill_process_tree(proc):
|
||||||
|
"""Kill a process AND all its children using taskkill.
|
||||||
|
|
||||||
|
Chrome/Edge spawns many child processes (GPU, renderer, network,
|
||||||
|
etc.). A simple proc.terminate() leaves children running, causing
|
||||||
|
lingering browser windows or zombie processes.
|
||||||
|
"""
|
||||||
|
if proc is None or proc.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['taskkill', '/F', '/T', '/PID', str(proc.pid)],
|
||||||
|
capture_output=True, timeout=5
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Fallback: try terminate + kill
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
except Exception:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_main():
|
||||||
|
"""Patch the main module after import for Windows compatibility."""
|
||||||
|
# ── CRITICAL: Override Linux env vars BEFORE importing main ─────
|
||||||
|
# main.py's top-level code sets SDL_VIDEODRIVER=wayland,x11,dummy
|
||||||
|
# and other Linux values. We MUST override these before main.py
|
||||||
|
# gets imported, otherwise Kivy will initialize with the wrong
|
||||||
|
# window provider and crash with SystemExit(1).
|
||||||
|
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_INPUTPROVIDERS'] = '' # Let Kivy auto-detect
|
||||||
|
os.environ['KIVY_VIDEO'] = 'ffpyplayer'
|
||||||
|
os.environ['KIVY_AUDIO'] = 'ffpyplayer'
|
||||||
|
|
||||||
|
# Now safe to import main.py — env vars are already Windows-correct
|
||||||
|
import main as signage_main
|
||||||
|
|
||||||
|
# Replace signal_screen_activity
|
||||||
|
# IMPORTANT: Assign under BOTH the attribute name AND the function's own name.
|
||||||
|
# Kivy's WeakMethod stores self.__func__.__name__ (= '_windows_screen_activity')
|
||||||
|
# and later does getattr(instance, '_windows_screen_activity'). If we only
|
||||||
|
# assign under 'signal_screen_activity', the weakref lookup fails with
|
||||||
|
# AttributeError: 'SignagePlayer' object has no attribute '_windows_screen_activity'
|
||||||
|
signage_main.SignagePlayer.signal_screen_activity = _windows_screen_activity
|
||||||
|
signage_main.SignagePlayer._windows_screen_activity = _windows_screen_activity
|
||||||
|
|
||||||
|
# Store a reference to the original play_weblink so we can wrap it
|
||||||
|
_original_play_weblink = signage_main.SignagePlayer.play_weblink
|
||||||
|
|
||||||
|
def _windows_play_weblink(self, url, duration):
|
||||||
|
"""Windows-compatible weblink handler.
|
||||||
|
|
||||||
|
Strategy (tried in order):
|
||||||
|
1. CEF embedded browser (best — no subprocess, no z-order fights)
|
||||||
|
2. Chrome/Edge subprocess (fallback)
|
||||||
|
"""
|
||||||
|
from kivy.logger import Logger
|
||||||
|
from kivy.clock import Clock
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
scheme = urlparse(url).scheme.lower()
|
||||||
|
if scheme not in ('http', 'https'):
|
||||||
|
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
|
||||||
|
self.consecutive_errors += 1
|
||||||
|
self._skip_to_next_media()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Strategy 1: CEF embedded browser ────────────────────────
|
||||||
|
cef_browser = _get_cef_browser()
|
||||||
|
if cef_browser is not None:
|
||||||
|
Logger.info(f"SignagePlayer: Opening weblink via CEF (embedded in Kivy): {url}")
|
||||||
|
try:
|
||||||
|
self.ids.content_area.opacity = 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Attach resize handler so CEF follows Kivy window resizes
|
||||||
|
from kivy.core.window import Window as KivyWindow
|
||||||
|
_orig_on_resize = getattr(KivyWindow, '_on_resize', None)
|
||||||
|
def _cef_resize(*args):
|
||||||
|
try:
|
||||||
|
w, h = KivyWindow.size
|
||||||
|
cef_browser.resize(int(w), int(h))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _orig_on_resize:
|
||||||
|
try:
|
||||||
|
return _orig_on_resize(*args)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
KivyWindow._on_resize = _cef_resize
|
||||||
|
# Bind to size event as well
|
||||||
|
try:
|
||||||
|
KivyWindow.bind(size=_cef_resize)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cef_browser.show(url)
|
||||||
|
Clock.unschedule(self.next_media)
|
||||||
|
self._start_inactivity_watchdog(duration)
|
||||||
|
self.preload_next_media()
|
||||||
|
Logger.info("SignagePlayer: CEF embedded browser visible (inside Kivy window)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Strategy 2: Subprocess Chrome/Edge (fallback) ────────────
|
||||||
|
browser = _windows_find_browser()
|
||||||
|
if not browser:
|
||||||
|
Logger.error(
|
||||||
|
"SignagePlayer: No embedded CEF and no Chrome/Edge found. "
|
||||||
|
"Cannot display weblink on Windows."
|
||||||
|
)
|
||||||
|
self.consecutive_errors += 1
|
||||||
|
self._skip_to_next_media()
|
||||||
|
return False
|
||||||
|
|
||||||
|
target_width, target_height = self._get_browser_target_size()
|
||||||
|
|
||||||
|
try:
|
||||||
|
Logger.info(f"SignagePlayer: Opening weblink via subprocess: {url} ({browser})")
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
|
||||||
|
# Hide Kivy content
|
||||||
|
from kivy.core.window import Window as KivyWindow
|
||||||
|
try:
|
||||||
|
self.ids.content_area.opacity = 0
|
||||||
|
KivyWindow.minimize()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_Win32Overlay.show()
|
||||||
|
|
||||||
|
self._weblink_proc = subprocess.Popen([
|
||||||
|
browser,
|
||||||
|
'--new-window',
|
||||||
|
'--start-maximized',
|
||||||
|
'--start-fullscreen',
|
||||||
|
'--app=' + url,
|
||||||
|
'--no-first-run',
|
||||||
|
'--noerrdialogs',
|
||||||
|
'--disable-infobars',
|
||||||
|
'--incognito',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-session-crashed-bubble',
|
||||||
|
'--disable-features=TranslateUI',
|
||||||
|
'--disable-sync',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--window-position=0,0',
|
||||||
|
f'--window-size={target_width},{target_height}',
|
||||||
|
'--force-device-scale-factor=1',
|
||||||
|
url,
|
||||||
|
], shell=False)
|
||||||
|
|
||||||
|
def _hide_overlay(dt):
|
||||||
|
_Win32Overlay.hide()
|
||||||
|
try:
|
||||||
|
KivyWindow.raise_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
Clock.schedule_once(_hide_overlay, 1.5)
|
||||||
|
|
||||||
|
Clock.unschedule(self.next_media)
|
||||||
|
self._start_inactivity_watchdog(duration)
|
||||||
|
self.preload_next_media()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"SignagePlayer: Error opening weblink: {e}")
|
||||||
|
_Win32Overlay.hide()
|
||||||
|
self.consecutive_errors += 1
|
||||||
|
self._skip_to_next_media()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Replace weblink handling
|
||||||
|
signage_main.SignagePlayer.play_weblink = _windows_play_weblink
|
||||||
|
|
||||||
|
# Patch the _get_browser_target_size to always return a reasonable size on Windows
|
||||||
|
def _windows_get_browser_target_size(self):
|
||||||
|
try:
|
||||||
|
from kivy.core.window import Window
|
||||||
|
width, height = Window.size
|
||||||
|
if width >= 1280 and height >= 720:
|
||||||
|
return int(width), int(height)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 1920, 1080
|
||||||
|
|
||||||
|
signage_main.SignagePlayer._get_browser_target_size = _windows_get_browser_target_size
|
||||||
|
|
||||||
|
# Patch _start_inactivity_watchdog for Windows — /dev/input does not exist
|
||||||
|
_original_watchdog = signage_main.SignagePlayer._start_inactivity_watchdog
|
||||||
|
|
||||||
|
def _windows_watchdog(self, duration):
|
||||||
|
"""Windows watchdog: uses a simple timer since /dev/input is not available.
|
||||||
|
|
||||||
|
Falls back to a fixed timer that advances after 'duration' seconds.
|
||||||
|
"""
|
||||||
|
from kivy.clock import Clock
|
||||||
|
import threading
|
||||||
|
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
stop_event = threading.Event()
|
||||||
|
self._watchdog_stop = stop_event
|
||||||
|
weblink_proc = self._weblink_proc
|
||||||
|
|
||||||
|
def watchdog():
|
||||||
|
import time
|
||||||
|
# Simply wait for the duration, checking if Chromium exited early
|
||||||
|
elapsed = 0.0
|
||||||
|
step = 0.5
|
||||||
|
while elapsed < duration and not stop_event.is_set():
|
||||||
|
if weblink_proc is not None and weblink_proc.poll() is not None:
|
||||||
|
Clock.schedule_once(self.next_media, 0)
|
||||||
|
return
|
||||||
|
time.sleep(step)
|
||||||
|
elapsed += step
|
||||||
|
if not stop_event.is_set():
|
||||||
|
Clock.schedule_once(self.next_media, 0)
|
||||||
|
|
||||||
|
self._weblink_watchdog_thread = threading.Thread(
|
||||||
|
target=watchdog, daemon=True, name='weblink-watchdog-win'
|
||||||
|
)
|
||||||
|
self._weblink_watchdog_thread.start()
|
||||||
|
|
||||||
|
signage_main.SignagePlayer._start_inactivity_watchdog = _windows_watchdog
|
||||||
|
|
||||||
|
# ── Patch kill_weblink_after_frame for both CEF and subprocess ──
|
||||||
|
def _windows_kill_weblink_after_frame(self):
|
||||||
|
"""Close the weblink (CEF or subprocess) immediately before next media."""
|
||||||
|
import time
|
||||||
|
from kivy.logger import Logger
|
||||||
|
from kivy.clock import Clock
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
|
||||||
|
# Try CEF first
|
||||||
|
cef_browser = _get_cef_browser()
|
||||||
|
if cef_browser is not None and cef_browser.is_showing():
|
||||||
|
Logger.info("SignagePlayer: Hiding CEF embedded browser")
|
||||||
|
cef_browser.hide()
|
||||||
|
self._weblink_proc = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback: subprocess Chrome
|
||||||
|
proc = self._weblink_proc
|
||||||
|
self._weblink_proc = None
|
||||||
|
|
||||||
|
if proc is None or proc.poll() is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
_Win32Overlay.show()
|
||||||
|
Logger.info("SignagePlayer: Killing Chromium subprocess immediately")
|
||||||
|
_windows_kill_process_tree(proc)
|
||||||
|
time.sleep(0.1)
|
||||||
|
_bring_kivy_to_front()
|
||||||
|
_Win32Overlay.hide()
|
||||||
|
signage_main.SignagePlayer._kill_weblink_after_frame = _windows_kill_weblink_after_frame
|
||||||
|
|
||||||
|
# ── Patch play_current_media — same immediate-kill logic ────────
|
||||||
|
_original_play_current = signage_main.SignagePlayer.play_current_media
|
||||||
|
|
||||||
|
def _windows_play_current_media(self, force_reload=False, _after_weblink=False):
|
||||||
|
"""Wrapped play_current_media — closes weblink immediately on transition."""
|
||||||
|
if not _after_weblink:
|
||||||
|
# Kill CEF browser if showing
|
||||||
|
cef_browser = _get_cef_browser()
|
||||||
|
if cef_browser is not None and cef_browser.is_showing():
|
||||||
|
cef_browser.hide()
|
||||||
|
self._weblink_proc = None
|
||||||
|
|
||||||
|
# Kill subprocess Chrome if running
|
||||||
|
proc = self._weblink_proc
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
_Win32Overlay.show()
|
||||||
|
_windows_kill_process_tree(proc)
|
||||||
|
self._weblink_proc = None
|
||||||
|
self._stop_inactivity_watchdog()
|
||||||
|
self._kill_weblink_preload()
|
||||||
|
_bring_kivy_to_front()
|
||||||
|
_Win32Overlay.hide()
|
||||||
|
|
||||||
|
return _original_play_current(self, force_reload=force_reload, _after_weblink=_after_weblink)
|
||||||
|
|
||||||
|
signage_main.SignagePlayer.play_current_media = _windows_play_current_media
|
||||||
|
|
||||||
|
# Patch _prewarm_weblink for Windows — disabled for now.
|
||||||
|
# The off-screen Chrome window on Windows can interfere with:
|
||||||
|
# - Audio playback (Chrome claims audio device)
|
||||||
|
# - GPU resources (Chrome's GPU process runs in background)
|
||||||
|
# - Taskbar icons showing duplicate Chrome windows
|
||||||
|
# Pre-warming is less critical on desktop where launch is already fast.
|
||||||
|
def _windows_prewarm_weblink(self, url):
|
||||||
|
pass # Disabled on Windows — desktop launch is fast enough
|
||||||
|
signage_main.SignagePlayer._prewarm_weblink = _windows_prewarm_weblink
|
||||||
|
|
||||||
|
# Patch cleanup of temp auth file (was using /tmp/)
|
||||||
|
_original_connection_test = signage_main.SettingsPopup.test_connection
|
||||||
|
|
||||||
|
def _windows_test_connection(self):
|
||||||
|
"""Test connection with Windows-safe temp file path."""
|
||||||
|
import tempfile as _tf
|
||||||
|
import os as _os
|
||||||
|
from player_auth import PlayerAuth
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from kivy.clock import Clock
|
||||||
|
|
||||||
|
self.ids.connection_status.text = 'Testing connection...'
|
||||||
|
self.ids.connection_status.color = (1, 0.7, 0, 1)
|
||||||
|
|
||||||
|
def run_test():
|
||||||
|
try:
|
||||||
|
server_ip = self.ids.server_input.text.strip()
|
||||||
|
screen_name = self.ids.screen_input.text.strip()
|
||||||
|
quickconnect = self.ids.quickconnect_input.text.strip()
|
||||||
|
port = self.ids.port_input.text.strip() or self.player.config.get('port', '')
|
||||||
|
use_https = self.player.config.get('use_https', True)
|
||||||
|
verify_ssl = self.player.config.get('verify_ssl', True)
|
||||||
|
|
||||||
|
if not all([server_ip, screen_name, quickconnect]):
|
||||||
|
Clock.schedule_once(lambda dt: self.update_connection_status('Error: Fill all fields', False))
|
||||||
|
return
|
||||||
|
|
||||||
|
if server_ip.startswith('http://') or server_ip.startswith('https://'):
|
||||||
|
server_url = server_ip
|
||||||
|
if ':' not in server_ip.replace('https://', '').replace('http://', ''):
|
||||||
|
if port and port not in ('443', '80'):
|
||||||
|
server_url = f"{server_ip}:{port}"
|
||||||
|
else:
|
||||||
|
protocol = "https" if use_https else "http"
|
||||||
|
if ':' in server_ip:
|
||||||
|
server_url = f"{protocol}://{server_ip}"
|
||||||
|
else:
|
||||||
|
server_url = f"{protocol}://{server_ip}:{port}" if port else f"{protocol}://{server_ip}"
|
||||||
|
|
||||||
|
# Use Windows temp path
|
||||||
|
temp_file = _os.path.join(_tf.gettempdir(), 'temp_auth_test.json')
|
||||||
|
auth = PlayerAuth(temp_file, use_https=use_https, verify_ssl=verify_ssl)
|
||||||
|
success, error = auth.authenticate(server_url=server_url, hostname=screen_name, quickconnect_code=quickconnect)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if _os.path.exists(temp_file):
|
||||||
|
_os.remove(temp_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if success:
|
||||||
|
player_name = auth.get_player_name()
|
||||||
|
Clock.schedule_once(lambda dt: self.update_connection_status(f'✓ Connected: {player_name}', True))
|
||||||
|
else:
|
||||||
|
Clock.schedule_once(lambda dt: self.update_connection_status(f'✗ Failed: {error}', False))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
Clock.schedule_once(lambda dt: self.update_connection_status(f'✗ Error: {str(e)}', False))
|
||||||
|
|
||||||
|
threading.Thread(target=run_test, daemon=True).start()
|
||||||
|
|
||||||
|
signage_main.SettingsPopup.test_connection = _windows_test_connection
|
||||||
|
|
||||||
|
return signage_main
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 4. Adjust path so we can import the src modules
|
||||||
|
# =====================================================================
|
||||||
|
# When run from PyInstaller .exe: the runtime hook inserts paths.
|
||||||
|
# When run as plain python, we add src/ relative to this file.
|
||||||
|
_script_dir = Path(__file__).resolve().parent
|
||||||
|
_project_root = _script_dir.parent
|
||||||
|
_src_dir = _project_root / 'src'
|
||||||
|
|
||||||
|
for p in [str(_src_dir), str(_project_root)]:
|
||||||
|
if p not in sys.path:
|
||||||
|
sys.path.insert(0, p)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 5. Determine the local data directory (next to the executable)
|
||||||
|
# =====================================================================
|
||||||
|
# The pyi_runtime_hook.py (when packaged) sets KIWY_DATA_DIR.
|
||||||
|
# When running in dev mode from python, use the project root.
|
||||||
|
# The executable will create its own local folders for playlist,
|
||||||
|
# media, config, and logs where the executable is launched.
|
||||||
|
DATA_DIR = os.environ.get('KIWY_DATA_DIR', str(_project_root))
|
||||||
|
|
||||||
|
# Create local data folders NEXT TO the executable
|
||||||
|
os.makedirs(os.path.join(DATA_DIR, 'config', 'resources'), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(DATA_DIR, 'media'), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(DATA_DIR, 'media', 'edited_media'), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(DATA_DIR, 'playlists'), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(DATA_DIR, 'logs'), exist_ok=True)
|
||||||
|
os.makedirs(os.path.join(DATA_DIR, 'config', 'certs'), exist_ok=True)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 6. Set Kivy config BEFORE importing Kivy
|
||||||
|
# =====================================================================
|
||||||
|
os.environ['KIVY_NO_FILELOG'] = '1' # Avoid file logging issues on Windows
|
||||||
|
os.environ['KIVY_HOME'] = os.path.join(DATA_DIR, '.kivy')
|
||||||
|
|
||||||
|
from kivy.config import Config
|
||||||
|
Config.set('kivy', 'keyboard_mode', '') # Disable default virtual keyboard
|
||||||
|
Config.set('graphics', 'fullscreen', '0')
|
||||||
|
Config.set('graphics', 'window_state', 'maximized')
|
||||||
|
Config.set('graphics', 'multisampling', '0')
|
||||||
|
Config.set('graphics', 'fast_rgba', '1')
|
||||||
|
Config.set('kivy', 'log_level', 'warning')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 7. Patch the main module, then run the app
|
||||||
|
# =====================================================================
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
# Write a startup marker so we know the .exe at least launched
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.join(DATA_DIR, 'logs'), exist_ok=True)
|
||||||
|
marker = os.path.join(DATA_DIR, 'logs', 'startup_marker.txt')
|
||||||
|
with open(marker, 'w') as f:
|
||||||
|
f.write(f"run_win.py started at {__import__('time').time()}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply all Windows patches before launching
|
||||||
|
try:
|
||||||
|
patched_main = _patch_main()
|
||||||
|
except Exception as e:
|
||||||
|
# Catch early import errors (pre-Logger) to a file
|
||||||
|
import traceback
|
||||||
|
try:
|
||||||
|
err_log = os.path.join(DATA_DIR, 'logs', 'startup_error.log')
|
||||||
|
with open(err_log, 'w') as f:
|
||||||
|
f.write(f"Error in _patch_main(): {e}\n")
|
||||||
|
traceback.print_exc(file=f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise # Re-raise so console shows it too
|
||||||
|
|
||||||
|
from kivy.logger import Logger
|
||||||
|
Logger.info("=" * 80)
|
||||||
|
Logger.info("Kiwy Signage Player - Windows Edition")
|
||||||
|
Logger.info(f"Python: {sys.version}")
|
||||||
|
Logger.info(f"Platform: {platform.platform()}")
|
||||||
|
Logger.info(f"Data directory: {DATA_DIR}")
|
||||||
|
Logger.info("=" * 80)
|
||||||
|
|
||||||
|
# Patch base_dir in SignagePlayer instances to point to local data folder
|
||||||
|
_original_init = patched_main.SignagePlayer.__init__
|
||||||
|
|
||||||
|
def _patched_init(self, **kwargs):
|
||||||
|
"""Override SignagePlayer.__init__ to use local data folders.
|
||||||
|
|
||||||
|
Creates all necessary folders (config, media, playlists, logs)
|
||||||
|
in the same directory where the executable is launched.
|
||||||
|
"""
|
||||||
|
# Call parent Widget.__init__
|
||||||
|
_original_init(self, **kwargs)
|
||||||
|
# Now override ALL paths to point to local data directory
|
||||||
|
# (where the .exe is located)
|
||||||
|
self.base_dir = DATA_DIR
|
||||||
|
self.config_dir = os.path.join(DATA_DIR, 'config')
|
||||||
|
self.media_dir = os.path.join(DATA_DIR, 'media')
|
||||||
|
self.playlists_dir = os.path.join(DATA_DIR, 'playlists')
|
||||||
|
self.config_file = os.path.join(self.config_dir, 'app_config.json')
|
||||||
|
self.resources_path = os.path.join(self.config_dir, 'resources')
|
||||||
|
self.heartbeat_file = os.path.join(DATA_DIR, '.player_heartbeat')
|
||||||
|
# Ensure all required folders exist locally
|
||||||
|
for directory in [
|
||||||
|
self.config_dir,
|
||||||
|
self.resources_path,
|
||||||
|
os.path.join(self.media_dir, 'edited_media'),
|
||||||
|
self.playlists_dir,
|
||||||
|
os.path.join(DATA_DIR, 'logs'),
|
||||||
|
os.path.join(DATA_DIR, 'config', 'certs'),
|
||||||
|
]:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
|
patched_main.SignagePlayer.__init__ = _patched_init
|
||||||
|
|
||||||
|
# Patch SSLManager cert directory to use local data folder
|
||||||
|
# ssl_utils is imported by player_auth.py and get_playlists_v2.py, not main.py
|
||||||
|
import ssl_utils
|
||||||
|
ssl_utils.SSLManager.CERT_DIR = os.path.join(DATA_DIR, 'config', 'certs')
|
||||||
|
ssl_utils.SSLManager.CERT_FILE = os.path.join(
|
||||||
|
ssl_utils.SSLManager.CERT_DIR, 'server_cert.pem'
|
||||||
|
)
|
||||||
|
ssl_utils.SSLManager.CERT_INFO_FILE = os.path.join(
|
||||||
|
ssl_utils.SSLManager.CERT_DIR, 'cert_info.json'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
try:
|
||||||
|
app = patched_main.SignagePlayerApp()
|
||||||
|
app.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
Logger.info("Application stopped by user (Ctrl+C)")
|
||||||
|
except SystemExit as _se:
|
||||||
|
Logger.critical(f"Kivy SystemExit (likely window provider missing): {_se}")
|
||||||
|
try:
|
||||||
|
crash_log = os.path.join(DATA_DIR, 'logs', 'crash.log')
|
||||||
|
with open(crash_log, 'w') as f:
|
||||||
|
f.write(f"Kivy SystemExit: {_se}\n")
|
||||||
|
f.write("This usually means Kivy could not find a window provider on this system.\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_show_error_box(
|
||||||
|
"Kiwy Signage Player - Kivy Error",
|
||||||
|
f"Kivy exited: {_se}\n\n"
|
||||||
|
"This usually means Kivy could not create a window.\n"
|
||||||
|
"Check your GPU drivers and DirectX installation.\n\n"
|
||||||
|
"See logs/crash.log for details."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
Logger.critical(f"Fatal error: {e}")
|
||||||
|
Logger.exception("Full traceback:")
|
||||||
|
# Also write to a crash log next to the executable
|
||||||
|
try:
|
||||||
|
import traceback
|
||||||
|
crash_log = os.path.join(DATA_DIR, 'logs', 'crash.log')
|
||||||
|
with open(crash_log, 'w') as f:
|
||||||
|
f.write(f"Fatal error: {e}\n")
|
||||||
|
traceback.print_exc(file=f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
Logger.info("Application shutdown complete")
|
||||||
|
except BaseException as _top_e:
|
||||||
|
# Catch any error BEFORE Logger is available (including SystemExit)
|
||||||
|
import traceback as _tb
|
||||||
|
_trace = _tb.format_exc()
|
||||||
|
try:
|
||||||
|
_crash_log = os.path.join(DATA_DIR, 'logs', 'fatal_crash.log')
|
||||||
|
with open(_crash_log, 'w') as _f:
|
||||||
|
_f.write(f"FATAL (pre-Logger): {_top_e}\n")
|
||||||
|
_f.write(_trace)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_show_error_box(
|
||||||
|
"Kiwy Signage Player - Startup Error",
|
||||||
|
f"{_top_e}\n\nSee logs/fatal_crash.log for details."
|
||||||
|
)
|
||||||
|
raise # Re-raise so .exe still shows the error
|
||||||
@@ -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)
|
||||||
Executable → Regular
Reference in New Issue
Block a user