├── icon.ico ├── icon.png ├── sounds ├── end1.mp3 ├── end2.mp3 ├── end3.mp3 ├── warning1.mp3 ├── warning2.mp3 └── warning3.mp3 ├── screenshots ├── s1.png ├── s2.png ├── s3.png ├── s4.png ├── s5.png ├── s6.png ├── s7.png ├── s8.png └── s9.png ├── requirements.txt ├── .gitignore ├── config.json ├── stagedeck.spec ├── README.md ├── osc_client.py ├── .github └── workflows │ └── build.yml ├── web_server.py └── main.py /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/icon.ico -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/icon.png -------------------------------------------------------------------------------- /sounds/end1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/sounds/end1.mp3 -------------------------------------------------------------------------------- /sounds/end2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/sounds/end2.mp3 -------------------------------------------------------------------------------- /sounds/end3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/sounds/end3.mp3 -------------------------------------------------------------------------------- /screenshots/s1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s1.png -------------------------------------------------------------------------------- /screenshots/s2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s2.png -------------------------------------------------------------------------------- /screenshots/s3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s3.png -------------------------------------------------------------------------------- /screenshots/s4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s4.png -------------------------------------------------------------------------------- /screenshots/s5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s5.png -------------------------------------------------------------------------------- /screenshots/s6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s6.png -------------------------------------------------------------------------------- /screenshots/s7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s7.png -------------------------------------------------------------------------------- /screenshots/s8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s8.png -------------------------------------------------------------------------------- /screenshots/s9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/screenshots/s9.png -------------------------------------------------------------------------------- /sounds/warning1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/sounds/warning1.mp3 -------------------------------------------------------------------------------- /sounds/warning2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/sounds/warning2.mp3 -------------------------------------------------------------------------------- /sounds/warning3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mko1989/stagedeck/HEAD/sounds/warning3.mp3 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5==5.15.11; platform_system == "Windows" 2 | PyQt5-Qt5==5.15.2; platform_system == "Windows" 3 | PyQt5-sip==12.12.2; platform_system == "Windows" 4 | python-osc==1.8.3 5 | pygame==2.6.1 6 | fastapi==0.109.2 7 | uvicorn==0.27.1 8 | websockets==12.0 9 | python-multipart==0.0.9 10 | Pillow==11.1.0 11 | aiofiles==23.2.1 12 | pyinstaller 13 | numpy==2.2.3 14 | opencv-python==4.8.0.74 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build directories 2 | build/ 3 | dist/ 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # Beta versions 8 | beta*/ 9 | 10 | # Old versions 11 | old/ 12 | 13 | # Environment 14 | .env 15 | .venv/ 16 | env/ 17 | venv/ 18 | 19 | # IDE 20 | .idea/ 21 | .vscode/ 22 | *.swp 23 | *.swo 24 | 25 | # OS 26 | .DS_Store 27 | Thumbs.db 28 | 29 | # Temporary wav files 30 | wavs/ 31 | 32 | # Companion Viewer specific 33 | "Companion Viewer build"/ 34 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#000000", 3 | "fields": { 4 | "timer": { 5 | "x": 200, 6 | "y": 10, 7 | "width": 300, 8 | "height": 200, 9 | "title_text": "Timer", 10 | "title_font_family": "Arial", 11 | "title_font_size": 24, 12 | "title_font_color": "white", 13 | "content_font_family": "Arial", 14 | "content_font_size": 32, 15 | "content_font_color": "white", 16 | "show_border": true 17 | }, 18 | "time": { 19 | "x": 200, 20 | "y": 200, 21 | "width": 300, 22 | "height": 200, 23 | "title_text": "time", 24 | "title_font_family": "Arial", 25 | "title_font_size": 24, 26 | "title_font_color": "white", 27 | "content_font_family": "Arial", 28 | "content_font_size": 20, 29 | "content_font_color": "white", 30 | "show_border": true 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /stagedeck.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | import os 3 | import sys 4 | from PyInstaller.utils.hooks import collect_data_files 5 | 6 | block_cipher = None 7 | 8 | # Check if we're on macOS 9 | is_mac = sys.platform == 'darwin' 10 | 11 | # Get icon based on platform 12 | if is_mac: 13 | icon_file = 'icon.icns' if os.path.exists('icon.icns') else None 14 | else: 15 | icon_file = 'icon.ico' if os.path.exists('icon.ico') else None 16 | 17 | # Get PyQt5 plugins 18 | pyqt_plugins = collect_data_files('PyQt5') 19 | 20 | a = Analysis( 21 | ['main.py'], 22 | pathex=[], 23 | binaries=[], 24 | datas=[ 25 | ('sounds/*', 'sounds'), 26 | ('web_server.py', '.'), 27 | *pyqt_plugins, 28 | ], 29 | hiddenimports=[ 30 | 'PyQt5', 31 | 'PyQt5.QtCore', 32 | 'PyQt5.QtGui', 33 | 'PyQt5.QtWidgets', 34 | 'PyQt5.sip', 35 | 'PyQt5.QtNetwork', 36 | 'asyncio', 37 | 'pythonosc', 38 | 'pythonosc.osc_server', 39 | 'pythonosc.dispatcher', 40 | 'threading', 41 | 'json', 42 | 'websockets', 43 | 'pygame', 44 | 'pygame.mixer', 45 | 'pygame.mixer_music', 46 | 'fastapi', 47 | 'uvicorn', 48 | 'PIL', 49 | 'numpy', 50 | 'cv2', 51 | 'multipart', 52 | 'aiofiles', 53 | 'fastapi.responses', 54 | 'fastapi.staticfiles', 55 | 'fastapi.websockets', 56 | 'uvicorn.config', 57 | 'uvicorn.main', 58 | 'uvicorn.loops', 59 | 'uvicorn.protocols', 60 | 'uvicorn.lifespan', 61 | 'uvicorn.logging', 62 | 'starlette', 63 | 'starlette.routing', 64 | 'starlette.applications', 65 | 'starlette.responses', 66 | 'starlette.websockets', 67 | 'starlette.types', 68 | 'starlette.datastructures', 69 | 'starlette.staticfiles', 70 | 'web_server' 71 | ], 72 | hookspath=[], 73 | hooksconfig={}, 74 | runtime_hooks=[], 75 | excludes=[], 76 | win_no_prefer_redirects=False, 77 | win_private_assemblies=False, 78 | cipher=block_cipher, 79 | noarchive=False 80 | ) 81 | 82 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 83 | 84 | exe = EXE( 85 | pyz, 86 | a.scripts, 87 | [], 88 | exclude_binaries=True, 89 | name='StageDeck', 90 | debug=False, 91 | bootloader_ignore_signals=False, 92 | strip=False, 93 | upx=True, 94 | console=True, 95 | disable_windowed_traceback=False, 96 | argv_emulation=False, 97 | target_arch=None, 98 | codesign_identity=None, 99 | entitlements_file=None, 100 | icon=icon_file 101 | ) 102 | 103 | coll = COLLECT( 104 | exe, 105 | a.binaries, 106 | a.zipfiles, 107 | a.datas, 108 | strip=False, 109 | upx=True, 110 | upx_exclude=[], 111 | name='StageDeck' 112 | ) 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The project is abandond now as I found other ways to achive the funcionalities, easier and more stable. 2 | 3 | # Introduction 4 | 5 | StageDeck is a versatile stage display application designed for live events and broadcasts. It supports NDI input and web streaming, making it an ideal solution for dynamic stage displays and real-time updates. 6 | 7 | ## Features 8 | 9 | Transparent or opaque window display: Choose between a transparent or solid background to best suit your stage environment. 10 | NDI input support: Integrate live video feeds directly into your display. 11 | Web streaming capability: Stream your display to any device on your network for remote viewing. 12 | Timer functionality: Use countdown or countup timers for time management. 13 | Timer features warning change color, end time change color and blinking. Playing warning time sound, playing end time sound. 14 | Customizable fields and text display: Create and customize fields that can be updated in real-time. 15 | OSC control support: Use OSC messages to dynamically change display content. 16 | 17 | 18 | ## Installation 19 | 20 | 21 | Option 1: Install from Executable (Recommended) 22 | Download the latest StageDeck Installer.zip 23 | Extract the zip file 24 | Run StageDeck.exe 25 | 26 | Option 2: Install from Source 27 | Clone this repository 28 | Install Python 3.9 or later 29 | Install dependencies: 30 | bash 31 | pip install -r requirements.txt 32 | Run the application: 33 | bash 34 | python main.py 35 | 36 | For NDI usage NDI runtime is needed. You can grab it quickly from here: 37 | https://github.com/DistroAV/DistroAV/discussions/831 38 | 39 | ## Usage 40 | 41 | 42 | Launch StageDeck 43 | 44 | Configure display settings in the Settings tab: 45 | 46 | Choose monitor 47 | Set background color or transparency 48 | Enable NDI input as background if needed 49 | Configure web streaming 50 | Add and customize fields in the Fields tab that can be dynamically set and changed via OSC. 51 | 52 | OSC messages should look like /field/(field-id)/content/(value) 53 | 54 | Example: /field/time/content/12:24:56 55 | For best results, create a trigger in Companion on variable change and choose send OSC as an action. 56 | Use the Timer tab for countdown/countup functionality 57 | 58 | ## Web Streaming 59 | 60 | When web streaming is enabled, access the display from any device on your network: 61 | 62 | Enable web streaming in the Settings tab 63 | Access http://:8181 from any web browser 64 | The display will update in real-time with minimal latency 65 | 66 | ## ScreenShots 67 | 68 | ![Main window](https://github.com/mko1989/stagedeck/blob/main/screenshots/s1.png) 69 | ![Timer](https://github.com/mko1989/stagedeck/blob/main/screenshots/s3.png) 70 | ![Setting up a field](https://github.com/mko1989/stagedeck/blob/main/screenshots/s4.png) 71 | ![Companion config](https://github.com/mko1989/stagedeck/blob/main/screenshots/s5.png) 72 | ![Trigger setup](https://github.com/mko1989/stagedeck/blob/main/screenshots/s6.png) 73 | ![Companion variable in a field](https://github.com/mko1989/stagedeck/blob/main/screenshots/s7.png) 74 | ![Transparent background](https://github.com/mko1989/stagedeck/blob/main/screenshots/s8.png) 75 | ![NDI background](https://github.com/mko1989/stagedeck/blob/main/screenshots/s9.png) 76 | 77 | ## Development 78 | 79 | Main application: main.py 80 | 81 | Web server component: web_server.py 82 | 83 | PyInstaller spec: companion_viewer.spec 84 | -------------------------------------------------------------------------------- /osc_client.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pythonosc.udp_client import SimpleUDPClient 3 | from typing import Dict, Any 4 | 5 | class OSCClient: 6 | """ 7 | OSC Client for sending StageDeck information to Bitfocus Companion. 8 | This client sends field IDs, content, and timer information. 9 | """ 10 | 11 | def __init__(self, ip="127.0.0.1", port=9292): 12 | """ 13 | Initialize the OSC client with target IP and port. 14 | 15 | Args: 16 | ip (str): Target IP address (default: 127.0.0.1) 17 | port (int): Target OSC port (default: 9292) 18 | """ 19 | self.ip = ip 20 | self.port = port 21 | self.client = SimpleUDPClient(ip, port) 22 | self.last_timer_update = 0 23 | self.update_interval = 0.1 # Limit updates to 10 per second 24 | 25 | def set_target(self, ip, port): 26 | """ 27 | Update the target IP and port. 28 | 29 | Args: 30 | ip (str): New target IP address 31 | port (int): New target OSC port 32 | """ 33 | self.ip = ip 34 | self.port = port 35 | self.client = SimpleUDPClient(ip, port) 36 | 37 | def send_field_update(self, field_id: str, content: str): 38 | """ 39 | Send field update to Companion. 40 | 41 | Args: 42 | field_id (str): ID of the field being updated 43 | content (str): Content of the field 44 | """ 45 | # Send field ID and content 46 | self.client.send_message(f"/stagedeck/field/{field_id}", content) 47 | 48 | def send_fields_list(self, fields: Dict[str, Any]): 49 | """ 50 | Send the list of all field IDs to Companion. 51 | 52 | Args: 53 | fields (Dict[str, Any]): Dictionary of fields 54 | """ 55 | # Send the number of fields 56 | self.client.send_message("/stagedeck/fields/count", len(fields)) 57 | 58 | # Send each field ID 59 | for i, field_id in enumerate(fields.keys()): 60 | self.client.send_message(f"/stagedeck/fields/{i}", field_id) 61 | 62 | def send_timer_update(self, remaining_seconds: int, running: bool, warning: bool = False): 63 | """ 64 | Send timer update to Companion. 65 | 66 | Args: 67 | remaining_seconds (int): Remaining time in seconds 68 | running (bool): Whether the timer is running 69 | warning (bool): Whether the timer is in warning state 70 | """ 71 | # Limit update rate to avoid flooding 72 | current_time = time.time() 73 | if current_time - self.last_timer_update < self.update_interval: 74 | return 75 | 76 | self.last_timer_update = current_time 77 | 78 | # Send timer status 79 | self.client.send_message("/stagedeck/timer/running", 1 if running else 0) 80 | self.client.send_message("/stagedeck/timer/warning", 1 if warning else 0) 81 | self.client.send_message("/stagedeck/timer/remaining", remaining_seconds) 82 | 83 | # Send formatted time values for easier display in Companion 84 | hours = remaining_seconds // 3600 85 | minutes = (remaining_seconds % 3600) // 60 86 | seconds = remaining_seconds % 60 87 | 88 | self.client.send_message("/stagedeck/timer/hours", hours) 89 | self.client.send_message("/stagedeck/timer/minutes", minutes) 90 | self.client.send_message("/stagedeck/timer/seconds", seconds) 91 | 92 | # Send formatted time string (HH:MM:SS) 93 | time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}" 94 | self.client.send_message("/stagedeck/timer/display", time_str) 95 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build StageDeck 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | # Allow manual trigger 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [windows-latest, macos-latest] 17 | python-version: ['3.13'] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install system dependencies (macOS) 28 | if: matrix.os == 'macos-latest' 29 | run: | 30 | # Install Qt5 and SDL2 dependencies 31 | brew install qt@5 sdl2 sdl2_image sdl2_mixer sdl2_ttf portmidi 32 | 33 | # Set up Qt5 environment 34 | echo "LDFLAGS=-L/opt/homebrew/opt/qt@5/lib" >> $GITHUB_ENV 35 | echo "CPPFLAGS=-I/opt/homebrew/opt/qt@5/include" >> $GITHUB_ENV 36 | echo "PKG_CONFIG_PATH=/opt/homebrew/opt/qt@5/lib/pkgconfig:/opt/homebrew/lib/pkgconfig" >> $GITHUB_ENV 37 | echo "/opt/homebrew/opt/qt@5/bin" >> $GITHUB_PATH 38 | 39 | # Force link Qt5 40 | brew link --force qt@5 || true 41 | 42 | - name: Install dependencies 43 | shell: bash 44 | run: | 45 | python -m pip install --upgrade pip wheel setuptools 46 | 47 | # Install platform-specific dependencies 48 | if [[ "$RUNNER_OS" == "Windows" ]]; then 49 | # Force binary installations for Windows 50 | pip install --only-binary :all: pygame==2.6.1 Pillow==11.1.0 numpy==2.2.3 51 | elif [[ "$RUNNER_OS" == "macOS" ]]; then 52 | # Set up pkg-config for SDL2 53 | export PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig:$PKG_CONFIG_PATH" 54 | # Install Mac dependencies with binary packages 55 | pip install --only-binary :all: pygame==2.6.1 PyQt5==5.15.11 PyQt5-Qt5==5.15.2 PyQt5-sip==12.12.2 56 | # Install remaining packages 57 | pip install --only-binary :all: Pillow==11.1.0 numpy==2.2.3 58 | fi 59 | 60 | # Install other dependencies 61 | pip install -r requirements.txt 62 | pip install pyinstaller 63 | 64 | - name: Create icons 65 | if: matrix.os == 'macos-latest' 66 | run: | 67 | # Check if icon.png exists 68 | if [ -f icon.png ]; then 69 | # Create iconset directory 70 | mkdir icon.iconset 71 | # Generate icons of different sizes 72 | sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png 73 | sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png 74 | sips -z 32 32 icon.png --out icon.iconset/icon_32x32.png 75 | sips -z 64 64 icon.png --out icon.iconset/icon_32x32@2x.png 76 | sips -z 128 128 icon.png --out icon.iconset/icon_128x128.png 77 | sips -z 256 256 icon.png --out icon.iconset/icon_128x128@2x.png 78 | sips -z 256 256 icon.png --out icon.iconset/icon_256x256.png 79 | sips -z 512 512 icon.png --out icon.iconset/icon_256x256@2x.png 80 | sips -z 512 512 icon.png --out icon.iconset/icon_512x512.png 81 | # Create icns file 82 | iconutil -c icns icon.iconset 83 | rm -rf icon.iconset 84 | fi 85 | 86 | - name: Build with PyInstaller 87 | shell: bash 88 | run: | 89 | pyinstaller stagedeck.spec 90 | 91 | - name: Package Windows Build 92 | if: matrix.os == 'windows-latest' 93 | shell: pwsh 94 | run: | 95 | cd dist 96 | 7z a -tzip StageDeck-windows.zip StageDeck/ 97 | 98 | - name: Package macOS Build 99 | if: matrix.os == 'macos-latest' 100 | run: | 101 | cd dist 102 | zip -r StageDeck-macos.zip StageDeck.app/ 103 | 104 | - name: Upload Windows Artifact 105 | if: matrix.os == 'windows-latest' 106 | uses: actions/upload-artifact@v4 107 | with: 108 | name: StageDeck-windows 109 | path: dist/StageDeck-windows.zip 110 | 111 | - name: Upload macOS Artifact 112 | if: matrix.os == 'macos-latest' 113 | uses: actions/upload-artifact@v4 114 | with: 115 | name: StageDeck-macos 116 | path: dist/StageDeck-macos.zip 117 | -------------------------------------------------------------------------------- /web_server.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, WebSocket 2 | from fastapi.responses import HTMLResponse, JSONResponse 3 | from fastapi.staticfiles import StaticFiles 4 | import asyncio 5 | from pathlib import Path 6 | import base64 7 | from io import BytesIO 8 | from PIL import Image 9 | import json 10 | from typing import Optional, Dict 11 | from queue import Queue 12 | import threading 13 | 14 | app = FastAPI() 15 | 16 | # Store active connections 17 | connections: Dict[int, WebSocket] = {} 18 | connection_counter = 0 19 | 20 | # Queue for new frames 21 | frame_queue = Queue(maxsize=1) # Only keep latest frame 22 | 23 | # HTML template for the viewer page 24 | HTML_TEMPLATE = """ 25 | 26 | 27 | 28 | Companion Viewer 29 | 46 | 47 | 48 | 49 | 67 | 68 | 69 | """ 70 | 71 | @app.get("/", response_class=HTMLResponse) 72 | async def get(): 73 | return HTMLResponse(content=HTML_TEMPLATE) 74 | 75 | @app.websocket("/ws") 76 | async def websocket_endpoint(websocket: WebSocket): 77 | global connection_counter 78 | await websocket.accept() 79 | 80 | # Assign unique ID to this connection 81 | connection_id = connection_counter 82 | connection_counter += 1 83 | connections[connection_id] = websocket 84 | 85 | try: 86 | while True: 87 | # Keep connection alive and wait for frames 88 | await asyncio.sleep(0.016) # ~60fps max 89 | except: 90 | pass 91 | finally: 92 | del connections[connection_id] 93 | 94 | def broadcast_frame(image_data: bytes): 95 | """Broadcast frame to all connected clients""" 96 | if not connections: 97 | return 98 | 99 | # Convert to base64 100 | frame_b64 = base64.b64encode(image_data).decode('utf-8') 101 | data = json.dumps({"frame": frame_b64}) 102 | 103 | # Put in queue for broadcasting 104 | try: 105 | frame_queue.put_nowait(data) 106 | except: 107 | # Queue full, skip frame 108 | pass 109 | 110 | async def broadcast_worker(): 111 | """Worker to broadcast frames from queue""" 112 | while True: 113 | try: 114 | if not frame_queue.empty(): 115 | data = frame_queue.get_nowait() 116 | # Broadcast to all connections 117 | for conn_id, websocket in list(connections.items()): 118 | try: 119 | await websocket.send_text(data) 120 | except: 121 | # Connection probably closed 122 | try: 123 | del connections[conn_id] 124 | except: 125 | pass 126 | except: 127 | pass 128 | await asyncio.sleep(0.016) # ~60fps max 129 | 130 | def start_server(host: str = "0.0.0.0", port: int = 8181): 131 | """Start the FastAPI server""" 132 | import uvicorn 133 | from contextlib import asynccontextmanager 134 | 135 | @asynccontextmanager 136 | async def lifespan(app: FastAPI): 137 | # Start broadcast worker on server startup 138 | task = asyncio.create_task(broadcast_worker()) 139 | yield 140 | # Cancel broadcast worker on server shutdown 141 | task.cancel() 142 | 143 | app.router.lifespan_context = lifespan 144 | 145 | # Run server 146 | uvicorn.run(app, host=host, port=port) 147 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | import time 4 | import ctypes 5 | from pathlib import Path 6 | from pythonosc.dispatcher import Dispatcher 7 | from pythonosc.osc_server import BlockingOSCUDPServer 8 | import threading 9 | from PyQt5.QtWidgets import * 10 | from PyQt5.QtCore import * 11 | from PyQt5.QtGui import * 12 | from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent 13 | import cv2 14 | import asyncio 15 | from PIL import Image 16 | from io import BytesIO 17 | import pygame.mixer 18 | import os 19 | from osc_client import OSCClient 20 | 21 | def get_resource_path(relative_path): 22 | """Get absolute path to resource for both dev and PyInstaller""" 23 | if getattr(sys, 'frozen', False): 24 | # Running in a bundle 25 | if sys.platform == 'darwin': 26 | # On macOS, resources are in the .app bundle 27 | base_path = os.path.join(sys._MEIPASS, 'Contents', 'Resources') 28 | else: 29 | base_path = sys._MEIPASS 30 | else: 31 | # Running in normal Python environment 32 | base_path = os.path.dirname(os.path.abspath(__file__)) 33 | return os.path.join(base_path, relative_path) 34 | 35 | class TextItem: 36 | def __init__(self, text="", font_family="Arial", font_size=20, font_color="white"): 37 | self.text = text 38 | self.font_family = font_family 39 | self.font_size = font_size 40 | self.font_color = font_color 41 | 42 | class Field(QWidget): 43 | def __init__(self, parent=None, field_id="", x=0, y=0, width=200, height=200, 44 | title_text="", title_font_family="Arial", title_font_size=20, title_font_color="white", 45 | content_font_family="Arial", content_font_size=20, content_font_color="white", 46 | show_border=True): 47 | super().__init__(parent) 48 | 49 | self.field_id = field_id 50 | self.show_border = show_border 51 | 52 | # Create title and content 53 | self.title = TextItem(title_text, title_font_family, title_font_size, title_font_color) 54 | self.content = TextItem("", content_font_family, content_font_size, content_font_color) 55 | 56 | # Set size and position 57 | self.setFixedSize(width, height) 58 | self.move(x, y) # Use absolute coordinates from top-left 59 | 60 | # Store position 61 | self._x = x 62 | self._y = y 63 | 64 | # Transparency 65 | self.setAttribute(Qt.WA_TranslucentBackground) 66 | self.setAutoFillBackground(False) 67 | 68 | def get_x(self): 69 | """Get x position""" 70 | return self._x 71 | 72 | def get_y(self): 73 | """Get y position""" 74 | return self._y 75 | 76 | def set_position(self, x, y): 77 | """Set position""" 78 | self._x = x 79 | self._y = y 80 | self.move(x, y) 81 | 82 | def paintEvent(self, event): 83 | painter = QPainter(self) 84 | 85 | # Draw border if enabled 86 | if self.show_border: 87 | painter.setPen(QColor("white")) 88 | painter.drawRect(0, 0, self.width() - 1, self.height() - 1) 89 | 90 | # Set up font for title 91 | title_font = QFont(self.title.font_family, self.title.font_size) 92 | painter.setFont(title_font) 93 | 94 | # Calculate title metrics 95 | title_metrics = painter.fontMetrics() 96 | 97 | # Draw title centered at top 98 | painter.setPen(QColor(self.title.font_color)) 99 | title_y = 10 # Small padding from top 100 | painter.drawText(0, title_y, self.width(), title_metrics.height(), Qt.AlignCenter, self.title.text) 101 | 102 | # Set up font for content 103 | content_font = QFont(self.content.font_family, self.content.font_size) 104 | painter.setFont(content_font) 105 | 106 | # Calculate content metrics 107 | content_metrics = painter.fontMetrics() 108 | content_height = content_metrics.height() 109 | 110 | # Draw content centered in remaining space 111 | painter.setPen(QColor(self.content.font_color)) 112 | content_y = title_y + title_metrics.height() + 10 # Below title with padding 113 | remaining_height = self.height() - content_y - 10 # Leave padding at bottom 114 | 115 | # Split content into lines and center each line 116 | lines = self.content.text.split('\n') 117 | total_lines_height = len(lines) * content_height 118 | current_y = content_y + (remaining_height - total_lines_height) // 2 # Vertical center 119 | 120 | for line in lines: 121 | painter.drawText(0, current_y, self.width(), content_height, Qt.AlignCenter, line) 122 | current_y += content_height 123 | 124 | class DisplayWindow(QMainWindow): 125 | def __init__(self): 126 | super().__init__() 127 | self.setWindowTitle("StageDeck Beta") 128 | self.setGeometry(100, 100, 800, 600) 129 | 130 | # Transparency setup 131 | self.setAttribute(Qt.WA_TranslucentBackground) 132 | self.setAttribute(Qt.WA_NoSystemBackground) 133 | self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) 134 | 135 | # Initialize variables 136 | self.fields = {} 137 | self._background_color = QColor(0, 0, 0, 255) # Start with opaque black 138 | self.ndi_frame = None 139 | self.original_ndi_size = None 140 | self.ndi_enabled = False 141 | self.ndi_receiver = NDIReceiver() 142 | 143 | # Border 144 | self.border_width = 2 145 | self.dragging = False 146 | self.offset = None 147 | 148 | # Create central widget and layout 149 | self.central_widget = QWidget() 150 | self.central_widget.setAttribute(Qt.WA_TranslucentBackground) 151 | self.central_widget.setAutoFillBackground(False) 152 | self.central_widget.setStyleSheet("background: transparent;") 153 | self.setCentralWidget(self.central_widget) 154 | 155 | # Setup NDI timer 156 | self.ndi_timer = QTimer() 157 | self.ndi_timer.timeout.connect(self.update_ndi) 158 | self.ndi_timer.setInterval(33) # ~30fps 159 | 160 | # Store screen info 161 | self.current_screen = 0 162 | self.normal_geometry = None 163 | 164 | # Setup web streaming 165 | self.web_enabled = False 166 | self.web_timer = QTimer() 167 | self.web_timer.timeout.connect(self.broadcast_frame) 168 | self.web_timer.setInterval(33) # ~30fps 169 | self.server_thread = None 170 | self.web_server = None 171 | 172 | # Import web server module 173 | try: 174 | web_server_path = get_resource_path('web_server.py') 175 | print(f"Loading web server from: {web_server_path}") 176 | import importlib.util 177 | import sys 178 | 179 | spec = importlib.util.spec_from_file_location("web_server", web_server_path) 180 | web_server = importlib.util.module_from_spec(spec) 181 | sys.modules["web_server"] = web_server 182 | spec.loader.exec_module(web_server) 183 | self.web_server = web_server 184 | print("Web server module loaded successfully") 185 | except Exception as e: 186 | print(f"Error loading web server module: {e}") 187 | import traceback 188 | traceback.print_exc() 189 | 190 | self.update_background() 191 | 192 | def update_ndi(self): 193 | """Update NDI frame from receiver""" 194 | if self.ndi_enabled and self.ndi_receiver: 195 | frame = self.ndi_receiver.receive_frame() 196 | if frame is not None: 197 | self._update_ndi_frame(frame) 198 | 199 | def set_ndi_enabled(self, enabled): 200 | """Enable or disable NDI reception""" 201 | self.ndi_enabled = enabled 202 | if enabled: 203 | if not self.ndi_enabled: 204 | if self.ndi_receiver.initialize(): 205 | self.ndi_enabled = True 206 | self.ndi_timer.start() 207 | print("NDI initialized, searching for sources...") 208 | return True 209 | return False 210 | else: 211 | self.ndi_enabled = False 212 | self.ndi_timer.stop() 213 | self.ndi_frame = None 214 | self.update() 215 | return True 216 | 217 | def _update_ndi_frame(self, frame): 218 | """Update NDI frame while preserving aspect ratio""" 219 | if frame is not None: 220 | self.ndi_frame = frame 221 | if self.original_ndi_size is None: 222 | self.original_ndi_size = (frame.width(), frame.height()) 223 | self.update() 224 | 225 | def get_scaled_ndi_frame(self): 226 | """Calculate scaled NDI frame dimensions maintaining aspect ratio""" 227 | if self.ndi_frame is None or self.original_ndi_size is None: 228 | return None, (0, 0) 229 | 230 | window_width = self.width() 231 | window_height = self.height() 232 | 233 | # Calculate scaling factors for both dimensions 234 | scale_x = window_width / self.original_ndi_size[0] 235 | scale_y = window_height / self.original_ndi_size[1] 236 | 237 | # Use the smaller scaling factor to maintain aspect ratio 238 | scale = min(scale_x, scale_y) 239 | 240 | # Calculate new dimensions 241 | new_width = int(self.original_ndi_size[0] * scale) 242 | new_height = int(self.original_ndi_size[1] * scale) 243 | 244 | # Scale the frame 245 | scaled_frame = self.ndi_frame.scaled(new_width, new_height, Qt.KeepAspectRatio, Qt.SmoothTransformation) 246 | 247 | return scaled_frame, (new_width, new_height) 248 | 249 | def paintEvent(self, event): 250 | painter = QPainter(self) 251 | 252 | # Fill background 253 | painter.fillRect(0, 0, self.width(), self.height(), self._background_color) 254 | 255 | # Draw NDI frame if available 256 | if self.ndi_frame is not None: 257 | scaled_frame, (width, height) = self.get_scaled_ndi_frame() 258 | if scaled_frame is not None: 259 | # Calculate position to center the frame 260 | x = (self.width() - width) // 2 261 | y = (self.height() - height) // 2 262 | 263 | # Draw the frame 264 | painter.drawImage(x, y, scaled_frame) 265 | 266 | # Draw border if transparent 267 | if self._background_color.alpha() < 255: 268 | painter.setPen(QColor("white")) 269 | painter.drawRect(0, 0, self.width() - 1, self.height() - 1) 270 | 271 | # Draw fields on top 272 | for field in self.fields.values(): 273 | field.update() 274 | 275 | def set_background_color(self, color): 276 | """Set window background color""" 277 | if isinstance(color, QColor): 278 | self._background_color = color 279 | elif isinstance(color, str): 280 | self._background_color = QColor(color) 281 | self.update_background() 282 | 283 | def update_background(self): 284 | """Update window background""" 285 | if self._background_color.alpha() < 255: 286 | # Enable transparency 287 | self.setAttribute(Qt.WA_TranslucentBackground, True) 288 | self.setAttribute(Qt.WA_NoSystemBackground, True) 289 | self.central_widget.setAttribute(Qt.WA_TranslucentBackground, True) 290 | self.central_widget.setAutoFillBackground(False) 291 | self.central_widget.setStyleSheet("background: transparent;") 292 | else: 293 | # Disable transparency 294 | self.setAttribute(Qt.WA_TranslucentBackground, False) 295 | self.setAttribute(Qt.WA_NoSystemBackground, False) 296 | self.central_widget.setAttribute(Qt.WA_TranslucentBackground, False) 297 | self.central_widget.setAutoFillBackground(True) 298 | self.central_widget.setStyleSheet("") 299 | 300 | # Force a repaint 301 | self.update() 302 | 303 | def toggle_transparency(self, state): 304 | """Toggle window transparency""" 305 | if state == Qt.Checked: 306 | # Enable transparency 307 | self._background_color = QColor(0, 0, 0, 0) # Fully transparent 308 | self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint) 309 | else: 310 | # Disable transparency 311 | self._background_color = QColor(0, 0, 0, 255) # Opaque black 312 | self.setWindowFlags(self.windowFlags() & ~Qt.FramelessWindowHint) 313 | 314 | # Show window again since changing flags hides it 315 | self.show() 316 | self.update_background() 317 | 318 | def mousePressEvent(self, event): 319 | if event.button() == Qt.LeftButton: 320 | self.dragging = True 321 | self.offset = event.pos() 322 | 323 | def mouseMoveEvent(self, event): 324 | if self.dragging and self.offset: 325 | new_pos = event.globalPos() - self.offset 326 | self.move(new_pos) 327 | 328 | def mouseReleaseEvent(self, event): 329 | if event.button() == Qt.LeftButton: 330 | self.dragging = False 331 | self.offset = None 332 | 333 | def get_background_color(self): 334 | """Get current background color""" 335 | return self._background_color.name() 336 | 337 | def get_safe_geometry(self, screen): 338 | """Calculate a safe window geometry for the given screen""" 339 | screen_geo = screen.geometry() 340 | 341 | # Use 80% of screen size as maximum for windowed mode 342 | width = min(800, int(screen_geo.width() * 0.8)) 343 | height = min(600, int(screen_geo.height() * 0.8)) 344 | 345 | # Center the window on screen 346 | x = screen_geo.x() + (screen_geo.width() - width) // 2 347 | y = screen_geo.y() + (screen_geo.height() - height) // 2 348 | 349 | return QRect(x, y, width, height) 350 | 351 | def move_to_screen(self, screen_index): 352 | screens = QApplication.screens() 353 | if 0 <= screen_index < len(screens): 354 | self.current_screen = screen_index 355 | screen = screens[screen_index] 356 | 357 | if self.isFullScreen(): 358 | # In fullscreen, use entire screen 359 | self.setGeometry(screen.geometry()) 360 | else: 361 | # In windowed mode, use safe geometry 362 | self.setGeometry(self.get_safe_geometry(screen)) 363 | 364 | def showFullScreen(self): 365 | screens = QApplication.screens() 366 | if 0 <= self.current_screen < len(screens): 367 | screen = screens[self.current_screen] 368 | # Store current geometry before going fullscreen 369 | if not self.isFullScreen(): 370 | self.normal_geometry = self.geometry() 371 | # Set geometry to full screen 372 | self.setGeometry(screen.geometry()) 373 | super().showFullScreen() 374 | 375 | def showNormal(self): 376 | super().showNormal() 377 | screens = QApplication.screens() 378 | if 0 <= self.current_screen < len(screens): 379 | screen = screens[self.current_screen] 380 | if self.normal_geometry and screen.geometry().contains(self.normal_geometry.center()): 381 | # If previous position is valid, use it 382 | self.setGeometry(self.normal_geometry) 383 | else: 384 | # Otherwise use safe default position 385 | self.setGeometry(self.get_safe_geometry(screen)) 386 | 387 | def enable_ndi(self, enabled): 388 | if enabled: 389 | if not self.ndi_enabled: 390 | if self.ndi_receiver.initialize(): 391 | self.ndi_enabled = True 392 | self.ndi_timer.start() 393 | print("NDI initialized, searching for sources...") 394 | return True 395 | return False 396 | else: 397 | self.ndi_enabled = False 398 | self.ndi_timer.stop() 399 | self.ndi_frame = None 400 | self.update() 401 | return True 402 | 403 | def get_ndi_sources(self): 404 | if self.ndi_enabled: 405 | return self.ndi_receiver.find_sources() 406 | return [] 407 | 408 | def connect_to_ndi_source(self, index): 409 | if self.ndi_enabled: 410 | return self.ndi_receiver.connect_to_source(index) 411 | return False 412 | 413 | def add_field(self, field_id, x, y, width, height, title_text="", 414 | title_font_family="Arial", title_font_size=20, title_font_color="white", 415 | content_font_family="Arial", content_font_size=20, content_font_color="white", 416 | show_border=True): 417 | # Remove existing field if it exists 418 | if field_id in self.fields: 419 | old_field = self.fields[field_id] 420 | old_field.deleteLater() 421 | 422 | # Create new field 423 | field = Field(self, field_id, x, y, width, height, title_text, title_font_family, title_font_size, title_font_color, content_font_family, content_font_size, content_font_color, show_border) 424 | self.fields[field_id] = field 425 | field.show() 426 | 427 | # Send fields list to Companion if OSC client is enabled 428 | if hasattr(self, 'osc_client_enabled') and self.osc_client_enabled: 429 | self.osc_client.send_fields_list(self.fields) 430 | 431 | # Force a repaint to clear any artifacts 432 | self.update() 433 | 434 | def remove_field(self, field_id): 435 | if field_id in self.fields: 436 | self.fields[field_id].deleteLater() 437 | del self.fields[field_id] 438 | 439 | # Send fields list to Companion if OSC client is enabled 440 | if hasattr(self, 'osc_client_enabled') and self.osc_client_enabled: 441 | self.osc_client.send_fields_list(self.fields) 442 | 443 | def update_field(self, field_id, value): 444 | if field_id in self.fields: 445 | self.fields[field_id].content.text = value 446 | self.fields[field_id].update() 447 | 448 | # Send field update to Companion if OSC client is enabled 449 | if hasattr(self, 'osc_client_enabled') and self.osc_client_enabled: 450 | self.osc_client.send_field_update(field_id, value) 451 | 452 | def update_background(self): 453 | if not self.ndi_enabled: 454 | self.update() 455 | 456 | def enable_web_streaming(self, enabled: bool): 457 | """Enable or disable web streaming""" 458 | self.web_enabled = enabled 459 | if enabled: 460 | # Start web server if not already running 461 | if not self.server_thread or not self.server_thread.is_alive(): 462 | def run_server(): 463 | try: 464 | print("Starting web server...") 465 | # Get port from main window 466 | port = 8181 # Default port 467 | main_window = QApplication.activeWindow() 468 | if hasattr(main_window, 'web_port_input'): 469 | port = main_window.web_port_input.value() 470 | 471 | self.web_server.start_server(host="0.0.0.0", port=port) 472 | except Exception as e: 473 | print(f"Error starting web server: {e}") 474 | import traceback 475 | traceback.print_exc() 476 | 477 | self.server_thread = threading.Thread(target=run_server, daemon=True) 478 | self.server_thread.start() 479 | 480 | # Start frame broadcasting 481 | self.web_timer.start() 482 | else: 483 | # Stop frame broadcasting 484 | self.web_timer.stop() 485 | 486 | # TODO: Add clean shutdown of web server if needed 487 | # Currently relying on daemon thread to terminate with app 488 | 489 | def broadcast_frame(self): 490 | """Capture and broadcast current window content""" 491 | if not self.web_enabled: 492 | return 493 | 494 | try: 495 | # Create a QImage with the window size 496 | image = QImage(self.size(), QImage.Format_ARGB32) 497 | image.fill(self._background_color) 498 | 499 | # Create painter for the image 500 | painter = QPainter(image) 501 | 502 | # Draw all content 503 | self.render(painter) 504 | 505 | # Draw fields 506 | for field in self.fields.values(): 507 | field_pos = field.pos() 508 | field_size = field.size() 509 | 510 | # Create temporary image for field 511 | field_image = QImage(field_size, QImage.Format_ARGB32) 512 | field_image.fill(Qt.transparent) 513 | 514 | # Render field to its image 515 | field_painter = QPainter(field_image) 516 | field.render(field_painter) 517 | field_painter.end() 518 | 519 | # Draw field image at correct position 520 | painter.drawImage(field_pos, field_image) 521 | 522 | # Draw NDI frame if present 523 | if self.ndi_frame is not None: 524 | scaled_frame = self.get_scaled_ndi_frame() 525 | if scaled_frame is not None: 526 | painter.drawImage(scaled_frame[0], scaled_frame[1]) 527 | 528 | painter.end() 529 | 530 | # Convert to JPEG 531 | img_buffer = QBuffer() 532 | img_buffer.open(QBuffer.ReadWrite) 533 | image.save(img_buffer, "JPEG", quality=85) 534 | 535 | # Use stored web_server module 536 | if hasattr(self, 'web_server'): 537 | self.web_server.broadcast_frame(img_buffer.data().data()) 538 | else: 539 | print("Web server module not available") 540 | except Exception as e: 541 | print(f"Error broadcasting frame: {e}") 542 | import traceback 543 | traceback.print_exc() 544 | 545 | def update(self): 546 | super().update() 547 | if self.ndi_frame is not None: 548 | self.ndi_timer.start() 549 | else: 550 | self.ndi_timer.stop() 551 | 552 | class NDIlib_frame_type: 553 | NONE = 0 554 | VIDEO = 1 555 | AUDIO = 2 556 | METADATA = 3 557 | ERROR = 4 558 | STATUS_CHANGE = 100 559 | 560 | class NDIlib_source_t(ctypes.Structure): 561 | _fields_ = [ 562 | ("p_ndi_name", ctypes.c_char_p), 563 | ("p_url_address", ctypes.c_char_p) 564 | ] 565 | 566 | class NDIlib_find_create_t(ctypes.Structure): 567 | _fields_ = [ 568 | ("show_local_sources", ctypes.c_bool), 569 | ("p_groups", ctypes.c_char_p), 570 | ("p_extra_ips", ctypes.c_char_p) 571 | ] 572 | 573 | class NDIlib_recv_create_v3_t(ctypes.Structure): 574 | _fields_ = [ 575 | ("source_to_connect_to", NDIlib_source_t), 576 | ("color_format", ctypes.c_int), 577 | ("bandwidth", ctypes.c_int), 578 | ("allow_video_fields", ctypes.c_bool), 579 | ("p_ndi_recv_name", ctypes.c_char_p) 580 | ] 581 | 582 | class NDIlib_video_frame_v2_t(ctypes.Structure): 583 | _fields_ = [ 584 | ("xres", ctypes.c_int), 585 | ("yres", ctypes.c_int), 586 | ("FourCC", ctypes.c_int), 587 | ("frame_rate_N", ctypes.c_int), 588 | ("frame_rate_D", ctypes.c_int), 589 | ("picture_aspect_ratio", ctypes.c_float), 590 | ("frame_format_type", ctypes.c_int), 591 | ("timecode", ctypes.c_longlong), 592 | ("p_data", ctypes.c_void_p), 593 | ("line_stride_in_bytes", ctypes.c_int), 594 | ("p_metadata", ctypes.c_char_p), 595 | ("timestamp", ctypes.c_longlong) 596 | ] 597 | 598 | class NDIReceiver: 599 | def __init__(self): 600 | self.ndi = None 601 | self.finder = None 602 | self.receiver = None 603 | self.video_frame = None 604 | self.current_source = None 605 | self.sources = [] 606 | self.sources_ptr = None 607 | 608 | def initialize(self): 609 | # Load NDI library - try multiple possible paths 610 | ndi_paths = [ 611 | Path("C:/Program Files/NDI/NDI 6 Runtime/v6/Processing.NDI.Lib.x64.dll"), 612 | Path("C:/Program Files/NDI/NDI 6 SDK/Lib/x64/Processing.NDI.Lib.x64.dll"), 613 | Path("C:/Program Files/NDI/NDI 5 Runtime/Processing.NDI.Lib.x64.dll"), 614 | Path("C:/Program Files/NDI/NDI 5 SDK/Lib/x64/Processing.NDI.Lib.x64.dll"), 615 | Path("Processing.NDI.Lib.x64.dll") 616 | ] 617 | 618 | for path in ndi_paths: 619 | try: 620 | print(f"Trying NDI path: {path}") 621 | if path.exists(): 622 | self.ndi = ctypes.WinDLL(str(path)) 623 | print(f"Found NDI DLL at: {path}") 624 | print(f"Successfully loaded NDI from: {path}") 625 | break 626 | except Exception as e: 627 | print(f"Error loading {path}: {e}") 628 | continue 629 | 630 | if not self.ndi: 631 | print("Failed to initialize NDI: Could not find NDI Runtime. Please make sure NDI Runtime is installed.") 632 | return False 633 | 634 | # Set up function signatures 635 | self.ndi.NDIlib_initialize.restype = ctypes.c_bool 636 | 637 | self.ndi.NDIlib_find_create_v2.argtypes = [ctypes.POINTER(NDIlib_find_create_t)] 638 | self.ndi.NDIlib_find_create_v2.restype = ctypes.c_void_p 639 | 640 | self.ndi.NDIlib_find_get_current_sources.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)] 641 | self.ndi.NDIlib_find_get_current_sources.restype = ctypes.POINTER(NDIlib_source_t) 642 | 643 | self.ndi.NDIlib_recv_create_v3.argtypes = [ctypes.POINTER(NDIlib_recv_create_v3_t)] 644 | self.ndi.NDIlib_recv_create_v3.restype = ctypes.c_void_p 645 | 646 | self.ndi.NDIlib_recv_destroy.argtypes = [ctypes.c_void_p] 647 | self.ndi.NDIlib_recv_destroy.restype = None 648 | 649 | self.ndi.NDIlib_find_destroy.argtypes = [ctypes.c_void_p] 650 | self.ndi.NDIlib_find_destroy.restype = None 651 | 652 | print("Initializing NDI...") 653 | if not self.ndi.NDIlib_initialize(): 654 | print("Failed to initialize NDI library") 655 | return False 656 | 657 | print("Creating NDI finder...") 658 | find_create = NDIlib_find_create_t(show_local_sources=True, p_groups=None, p_extra_ips=None) 659 | self.finder = self.ndi.NDIlib_find_create_v2(ctypes.byref(find_create)) 660 | if not self.finder: 661 | print("Failed to create NDI finder") 662 | return False 663 | 664 | print("NDI initialized successfully") 665 | return True 666 | 667 | def find_sources(self): 668 | if not self.finder: 669 | print("Cannot find sources - NDI finder not initialized") 670 | return [] 671 | 672 | # Wait for sources to be discovered 673 | time.sleep(1.0) # Give NDI time to discover sources 674 | 675 | # Get number of sources 676 | num_sources = ctypes.c_uint32(0) 677 | self.sources_ptr = self.ndi.NDIlib_find_get_current_sources(self.finder, ctypes.byref(num_sources)) 678 | 679 | if not self.sources_ptr or num_sources.value == 0: 680 | self.sources = [] 681 | return [] 682 | 683 | # Convert sources to Python list 684 | self.sources = [] 685 | for i in range(num_sources.value): 686 | source = self.sources_ptr[i] 687 | name = source.p_ndi_name.decode('utf-8') if source.p_ndi_name else "Unknown" 688 | self.sources.append(name) 689 | 690 | print(f"Found {len(self.sources)} NDI sources") 691 | for source in self.sources: 692 | print(f"Found NDI source: {source}") 693 | 694 | return self.sources 695 | 696 | def connect_to_source(self, source_index): 697 | if source_index >= len(self.sources): 698 | print(f"Invalid source index: {source_index}") 699 | return False 700 | 701 | if not self.sources_ptr: 702 | print("No sources available") 703 | return False 704 | 705 | try: 706 | # Clean up existing receiver 707 | if self.receiver: 708 | self.ndi.NDIlib_recv_destroy(ctypes.c_void_p(self.receiver)) 709 | self.receiver = None 710 | self.current_source = None # Clear current source reference 711 | 712 | # Create receiver for the selected source 713 | source = self.sources_ptr[source_index] 714 | 715 | # Create receiver description 716 | recv_desc = NDIlib_recv_create_v3_t() 717 | recv_desc.source_to_connect_to = source 718 | recv_desc.color_format = 0 # BGRX_BGRA = 0 719 | recv_desc.bandwidth = 100 # Highest quality (100 = highest, -10 = audio only) 720 | recv_desc.allow_video_fields = False 721 | recv_desc.p_ndi_recv_name = None 722 | 723 | # Create receiver with proper error handling 724 | try: 725 | self.receiver = self.ndi.NDIlib_recv_create_v3(ctypes.byref(recv_desc)) 726 | if not self.receiver: 727 | print("Failed to create receiver") 728 | return False 729 | 730 | # Store current source index 731 | self.current_source = source_index 732 | print(f"Connected to NDI source: {self.sources[source_index]}") 733 | return True 734 | 735 | except Exception as e: 736 | print(f"Failed to create receiver: {e}") 737 | return False 738 | 739 | except Exception as e: 740 | print(f"Error connecting to source: {e}") 741 | return False 742 | 743 | def receive_frame(self): 744 | """Receive a frame from NDI source""" 745 | if not self.receiver or self.current_source is None: 746 | return None 747 | 748 | video_frame = NDIlib_video_frame_v2_t() 749 | if self.ndi.NDIlib_recv_capture_v2(ctypes.c_void_p(self.receiver), ctypes.byref(video_frame), None, None, 0) == NDIlib_frame_type.VIDEO: 750 | try: 751 | # Get frame dimensions and data 752 | width = video_frame.xres 753 | height = video_frame.yres 754 | buffer_size = width * height * 4 # RGBA format 755 | 756 | # Create buffer and copy frame data 757 | buffer = (ctypes.c_ubyte * buffer_size).from_address(video_frame.p_data) 758 | 759 | # Create QImage with correct format (NDI uses BGRA) 760 | image = QImage( 761 | buffer, 762 | width, 763 | height, 764 | video_frame.line_stride_in_bytes, 765 | QImage.Format_ARGB32 # This will handle BGRA correctly 766 | ) 767 | 768 | # Create a deep copy before freeing the frame 769 | copied_image = image.copy() 770 | 771 | # Free the frame 772 | self.ndi.NDIlib_recv_free_video_v2(ctypes.c_void_p(self.receiver), ctypes.byref(video_frame)) 773 | 774 | return copied_image 775 | except Exception as e: 776 | print(f"Error receiving NDI frame: {e}") 777 | self.ndi.NDIlib_recv_free_video_v2(ctypes.c_void_p(self.receiver), ctypes.byref(video_frame)) 778 | return None 779 | return None 780 | 781 | def cleanup(self): 782 | """Clean up NDI resources""" 783 | if self.receiver: 784 | try: 785 | self.ndi.NDIlib_recv_destroy(ctypes.c_void_p(self.receiver)) 786 | except: 787 | pass 788 | self.receiver = None 789 | 790 | if self.finder: 791 | try: 792 | self.ndi.NDIlib_find_destroy(ctypes.c_void_p(self.finder)) 793 | except: 794 | pass 795 | self.finder = None 796 | 797 | if self.ndi: 798 | try: 799 | self.ndi.NDIlib_destroy() 800 | except: 801 | pass 802 | self.ndi = None 803 | 804 | def __del__(self): 805 | self.cleanup() 806 | 807 | class MainWindow(QMainWindow): 808 | def __init__(self): 809 | super().__init__() 810 | self.setWindowTitle("StageDeck Beta - Control Panel") 811 | self.setGeometry(100, 100, 800, 600) 812 | 813 | # Initialize display window 814 | self.display_window = DisplayWindow() 815 | self.display_window.show() 816 | 817 | # Initialize OSC client for sending data to Bitfocus Companion 818 | self.osc_client = OSCClient(port=9292) 819 | self.osc_client_enabled = False 820 | 821 | # Initialize OSC server variables 822 | self.osc_port = 9191 823 | self.server = None 824 | self.server_thread = None 825 | 826 | self.web_port = 8181 827 | 828 | # Initialize timer variables 829 | self.timer_running = False 830 | self.remaining_time = 0 831 | self.overtime = 0 # Track overtime seconds 832 | self.blink_state = False 833 | self.timer = None 834 | self.blink_timer = QTimer() 835 | self.blink_timer.timeout.connect(self._toggle_timer_visibility) 836 | self.blink_visible = True 837 | self.in_overtime = False 838 | 839 | # Initialize sound variables 840 | self.warning_sound = None 841 | self.end_sound = None 842 | self.warning_sound_playing = False 843 | self.test_warning_playing = False 844 | self.test_end_playing = False 845 | self._sound_lock = threading.Lock() # Lock for thread safety 846 | 847 | # Default sound paths (using MP3s) 848 | self.warning_sound_path = get_resource_path(os.path.join('sounds', 'warning1.mp3')) 849 | self.end_sound_path = get_resource_path(os.path.join('sounds', 'end1.mp3')) 850 | 851 | print(f"Warning sound path: {self.warning_sound_path}") 852 | print(f"End sound path: {self.end_sound_path}") 853 | 854 | # Initialize pygame mixer 855 | pygame.mixer.init() 856 | pygame.mixer.music.set_volume(1.0) 857 | 858 | # Create main layout 859 | central_widget = QWidget() 860 | self.setCentralWidget(central_widget) 861 | layout = QVBoxLayout(central_widget) 862 | 863 | # Create tabs 864 | tabs = QTabWidget() 865 | layout.addWidget(tabs) 866 | 867 | # Settings tab 868 | settings_tab = QWidget() 869 | tabs.addTab(settings_tab, "Settings") 870 | settings_layout = QVBoxLayout(settings_tab) 871 | 872 | # OSC Server settings 873 | osc_group = QGroupBox("OSC Server Settings") 874 | osc_layout = QVBoxLayout(osc_group) 875 | 876 | port_layout = QHBoxLayout() 877 | port_layout.addWidget(QLabel("OSC Port:")) 878 | self.port_input = QSpinBox() 879 | self.port_input.setRange(1024, 65535) 880 | self.port_input.setValue(9191) 881 | self.port_input.valueChanged.connect(self.update_port) 882 | port_layout.addWidget(self.port_input) 883 | osc_layout.addLayout(port_layout) 884 | 885 | settings_layout.addWidget(osc_group) 886 | 887 | # OSC Client settings for Bitfocus Companion 888 | companion_group = QGroupBox("Bitfocus Companion Integration") 889 | companion_layout = QVBoxLayout(companion_group) 890 | 891 | # Enable/disable OSC client 892 | enable_layout = QHBoxLayout() 893 | enable_layout.addWidget(QLabel("Enable OSC Client:")) 894 | self.enable_osc_client_checkbox = QCheckBox() 895 | self.enable_osc_client_checkbox.setChecked(False) 896 | self.enable_osc_client_checkbox.stateChanged.connect(self.toggle_osc_client) 897 | enable_layout.addWidget(self.enable_osc_client_checkbox) 898 | companion_layout.addLayout(enable_layout) 899 | 900 | # IP address 901 | ip_layout = QHBoxLayout() 902 | ip_layout.addWidget(QLabel("Companion IP:")) 903 | self.companion_ip_input = QLineEdit("127.0.0.1") 904 | ip_layout.addWidget(self.companion_ip_input) 905 | companion_layout.addLayout(ip_layout) 906 | 907 | # Port 908 | companion_port_layout = QHBoxLayout() 909 | companion_port_layout.addWidget(QLabel("Companion OSC Port:")) 910 | self.companion_port_input = QSpinBox() 911 | self.companion_port_input.setRange(1024, 65535) 912 | self.companion_port_input.setValue(9292) 913 | companion_port_layout.addWidget(self.companion_port_input) 914 | companion_layout.addLayout(companion_port_layout) 915 | 916 | # Apply button 917 | apply_button = QPushButton("Apply Companion Settings") 918 | apply_button.clicked.connect(self.apply_companion_settings) 919 | companion_layout.addWidget(apply_button) 920 | 921 | settings_layout.addWidget(companion_group) 922 | 923 | # Web port settings 924 | web_port_layout = QHBoxLayout() 925 | web_port_layout.addWidget(QLabel("Web Port:")) 926 | self.web_port_input = QSpinBox() 927 | self.web_port_input.setRange(1024, 65535) 928 | self.web_port_input.setValue(8181) 929 | web_port_layout.addWidget(self.web_port_input) 930 | settings_layout.addLayout(web_port_layout) 931 | 932 | # Monitor selection 933 | monitor_layout = QHBoxLayout() 934 | monitor_layout.addWidget(QLabel("Display Monitor:")) 935 | self.monitor_combo = QComboBox() 936 | self.update_monitor_list() 937 | self.monitor_combo.currentIndexChanged.connect(self.update_monitor) 938 | monitor_layout.addWidget(self.monitor_combo) 939 | settings_layout.addLayout(monitor_layout) 940 | 941 | # Window controls 942 | window_controls = QHBoxLayout() 943 | 944 | fullscreen_button = QPushButton("Show Fullscreen") 945 | fullscreen_button.clicked.connect(self.display_window.showFullScreen) 946 | window_controls.addWidget(fullscreen_button) 947 | 948 | windowed_button = QPushButton("Show Windowed") 949 | windowed_button.clicked.connect(self.display_window.showNormal) 950 | window_controls.addWidget(windowed_button) 951 | 952 | settings_layout.addLayout(window_controls) 953 | 954 | # Background settings 955 | bg_group = QGroupBox("Background Settings") 956 | bg_layout = QVBoxLayout() 957 | 958 | # Color picker 959 | color_layout = QHBoxLayout() 960 | color_layout.addWidget(QLabel("Background Color:")) 961 | self.color_button = QPushButton() 962 | self.color_button.clicked.connect(self.choose_background_color) 963 | color_layout.addWidget(self.color_button) 964 | bg_layout.addLayout(color_layout) 965 | 966 | # Transparency checkbox 967 | self.transparent_bg = QCheckBox("Transparent Background") 968 | self.transparent_bg.stateChanged.connect(self.toggle_transparency) 969 | bg_layout.addLayout(QHBoxLayout()) 970 | bg_layout.itemAt(bg_layout.count()-1).layout().addWidget(self.transparent_bg) 971 | 972 | # NDI settings 973 | ndi_layout = QHBoxLayout() 974 | self.ndi_enabled = QCheckBox("Enable NDI") 975 | self.ndi_enabled.stateChanged.connect(self.update_ndi_enabled) 976 | ndi_layout.addWidget(self.ndi_enabled) 977 | 978 | self.ndi_source_combo = QComboBox() 979 | self.ndi_source_combo.currentIndexChanged.connect(self.update_ndi_source) 980 | ndi_layout.addWidget(self.ndi_source_combo) 981 | bg_layout.addLayout(ndi_layout) 982 | 983 | # Web streaming settings 984 | web_group = QGroupBox("Web Streaming") 985 | web_layout = QVBoxLayout() 986 | 987 | # Enable checkbox 988 | self.web_enabled = QCheckBox("Enable Web Streaming") 989 | self.web_enabled.stateChanged.connect(self.toggle_web_streaming) 990 | web_layout.addWidget(self.web_enabled) 991 | 992 | web_group.setLayout(web_layout) 993 | settings_layout.addWidget(web_group) 994 | 995 | bg_group.setLayout(bg_layout) 996 | settings_layout.addWidget(bg_group) 997 | 998 | tabs.addTab(settings_tab, "Settings") 999 | 1000 | # Fields tab 1001 | fields_tab = QWidget() 1002 | fields_layout = QVBoxLayout(fields_tab) 1003 | 1004 | # Field list and ID 1005 | field_list_layout = QHBoxLayout() 1006 | 1007 | # Field ID input 1008 | field_id_layout = QVBoxLayout() 1009 | field_id_layout.addWidget(QLabel("Field ID:")) 1010 | self.field_id_input = QLineEdit() 1011 | field_id_layout.addWidget(self.field_id_input) 1012 | field_list_layout.addLayout(field_id_layout) 1013 | 1014 | # Field list 1015 | field_list_group = QGroupBox("Fields") 1016 | field_list_inner = QVBoxLayout() 1017 | self.fields_list = QListWidget() 1018 | self.fields_list.currentItemChanged.connect(self.load_field) 1019 | field_list_inner.addWidget(self.fields_list) 1020 | field_list_group.setLayout(field_list_inner) 1021 | field_list_layout.addWidget(field_list_group) 1022 | 1023 | fields_layout.addLayout(field_list_layout) 1024 | 1025 | # Field editor 1026 | field_editor = QGroupBox("Field Properties") 1027 | editor_layout = QGridLayout() 1028 | 1029 | # Position and size 1030 | editor_layout.addWidget(QLabel("X:"), 0, 0) 1031 | self.x_input = QSpinBox() 1032 | self.x_input.setRange(-9999, 9999) 1033 | editor_layout.addWidget(self.x_input, 0, 1) 1034 | 1035 | editor_layout.addWidget(QLabel("Y:"), 0, 2) 1036 | self.y_input = QSpinBox() 1037 | self.y_input.setRange(-9999, 9999) 1038 | editor_layout.addWidget(self.y_input, 0, 3) 1039 | 1040 | editor_layout.addWidget(QLabel("Width:"), 1, 0) 1041 | self.width_input = QSpinBox() 1042 | self.width_input.setRange(1, 9999) 1043 | self.width_input.setValue(200) # Default width 1044 | editor_layout.addWidget(self.width_input, 1, 1) 1045 | 1046 | editor_layout.addWidget(QLabel("Height:"), 1, 2) 1047 | self.height_input = QSpinBox() 1048 | self.height_input.setRange(1, 9999) 1049 | self.height_input.setValue(200) # Default height 1050 | editor_layout.addWidget(self.height_input, 1, 3) 1051 | 1052 | # Show border checkbox 1053 | self.show_border = QCheckBox("Show Border") 1054 | self.show_border.setChecked(True) # Default to showing border 1055 | editor_layout.addWidget(self.show_border, 2, 0, 1, 2) 1056 | 1057 | # Title settings 1058 | editor_layout.addWidget(QLabel("Title:"), 3, 0) 1059 | self.title_input = QLineEdit() 1060 | editor_layout.addWidget(self.title_input, 3, 1, 1, 3) 1061 | 1062 | # Title font settings 1063 | editor_layout.addWidget(QLabel("Title Font:"), 4, 0) 1064 | self.title_font_combo = QFontComboBox() 1065 | self.title_font_combo.setCurrentText("Arial") # Default font 1066 | editor_layout.addWidget(self.title_font_combo, 4, 1) 1067 | 1068 | self.title_size_input = QSpinBox() 1069 | self.title_size_input.setRange(6, 72) 1070 | self.title_size_input.setValue(20) # Default size 1071 | editor_layout.addWidget(self.title_size_input, 4, 2) 1072 | 1073 | self.title_color_button = QPushButton() 1074 | self.title_color_button.setText("#FFFFFF") # Default white 1075 | self.title_color_button.clicked.connect(self.choose_title_font_color) 1076 | editor_layout.addWidget(self.title_color_button, 4, 3) 1077 | 1078 | # Content font settings 1079 | editor_layout.addWidget(QLabel("Content Font:"), 5, 0) 1080 | self.content_font_combo = QFontComboBox() 1081 | self.content_font_combo.setCurrentText("Arial") # Default font 1082 | editor_layout.addWidget(self.content_font_combo, 5, 1) 1083 | 1084 | self.content_size_input = QSpinBox() 1085 | self.content_size_input.setRange(6, 72) 1086 | self.content_size_input.setValue(20) # Default size 1087 | editor_layout.addWidget(self.content_size_input, 5, 2) 1088 | 1089 | self.content_color_button = QPushButton() 1090 | self.content_color_button.setText("#FFFFFF") # Default white 1091 | self.content_color_button.clicked.connect(self.choose_content_font_color) 1092 | editor_layout.addWidget(self.content_color_button, 5, 3) 1093 | 1094 | field_editor.setLayout(editor_layout) 1095 | fields_layout.addWidget(field_editor) 1096 | 1097 | # Field actions 1098 | actions_layout = QHBoxLayout() 1099 | 1100 | add_button = QPushButton("Add Field") 1101 | add_button.clicked.connect(self.add_field) 1102 | actions_layout.addWidget(add_button) 1103 | 1104 | update_button = QPushButton("Update Field") 1105 | update_button.clicked.connect(self.update_field) 1106 | actions_layout.addWidget(update_button) 1107 | 1108 | delete_button = QPushButton("Delete Field") 1109 | delete_button.clicked.connect(self.delete_field) 1110 | actions_layout.addWidget(delete_button) 1111 | 1112 | fields_layout.addLayout(actions_layout) 1113 | 1114 | tabs.addTab(fields_tab, "Fields") 1115 | 1116 | # Timer tab 1117 | timer_tab = QWidget() 1118 | timer_layout = QVBoxLayout(timer_tab) 1119 | 1120 | # Timer field controls 1121 | field_controls = QHBoxLayout() 1122 | self.create_timer_button = QPushButton("Create Timer Field") 1123 | self.create_timer_button.clicked.connect(self.create_timer_field) 1124 | field_controls.addWidget(self.create_timer_button) 1125 | 1126 | self.remove_timer_button = QPushButton("Remove Timer Field") 1127 | self.remove_timer_button.clicked.connect(self.remove_timer_field) 1128 | field_controls.addWidget(self.remove_timer_button) 1129 | 1130 | timer_layout.addLayout(field_controls) 1131 | 1132 | # Timer input fields 1133 | timer_input_layout = QHBoxLayout() 1134 | 1135 | # Hours input 1136 | hours_layout = QVBoxLayout() 1137 | hours_layout.addWidget(QLabel("Hours")) 1138 | self.hours_input = QSpinBox() 1139 | self.hours_input.setRange(0, 99) 1140 | hours_layout.addWidget(self.hours_input) 1141 | timer_input_layout.addLayout(hours_layout) 1142 | 1143 | # Minutes input 1144 | minutes_layout = QVBoxLayout() 1145 | minutes_layout.addWidget(QLabel("Minutes")) 1146 | self.minutes_input = QSpinBox() 1147 | self.minutes_input.setRange(0, 59) 1148 | minutes_layout.addWidget(self.minutes_input) 1149 | timer_input_layout.addLayout(minutes_layout) 1150 | 1151 | # Seconds input 1152 | seconds_layout = QVBoxLayout() 1153 | seconds_layout.addWidget(QLabel("Seconds")) 1154 | self.seconds_input = QSpinBox() 1155 | self.seconds_input.setRange(0, 59) 1156 | seconds_layout.addWidget(self.seconds_input) 1157 | timer_input_layout.addLayout(seconds_layout) 1158 | 1159 | timer_layout.addLayout(timer_input_layout) 1160 | 1161 | # Warning settings 1162 | warning_group = QGroupBox("Warning Settings") 1163 | warning_layout = QVBoxLayout() 1164 | 1165 | # Warning time 1166 | warning_time_layout = QHBoxLayout() 1167 | warning_controls_layout = QVBoxLayout() 1168 | 1169 | warning_checkbox_layout = QHBoxLayout() 1170 | self.enable_warning = QCheckBox("Enable warning (turn yellow) at:") 1171 | self.enable_warning.setChecked(True) 1172 | warning_checkbox_layout.addWidget(self.enable_warning) 1173 | 1174 | self.enable_warning_sound = QCheckBox("Play sound") 1175 | self.enable_warning_sound.setChecked(True) 1176 | warning_checkbox_layout.addWidget(self.enable_warning_sound) 1177 | warning_controls_layout.addLayout(warning_checkbox_layout) 1178 | 1179 | warning_sound_layout = QHBoxLayout() 1180 | self.warning_sound_combo = QComboBox() 1181 | self.warning_sound_combo.addItems(["Sound 1", "Sound 2", "Sound 3", "Custom..."]) 1182 | self.warning_sound_combo.currentTextChanged.connect(self.warning_sound_changed) 1183 | warning_sound_layout.addWidget(self.warning_sound_combo) 1184 | 1185 | self.warning_sound_browse = QPushButton("Browse...") 1186 | self.warning_sound_browse.clicked.connect(self.browse_warning_sound) 1187 | warning_sound_layout.addWidget(self.warning_sound_browse) 1188 | 1189 | self.warning_test_button = QPushButton("Test") 1190 | self.warning_test_button.clicked.connect(self.test_warning_sound) 1191 | warning_sound_layout.addWidget(self.warning_test_button) 1192 | warning_controls_layout.addLayout(warning_sound_layout) 1193 | 1194 | warning_time_layout.addLayout(warning_controls_layout) 1195 | 1196 | self.warning_time = QSpinBox() 1197 | self.warning_time.setRange(0, 300) 1198 | self.warning_time.setValue(30) # Default 30 seconds 1199 | self.warning_time.setSuffix(" sec") 1200 | warning_time_layout.addWidget(self.warning_time) 1201 | warning_layout.addLayout(warning_time_layout) 1202 | 1203 | # End warning 1204 | end_warning_layout = QHBoxLayout() 1205 | end_controls_layout = QVBoxLayout() 1206 | 1207 | end_checkbox_layout = QHBoxLayout() 1208 | self.enable_end_warning = QCheckBox("Enable blinking red when done") 1209 | self.enable_end_warning.setChecked(True) 1210 | end_checkbox_layout.addWidget(self.enable_end_warning) 1211 | 1212 | self.enable_end_sound = QCheckBox("Play sound") 1213 | self.enable_end_sound.setChecked(True) 1214 | end_checkbox_layout.addWidget(self.enable_end_sound) 1215 | end_controls_layout.addLayout(end_checkbox_layout) 1216 | 1217 | end_sound_layout = QHBoxLayout() 1218 | self.end_sound_combo = QComboBox() 1219 | self.end_sound_combo.addItems(["Sound 1", "Sound 2", "Sound 3", "Custom..."]) 1220 | self.end_sound_combo.currentTextChanged.connect(self.end_sound_changed) 1221 | end_sound_layout.addWidget(self.end_sound_combo) 1222 | 1223 | self.end_sound_browse = QPushButton("Browse...") 1224 | self.end_sound_browse.clicked.connect(self.browse_end_sound) 1225 | end_sound_layout.addWidget(self.end_sound_browse) 1226 | 1227 | self.end_test_button = QPushButton("Test") 1228 | self.end_test_button.clicked.connect(self.test_end_sound) 1229 | end_sound_layout.addWidget(self.end_test_button) 1230 | end_controls_layout.addLayout(end_sound_layout) 1231 | 1232 | end_warning_layout.addLayout(end_controls_layout) 1233 | warning_layout.addLayout(end_warning_layout) 1234 | 1235 | # Overtime settings 1236 | overtime_layout = QHBoxLayout() 1237 | self.enable_overtime = QCheckBox("Enable Overtime Counting") 1238 | self.enable_overtime.setChecked(True) 1239 | overtime_layout.addWidget(self.enable_overtime) 1240 | warning_layout.addLayout(overtime_layout) 1241 | 1242 | warning_group.setLayout(warning_layout) 1243 | timer_layout.addWidget(warning_group) 1244 | 1245 | # Timer presets 1246 | preset_layout = QHBoxLayout() 1247 | preset_layout.addWidget(QLabel("Presets:")) 1248 | 1249 | for minutes in [5, 10, 15, 20, 30, 45, 60]: 1250 | button = QPushButton(f"{minutes} min") 1251 | button.clicked.connect(lambda checked, m=minutes: self.set_timer_duration(m * 60)) 1252 | preset_layout.addWidget(button) 1253 | 1254 | timer_layout.addLayout(preset_layout) 1255 | 1256 | # Timer control buttons 1257 | timer_controls_layout = QHBoxLayout() 1258 | 1259 | self.timer_start_button = QPushButton("Start") 1260 | self.timer_start_button.clicked.connect(self.start_timer) 1261 | timer_controls_layout.addWidget(self.timer_start_button) 1262 | 1263 | self.timer_pause_button = QPushButton("Pause") 1264 | self.timer_pause_button.clicked.connect(self.pause_timer) 1265 | timer_controls_layout.addWidget(self.timer_pause_button) 1266 | 1267 | self.timer_stop_button = QPushButton("Stop") 1268 | self.timer_stop_button.clicked.connect(self.stop_timer) 1269 | self.timer_stop_button.setEnabled(True) 1270 | timer_controls_layout.addWidget(self.timer_stop_button) 1271 | 1272 | timer_layout.addLayout(timer_controls_layout) 1273 | 1274 | # Timer display 1275 | self.timer_display = QLabel("00:00:00") 1276 | self.timer_display.setAlignment(Qt.AlignCenter) 1277 | self.timer_display.setFont(QFont("Arial", 24)) 1278 | timer_layout.addWidget(self.timer_display) 1279 | 1280 | tabs.addTab(timer_tab, "Timer") 1281 | 1282 | # Load saved configuration 1283 | self.load_config() 1284 | 1285 | # Setup OSC server 1286 | self.start_osc_server() 1287 | 1288 | # Show main window 1289 | self.show() 1290 | 1291 | def toggle_transparency(self, state): 1292 | """Toggle window transparency""" 1293 | if state == Qt.Checked: 1294 | # Enable transparency 1295 | self.display_window._background_color = QColor(0, 0, 0, 0) # Fully transparent 1296 | self.display_window.setWindowFlags(self.display_window.windowFlags() | Qt.FramelessWindowHint) 1297 | else: 1298 | # Disable transparency 1299 | self.display_window._background_color = QColor(0, 0, 0, 255) # Opaque black 1300 | self.display_window.setWindowFlags(self.display_window.windowFlags() & ~Qt.FramelessWindowHint) 1301 | 1302 | # Show window again since changing flags hides it 1303 | self.display_window.show() 1304 | self.display_window.update_background() 1305 | 1306 | def update_ndi_enabled(self, state): 1307 | enabled = state == Qt.Checked 1308 | if self.display_window.enable_ndi(enabled): 1309 | if enabled: 1310 | # Update source list 1311 | sources = self.display_window.get_ndi_sources() 1312 | self.ndi_source_combo.clear() 1313 | self.ndi_source_combo.addItems(sources) 1314 | else: 1315 | self.ndi_enabled.setChecked(False) 1316 | 1317 | def update_ndi_source(self, index): 1318 | if index >= 0: 1319 | self.display_window.connect_to_ndi_source(index) 1320 | 1321 | def update_monitor_list(self): 1322 | self.monitor_combo.clear() 1323 | for i, screen in enumerate(QApplication.screens()): 1324 | geometry = screen.geometry() 1325 | self.monitor_combo.addItem(f"Monitor {i+1} ({geometry.width()}x{geometry.height()} at {geometry.x()},{geometry.y()})") 1326 | 1327 | def update_monitor(self, index): 1328 | self.display_window.move_to_screen(index) 1329 | self.save_config() 1330 | 1331 | def update_port(self, new_port): 1332 | """Update OSC server port""" 1333 | if new_port != self.osc_port: 1334 | self.osc_port = new_port 1335 | self.start_osc_server() 1336 | 1337 | def start_osc_server(self): 1338 | """Start the OSC server with proper cleanup and error handling""" 1339 | try: 1340 | # Clean up existing server if any 1341 | self.cleanup_osc_server() 1342 | 1343 | # Create dispatcher and register handlers 1344 | dispatcher = Dispatcher() 1345 | dispatcher.map("/field/*", self.handle_osc_message) 1346 | 1347 | # Create and start new server 1348 | self.server = BlockingOSCUDPServer(("0.0.0.0", self.osc_port), dispatcher) 1349 | print(f"OSC Server listening on port {self.osc_port}") 1350 | 1351 | # Start server in a thread 1352 | self.server_thread = threading.Thread(target=self.server.serve_forever) 1353 | self.server_thread.daemon = True 1354 | self.server_thread.start() 1355 | 1356 | except OSError as e: 1357 | if hasattr(e, 'winerror') and e.winerror == 10048: # Port already in use 1358 | print(f"Error: OSC port {self.osc_port} is already in use. Please close any other applications using this port.") 1359 | # Try next available port 1360 | self.osc_port += 1 1361 | self.port_input.setValue(self.osc_port) 1362 | self.start_osc_server() # Retry with new port 1363 | else: 1364 | print(f"Error starting OSC server: {e}") 1365 | except Exception as e: 1366 | print(f"Error starting OSC server: {e}") 1367 | 1368 | def cleanup_osc_server(self): 1369 | """Clean up OSC server resources""" 1370 | if hasattr(self, 'server') and self.server: 1371 | self.server.shutdown() 1372 | if self.server_thread and self.server_thread.is_alive(): 1373 | self.server_thread.join() 1374 | self.server = None 1375 | self.server_thread = None 1376 | 1377 | def handle_osc_message(self, address, *args): 1378 | """Handle incoming OSC messages""" 1379 | try: 1380 | # Split address into parts 1381 | parts = address.split('/') 1382 | if len(parts) < 3: # Need at least /field/field_id 1383 | return 1384 | 1385 | field_id = parts[2] # Get field ID from /field/field_id 1386 | 1387 | # Get or create field 1388 | field = self.display_window.fields.get(field_id) 1389 | if not field: 1390 | # Create field on main thread 1391 | if QThread.currentThread() != QApplication.instance().thread(): 1392 | # Schedule field creation on main thread 1393 | QMetaObject.invokeMethod(self, "_create_field_from_osc", 1394 | Qt.ConnectionType.BlockingQueuedConnection, 1395 | Q_ARG(str, field_id)) 1396 | field = self.display_window.fields.get(field_id) 1397 | if not field: 1398 | return 1399 | else: 1400 | self._create_field_from_osc(field_id) 1401 | field = self.display_window.fields.get(field_id) 1402 | if not field: 1403 | return 1404 | 1405 | # Handle different message types based on address 1406 | if len(parts) > 3: 1407 | property_name = parts[3] 1408 | if len(args) > 0: 1409 | value = args[0] 1410 | 1411 | if property_name == "content": 1412 | field.content.text = str(value) 1413 | elif property_name == "title": 1414 | field.title.text = str(value) 1415 | elif property_name == "x": 1416 | field.move(int(value), field.y()) 1417 | elif property_name == "y": 1418 | field.move(field.x(), int(value)) 1419 | elif property_name == "width": 1420 | field.resize(int(value), field.height()) 1421 | elif property_name == "height": 1422 | field.resize(field.width(), int(value)) 1423 | elif property_name == "font_size": 1424 | field.content.font_size = int(value) 1425 | field.title.font_size = int(value) 1426 | elif property_name == "font_color": 1427 | field.content.font_color = str(value) 1428 | field.title.font_color = str(value) 1429 | elif property_name == "show_border": 1430 | field.show_border = bool(value) 1431 | 1432 | field.update() 1433 | self.display_window.update() 1434 | 1435 | except Exception as e: 1436 | print(f"Error handling OSC message: {e}") 1437 | 1438 | @pyqtSlot(str) 1439 | def _create_field_from_osc(self, field_id): 1440 | """Create a new field from OSC message on the main thread""" 1441 | try: 1442 | field = self.display_window.add_field( 1443 | field_id, 1444 | x=200, y=10, # Default position 1445 | width=300, height=200, # Default size 1446 | title_text=field_id, 1447 | title_font_family="Arial", 1448 | title_font_size=24, 1449 | title_font_color="white", 1450 | content_font_family="Arial", 1451 | content_font_size=32, 1452 | content_font_color="white", 1453 | show_border=True 1454 | ) 1455 | self.fields_list.addItem(field_id) 1456 | self.save_config() # Save the new field configuration 1457 | except Exception as e: 1458 | print(f"Error creating field from OSC: {e}") 1459 | 1460 | def choose_background_color(self): 1461 | color = QColorDialog.getColor(QColor(self.display_window._background_color)) 1462 | if color.isValid(): 1463 | self.display_window.set_background_color(color.name()) 1464 | self.save_config() 1465 | 1466 | def choose_title_font_color(self): 1467 | color = QColorDialog.getColor(QColor(self.title_color_button.text())) 1468 | if color.isValid(): 1469 | self.title_color_button.setText(color.name()) 1470 | 1471 | def choose_content_font_color(self): 1472 | color = QColorDialog.getColor(QColor(self.content_color_button.text())) 1473 | if color.isValid(): 1474 | self.content_color_button.setText(color.name()) 1475 | 1476 | def load_field(self, current, previous): 1477 | if not current: 1478 | return 1479 | 1480 | field_id = current.text() 1481 | if field_id not in self.display_window.fields: 1482 | return 1483 | 1484 | field = self.display_window.fields[field_id] 1485 | self.field_id_input.setText(field_id) 1486 | self.x_input.setValue(field.get_x()) 1487 | self.y_input.setValue(field.get_y()) 1488 | self.width_input.setValue(field.width()) 1489 | self.height_input.setValue(field.height()) 1490 | self.show_border.setChecked(field.show_border) 1491 | self.title_input.setText(field.title.text) 1492 | self.title_font_combo.setCurrentText(field.title.font_family) 1493 | self.title_size_input.setValue(field.title.font_size) 1494 | self.title_color_button.setText(field.title.font_color) 1495 | self.content_font_combo.setCurrentText(field.content.font_family) 1496 | self.content_size_input.setValue(field.content.font_size) 1497 | self.content_color_button.setText(field.content.font_color) 1498 | 1499 | def add_field(self): 1500 | field_id = self.field_id_input.text() 1501 | if not field_id: 1502 | return 1503 | 1504 | self.display_window.add_field( 1505 | field_id, 1506 | self.x_input.value(), 1507 | self.y_input.value(), 1508 | self.width_input.value(), 1509 | self.height_input.value(), 1510 | self.title_input.text(), 1511 | self.title_font_combo.currentText(), 1512 | self.title_size_input.value(), 1513 | self.title_color_button.text(), 1514 | self.content_font_combo.currentText(), 1515 | self.content_size_input.value(), 1516 | self.content_color_button.text(), 1517 | self.show_border.isChecked() 1518 | ) 1519 | 1520 | # Update field list 1521 | if self.fields_list.findItems(field_id, Qt.MatchExactly): 1522 | return 1523 | self.fields_list.addItem(field_id) 1524 | self.save_config() 1525 | 1526 | def update_field(self): 1527 | current = self.fields_list.currentItem() 1528 | if not current: 1529 | return 1530 | 1531 | field_id = current.text() 1532 | self.display_window.add_field( 1533 | field_id, 1534 | self.x_input.value(), 1535 | self.y_input.value(), 1536 | self.width_input.value(), 1537 | self.height_input.value(), 1538 | self.title_input.text(), 1539 | self.title_font_combo.currentText(), 1540 | self.title_size_input.value(), 1541 | self.title_color_button.text(), 1542 | self.content_font_combo.currentText(), 1543 | self.content_size_input.value(), 1544 | self.content_color_button.text(), 1545 | self.show_border.isChecked() 1546 | ) 1547 | self.save_config() 1548 | 1549 | # Send field update to Companion if OSC client is enabled 1550 | if hasattr(self, 'osc_client_enabled') and self.osc_client_enabled: 1551 | self.osc_client.send_field_update(field_id, self.display_window.fields[field_id].content.text) 1552 | 1553 | def delete_field(self): 1554 | current = self.fields_list.currentItem() 1555 | if not current: 1556 | return 1557 | 1558 | field_id = current.text() 1559 | self.display_window.remove_field(field_id) 1560 | self.fields_list.takeItem(self.fields_list.row(current)) 1561 | self.save_config() 1562 | 1563 | def closeEvent(self, event): 1564 | """Handle application shutdown""" 1565 | # Clean up OSC server 1566 | self.cleanup_osc_server() 1567 | 1568 | # Clean up NDI 1569 | if self.display_window.ndi_receiver: 1570 | self.display_window.ndi_receiver.cleanup() 1571 | 1572 | # Save fields 1573 | self.save_config() 1574 | 1575 | # Close display window 1576 | self.display_window.close() 1577 | 1578 | event.accept() 1579 | 1580 | def log_message(self, message): 1581 | """Log a message - now just prints to console""" 1582 | print(f"[Companion Viewer] {message}") 1583 | 1584 | def load_config(self): 1585 | try: 1586 | with open('config.json', 'r') as f: 1587 | config = json.load(f) 1588 | 1589 | # Load background color 1590 | if 'background_color' in config: 1591 | self.display_window.set_background_color(config['background_color']) 1592 | 1593 | # Load fields 1594 | if 'fields' in config: 1595 | for field_id, field_data in config['fields'].items(): 1596 | self.display_window.add_field( 1597 | field_id, 1598 | field_data['x'], 1599 | field_data['y'], 1600 | field_data['width'], 1601 | field_data['height'], 1602 | field_data.get('title_text', ''), 1603 | field_data.get('title_font_family', 'Arial'), 1604 | field_data.get('title_font_size', 20), 1605 | field_data.get('title_font_color', 'white'), 1606 | field_data.get('content_font_family', 'Arial'), 1607 | field_data.get('content_font_size', 20), 1608 | field_data.get('content_font_color', 'white'), 1609 | field_data.get('show_border', True) 1610 | ) 1611 | # Add to field list 1612 | self.fields_list.addItem(field_id) 1613 | 1614 | except FileNotFoundError: 1615 | pass 1616 | 1617 | def save_config(self): 1618 | config = { 1619 | 'background_color': self.display_window._background_color.name(), 1620 | 'fields': {} 1621 | } 1622 | 1623 | # Save fields 1624 | for field_id, field in self.display_window.fields.items(): 1625 | config['fields'][field_id] = { 1626 | 'x': field.get_x(), 1627 | 'y': field.get_y(), 1628 | 'width': field.width(), 1629 | 'height': field.height(), 1630 | 'title_text': field.title.text, 1631 | 'title_font_family': field.title.font_family, 1632 | 'title_font_size': field.title.font_size, 1633 | 'title_font_color': field.title.font_color, 1634 | 'content_font_family': field.content.font_family, 1635 | 'content_font_size': field.content.font_size, 1636 | 'content_font_color': field.content.font_color, 1637 | 'show_border': field.show_border 1638 | } 1639 | 1640 | with open('config.json', 'w') as f: 1641 | json.dump(config, f, indent=4) 1642 | 1643 | def add_field_to_list(self, field_id, field): 1644 | """Add field to the fields list widget""" 1645 | # Add to fields list widget if not already there 1646 | if not self.fields_list.findItems(field_id, Qt.MatchExactly): 1647 | item = QListWidgetItem(field_id) 1648 | item.setFlags(item.flags() | Qt.ItemIsEditable) 1649 | self.fields_list.addItem(item) 1650 | self.fields_list.setCurrentItem(item) 1651 | 1652 | def remove_field_from_list(self, field_id): 1653 | """Remove field from the fields list widget""" 1654 | # Find and remove from fields list widget 1655 | items = self.fields_list.findItems(field_id, Qt.MatchExactly) 1656 | for item in items: 1657 | self.fields_list.takeItem(self.fields_list.row(item)) 1658 | 1659 | # Update selection 1660 | if self.fields_list.count() > 0: 1661 | self.fields_list.setCurrentRow(0) 1662 | 1663 | def create_timer_field(self): 1664 | """Create timer field if it doesn't exist""" 1665 | if "timer" not in self.display_window.fields: 1666 | # Stop any running timer before creating new field 1667 | if self.timer_running: 1668 | self.stop_timer() 1669 | 1670 | # Create the field 1671 | field = self.display_window.add_field( 1672 | "timer", 1673 | x=200, y=10, # Position near top 1674 | width=300, height=200, # Larger default size 1675 | title_text="Timer", 1676 | title_font_size=24, 1677 | content_font_size=32 1678 | ) 1679 | 1680 | # Add to fields list 1681 | self.add_field_to_list("timer", field) 1682 | 1683 | # Update the field with current time 1684 | self.update_timer_display() 1685 | 1686 | # Update button states 1687 | self.create_timer_button.setEnabled(False) 1688 | self.remove_timer_button.setEnabled(True) 1689 | 1690 | def remove_timer_field(self): 1691 | """Remove timer field if it exists""" 1692 | if "timer" in self.display_window.fields: 1693 | # Stop any running timer before removing field 1694 | if self.timer_running: 1695 | self.stop_timer() 1696 | 1697 | # Remove the field 1698 | self.display_window.remove_field("timer") 1699 | 1700 | # Remove from fields list 1701 | self.remove_field_from_list("timer") 1702 | 1703 | # Update button states 1704 | self.create_timer_button.setEnabled(True) 1705 | self.remove_timer_button.setEnabled(False) 1706 | 1707 | def update_button_states(self): 1708 | """Update timer field button states based on field existence""" 1709 | has_timer = "timer" in self.display_window.fields 1710 | self.create_timer_button.setEnabled(not has_timer) 1711 | self.remove_timer_button.setEnabled(has_timer) 1712 | 1713 | def start_timer(self): 1714 | """Start the timer""" 1715 | if not self.timer_running: 1716 | # Calculate total seconds 1717 | total_seconds = (self.hours_input.value() * 3600 + 1718 | self.minutes_input.value() * 60 + 1719 | self.seconds_input.value()) 1720 | 1721 | if total_seconds <= 0: 1722 | return 1723 | 1724 | self.remaining_time = total_seconds 1725 | self.timer_running = True 1726 | self.in_overtime = False 1727 | self.overtime = 0 1728 | 1729 | if not self.timer: 1730 | self.timer = QTimer() 1731 | self.timer.timeout.connect(self.update_timer) 1732 | self.timer.start(1000) # Update every second 1733 | 1734 | # Update button states 1735 | self.timer_start_button.setText("Stop") 1736 | self.timer_pause_button.setEnabled(True) 1737 | self.timer_stop_button.setEnabled(True) 1738 | 1739 | # Reset any previous styling 1740 | if "timer" in self.display_window.fields: 1741 | self.update_timer_field_color("white") 1742 | self.display_window.fields["timer"].setVisible(True) 1743 | else: 1744 | # Stop was clicked (since button text is "Stop") 1745 | self.stop_timer() 1746 | 1747 | def stop_timer(self): 1748 | """Stop the timer""" 1749 | if self.timer: 1750 | self.timer.stop() 1751 | self.timer_running = False 1752 | self.in_overtime = False 1753 | self.overtime = 0 1754 | if hasattr(self, 'blink_timer') and self.blink_timer: 1755 | self.blink_timer.stop() 1756 | if "timer" in self.display_window.fields: 1757 | self.display_window.fields["timer"].setVisible(True) 1758 | self.update_timer_field_color("white") 1759 | 1760 | # Reset timer to input values 1761 | total_seconds = (self.hours_input.value() * 3600 + 1762 | self.minutes_input.value() * 60 + 1763 | self.seconds_input.value()) 1764 | self.remaining_time = total_seconds 1765 | self.update_timer_display() 1766 | 1767 | # Update button states 1768 | self.timer_start_button.setText("Start") 1769 | self.timer_pause_button.setEnabled(False) 1770 | self.timer_stop_button.setEnabled(True) 1771 | 1772 | # Stop any playing sounds 1773 | self.stop_warning_sound() 1774 | 1775 | def pause_timer(self): 1776 | """Pause the timer""" 1777 | if self.timer_running: 1778 | self.timer.stop() 1779 | self.timer_running = False 1780 | self.timer_pause_button.setText("Resume") 1781 | else: 1782 | self.timer.start(1000) 1783 | self.timer_running = True 1784 | self.timer_pause_button.setText("Pause") 1785 | 1786 | def update_timer(self): 1787 | """Update timer countdown""" 1788 | if not self.timer_running: 1789 | return 1790 | 1791 | if self.remaining_time > 0: 1792 | # Stop warning sound early at 3 seconds 1793 | if self.remaining_time == 3: 1794 | self.stop_warning_sound() 1795 | 1796 | self.remaining_time -= 1 1797 | 1798 | # Change color and play warning sound when warning time is reached 1799 | if self.enable_warning.isChecked() and self.remaining_time <= self.warning_time.value() and self.remaining_time > 1: 1800 | self.update_timer_field_color("yellow") 1801 | if self.enable_warning_sound.isChecked(): 1802 | QTimer.singleShot(0, self.play_warning_sound) 1803 | 1804 | self.update_timer_display() 1805 | 1806 | # Handle timer completion 1807 | if self.remaining_time == 0: 1808 | self.update_timer_field_color("red") 1809 | if self.enable_end_sound.isChecked(): 1810 | QTimer.singleShot(0, self.play_end_sound) 1811 | if self.enable_end_warning.isChecked(): 1812 | if not hasattr(self, 'blink_timer'): 1813 | self.blink_timer = QTimer() 1814 | self.blink_timer.timeout.connect(self._toggle_timer_visibility) 1815 | self.blink_visible = True 1816 | self.blink_timer.start(500) # Start blinking 1817 | if self.enable_overtime.isChecked(): 1818 | self.overtime = 0 1819 | self.in_overtime = True 1820 | else: 1821 | self.stop_timer() 1822 | elif self.in_overtime and self.enable_overtime.isChecked(): 1823 | # In overtime 1824 | self.overtime += 1 1825 | self.update_timer_display() 1826 | 1827 | # Send timer update to Companion if OSC client is enabled 1828 | if self.osc_client_enabled: 1829 | self.osc_client.send_timer_update( 1830 | int(self.remaining_time), 1831 | self.timer_running, 1832 | self.enable_warning.isChecked() and self.remaining_time <= self.warning_time.value() 1833 | ) 1834 | 1835 | def blink_timer_text(self): 1836 | """Blink timer text when time is up""" 1837 | if not self.enable_end_warning.isChecked(): 1838 | if self.blink_timer and self.blink_timer.isActive(): 1839 | self.blink_timer.stop() 1840 | self.update_timer_field_color("white") 1841 | return 1842 | 1843 | self.blink_state = not self.blink_state 1844 | self.update_timer_field_color("red" if self.blink_state else "black") 1845 | 1846 | def update_timer_display(self): 1847 | """Update timer display""" 1848 | if "timer" in self.display_window.fields: 1849 | timer_field = self.display_window.fields["timer"] 1850 | 1851 | if self.remaining_time > 0 or not self.in_overtime: 1852 | # Normal countdown 1853 | minutes = self.remaining_time // 60 1854 | seconds = self.remaining_time % 60 1855 | time_str = f"{minutes:02d}:{seconds:02d}" 1856 | else: 1857 | # Overtime display with + sign 1858 | overtime_seconds = self.overtime 1859 | minutes = overtime_seconds // 60 1860 | seconds = overtime_seconds % 60 1861 | time_str = f"+{minutes:02d}:{seconds:02d}" 1862 | 1863 | timer_field.content.text = time_str 1864 | timer_field.update() 1865 | 1866 | # Update timer in GUI window 1867 | if hasattr(self, 'timer_display'): 1868 | if self.remaining_time > 0 or not self.in_overtime: 1869 | minutes = self.remaining_time // 60 1870 | seconds = self.remaining_time % 60 1871 | self.timer_display.setText(f"{minutes:02d}:{seconds:02d}") 1872 | else: 1873 | minutes = self.overtime // 60 1874 | seconds = self.overtime % 60 1875 | self.timer_display.setText(f"+{minutes:02d}:{seconds:02d}") 1876 | 1877 | def set_timer_duration(self, duration): 1878 | """Set timer duration in seconds""" 1879 | self.stop_timer() # Stop any running timer 1880 | self.remaining_time = duration 1881 | 1882 | # Update input fields 1883 | self.hours_input.setValue(duration // 3600) 1884 | self.minutes_input.setValue((duration % 3600) // 60) 1885 | self.seconds_input.setValue(duration % 60) 1886 | 1887 | self.update_timer_display() 1888 | 1889 | def update_timer_field_color(self, color): 1890 | """Update timer field color if it exists""" 1891 | if "timer" in self.display_window.fields: 1892 | self.display_window.fields['timer'].content.font_color = color 1893 | self.display_window.fields['timer'].update() 1894 | 1895 | def update_timer_field_text(self, text): 1896 | """Update timer field text if it exists""" 1897 | if "timer" in self.display_window.fields: 1898 | self.display_window.fields['timer'].content.text = text 1899 | self.display_window.fields['timer'].update() 1900 | 1901 | def toggle_web_streaming(self, state): 1902 | """Toggle web streaming""" 1903 | enabled = state == Qt.Checked 1904 | self.display_window.enable_web_streaming(enabled) 1905 | 1906 | def warning_sound_changed(self, text): 1907 | """Handle warning sound selection change""" 1908 | if text == "Custom...": 1909 | self.browse_warning_sound() 1910 | else: 1911 | sound_num = int(text.split()[-1]) 1912 | self.warning_sound_path = f"sounds/warning{sound_num}.mp3" 1913 | 1914 | def end_sound_changed(self, text): 1915 | """Handle end sound selection change""" 1916 | if text == "Custom...": 1917 | self.browse_end_sound() 1918 | else: 1919 | sound_num = int(text.split()[-1]) 1920 | self.end_sound_path = f"sounds/end{sound_num}.mp3" 1921 | 1922 | def play_warning_sound(self): 1923 | """Play warning sound in a loop""" 1924 | try: 1925 | with self._sound_lock: 1926 | if not self.warning_sound_playing and os.path.exists(self.warning_sound_path): 1927 | try: 1928 | # Load and play the sound 1929 | pygame.mixer.music.load(self.warning_sound_path) 1930 | pygame.mixer.music.play(-1) # -1 means loop indefinitely 1931 | self.warning_sound_playing = True 1932 | print("Started playing warning sound") 1933 | except Exception as e: 1934 | print(f"Error playing warning sound: {e}") 1935 | except Exception as e: 1936 | print(f"Error in play_warning_sound: {e}") 1937 | 1938 | def play_end_sound(self): 1939 | """Play end sound once""" 1940 | try: 1941 | if os.path.exists(self.end_sound_path): 1942 | # Stop any playing warning sound 1943 | self.stop_warning_sound() 1944 | 1945 | try: 1946 | # Load and play the sound 1947 | pygame.mixer.music.load(self.end_sound_path) 1948 | pygame.mixer.music.play(0) # 0 means play once 1949 | print("Started playing end sound") 1950 | except Exception as e: 1951 | print(f"Error playing end sound: {e}") 1952 | except Exception as e: 1953 | print(f"Error in play_end_sound: {e}") 1954 | 1955 | def stop_warning_sound(self): 1956 | """Stop warning sound""" 1957 | with self._sound_lock: 1958 | if self.warning_sound_playing: 1959 | pygame.mixer.music.stop() 1960 | self.warning_sound_playing = False 1961 | print("Stopped warning sound") 1962 | # Also reset test buttons if they were playing 1963 | if self.test_warning_playing: 1964 | self.test_warning_playing = False 1965 | self.warning_test_button.setText("Test") 1966 | if self.test_end_playing: 1967 | self.test_end_playing = False 1968 | self.end_test_button.setText("Test") 1969 | 1970 | def test_warning_sound(self): 1971 | """Test warning sound""" 1972 | if not self.test_warning_playing: 1973 | if os.path.exists(self.warning_sound_path): 1974 | try: 1975 | pygame.mixer.music.load(self.warning_sound_path) 1976 | pygame.mixer.music.play(0) # Play once for testing 1977 | self.test_warning_playing = True 1978 | self.warning_test_button.setText("Stop") 1979 | except Exception as e: 1980 | print(f"Error testing warning sound: {e}") 1981 | else: 1982 | pygame.mixer.music.stop() 1983 | self.test_warning_playing = False 1984 | self.warning_test_button.setText("Test") 1985 | 1986 | def test_end_sound(self): 1987 | """Test end sound""" 1988 | if not self.test_end_playing: 1989 | if os.path.exists(self.end_sound_path): 1990 | try: 1991 | pygame.mixer.music.load(self.end_sound_path) 1992 | pygame.mixer.music.play(0) # Play once for testing 1993 | self.test_end_playing = True 1994 | self.end_test_button.setText("Stop") 1995 | except Exception as e: 1996 | print(f"Error testing end sound: {e}") 1997 | else: 1998 | pygame.mixer.music.stop() 1999 | self.test_end_playing = False 2000 | self.end_test_button.setText("Test") 2001 | 2002 | def browse_warning_sound(self): 2003 | """Browse for custom warning sound""" 2004 | file_name, _ = QFileDialog.getOpenFileName( 2005 | self, 2006 | "Select Warning Sound", 2007 | "", 2008 | "Sound Files (*.mp3 *.wav);;All Files (*.*)" 2009 | ) 2010 | if file_name: 2011 | self.warning_sound_path = file_name 2012 | self.warning_sound_combo.setCurrentText("Custom...") 2013 | 2014 | def browse_end_sound(self): 2015 | """Browse for custom end sound""" 2016 | file_name, _ = QFileDialog.getOpenFileName( 2017 | self, 2018 | "Select End Sound", 2019 | "", 2020 | "Sound Files (*.mp3 *.wav);;All Files (*.*)" 2021 | ) 2022 | if file_name: 2023 | self.end_sound_path = file_name 2024 | self.end_sound_combo.setCurrentText("Custom...") 2025 | 2026 | def _toggle_timer_visibility(self): 2027 | """Toggle timer visibility for blinking effect""" 2028 | if "timer" in self.display_window.fields: 2029 | timer_field = self.display_window.fields["timer"] 2030 | self.blink_visible = not self.blink_visible 2031 | timer_field.setVisible(self.blink_visible) 2032 | 2033 | def toggle_osc_client(self, state): 2034 | """Toggle OSC client""" 2035 | if state == Qt.Checked: 2036 | self.osc_client_enabled = True 2037 | else: 2038 | self.osc_client_enabled = False 2039 | 2040 | def apply_companion_settings(self): 2041 | """Apply Companion settings""" 2042 | self.osc_client.set_target(self.companion_ip_input.text(), self.companion_port_input.value()) 2043 | 2044 | if __name__ == '__main__': 2045 | app = QApplication(sys.argv) 2046 | window = MainWindow() 2047 | sys.exit(app.exec_()) 2048 | --------------------------------------------------------------------------------