import os
import sys
import time
import requests
import subprocess
import gzip
import threading
import socket
from urllib.parse import unquote
from datetime import datetime, timedelta,timezone
from PyQt5.QtWidgets import (
QMainWindow, QVBoxLayout, QListWidget, QWidget, QHBoxLayout,
QListWidgetItem, QLabel, QSplitter, QFrame, QPushButton, QSlider, QSizePolicy,
QScrollArea,QGridLayout,QFileDialog,QGraphicsView, QGraphicsScene, QGraphicsRectItem, QGraphicsSimpleTextItem,
QApplication, QGraphicsLineItem,QGraphicsTextItem,QGraphicsItem,QLineEdit
)
from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal,QPointF,QRectF,QEvent
from PyQt5.QtGui import QFont, QColor,QBrush, QColor, QPen,QPainter,QGuiApplication
import pytz
import vlc
import xml.etree.ElementTree as ET
RED_LINE_SHIFT_SECONDS = 700 # 10-minute forward shift
php_proc = None # Keep reference to PHP server to prevent it from being garbage collected
class VideoFrame(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("background-color: black;")
class VLCPlayer:
def __init__(self, video_frame):
self.instance = vlc.Instance()
self.media_player = self.instance.media_player_new()
if sys.platform == "win32":
self.media_player.set_hwnd(int(video_frame.winId()))
else:
self.media_player.set_xwindow(int(video_frame.winId()))
self.playback_timeout_timer = QTimer()
self.playback_timeout_timer.setSingleShot(True)
self.playback_timeout_timer.timeout.connect(self.check_playback_timeout)
def play_stream(self, url):
try:
self.media_player.stop() # Make sure it's fully stopped before new media
except Exception:
pass
media = self.instance.media_new(url)
# ✅ Optimized for faster startup
media.add_option(":network-caching=1000")
media.add_option(":file-caching=500")
media.add_option(":tcp-caching=500")
media.add_option(":avcodec-hw=any") # ✅ Try hardware decoding when possible
media.add_option("--no-drop-late-frames")
media.add_option("--no-skip-frames")
media.add_option(":http-reconnect")
#media.add_option(":http-continuous")
media.add_option(":ffmpeg-skiploopfilter=all")
media.add_option(":ffmpeg-skipframe=default")
self.media_player.set_media(media)
self.media_player.audio_set_volume(70)
self.media_player.play()
# Start timeout safety
self.playback_timeout_timer.start(10000)
def stop(self):
self.media_player.stop()
def release(self):
try:
self.media_player.release()
except Exception: pass
try:
self.instance.release()
except Exception: pass
def check_playback_timeout(self):
if not self.media_player.is_playing():
print("[VLC] Stream timeout – stopping playback")
self.media_player.stop()
class EPGDownloader(QThread):
epg_downloaded = pyqtSignal(bytes)
error_occurred = pyqtSignal(str)
def __init__(self, epg_url):
super().__init__()
self.epg_url = epg_url
def run(self):
try:
response = requests.get(self.epg_url, timeout=10)
response.raise_for_status()
self.epg_downloaded.emit(response.content)
except Exception as e:
self.error_occurred.emit(f"EPG download failed: {str(e)}")
class IPTVWindow(QMainWindow):
def __init__(self, php_dir):
self.channel_video_info = {}
self.epg_grid_window = None
super().__init__()
self.setWindowTitle("Playlist and EPG")
self.setGeometry(100, 100, 1280, 720)
self.php_dir = php_dir
self._is_fullscreen = False
self._normal_geometry = None
self._normal_flags = None
self.current_channel_epg = None
self.epg_url = None
self.epg_data = None
self.epg_last_update = None
self.debug_log = []
self.channel_order = [] # ✅ Prevent AttributeError
self.full_channel_order = list(self.channel_order)
self.init_ui()
self.epg_data = ET.Element("tv") # placeholder in case it's accessed early
self.download_playlist() # This will later call load_playlist() which fills channel_order
screen = QApplication.primaryScreen().availableGeometry()
half_width = screen.width() // 2
full_height = screen.height()
self.resize(half_width, full_height - 50) # Shrink to avoid going off screen
self.move(half_width, 0) # Position on right half
# Position IPTV window in top-right quarter of screen
screen = QApplication.primaryScreen().availableGeometry()
half_width = screen.width() // 2
half_height = screen.height() // 2
self.setGeometry(half_width, 30, half_width, half_height - 30)
self.stream_error_label = None # for "Stream not available" overlay
def log_debug(self, message):
"""Add debug message to log and print to console"""
timestamp = datetime.now().strftime("%H:%M:%S")
full_message = f"[{timestamp}] {message}"
self.debug_log.append(full_message)
print(full_message)
if hasattr(self, 'debug_display'):
self.debug_display.addItem(full_message)
self.debug_display.scrollToBottom()
def init_ui(self):
self.splitter = QSplitter(Qt.Horizontal)
# Left panel with channels
self.channel_list = QListWidget()
self.channel_list.itemClicked.connect(self.on_channel_clicked)
self.splitter.addWidget(self.channel_list)
self.channel_list.hide() # ✅ Hide the old playlist
# Right panel with video and EPG
right_panel = QWidget()
layout = QVBoxLayout(right_panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.video_container = QWidget()
self.video_container.setStyleSheet("background-color: black;")
self.video_container.setMinimumHeight(400)
video_container_layout = QVBoxLayout(self.video_container)
video_container_layout.setContentsMargins(0, 0, 0, 0)
self.video_frame = VideoFrame(self.video_container)
self.video_frame.mouseDoubleClickEvent = self.toggle_fullscreen
video_container_layout.addWidget(self.video_frame)
layout.addWidget(self.video_container, stretch=3)
self.player = VLCPlayer(self.video_frame)
# EPG Display with new formatting
self.epg_display = QLabel()
self.epg_display.setStyleSheet("""
background-color: #222;
color: white;
padding: 10px;
""")
self.epg_display.setWordWrap(True)
self.epg_display.setAlignment(Qt.AlignTop | Qt.AlignLeft)
layout.addWidget(self.epg_display, stretch=1)
# Timers
self.clock_timer = QTimer()
self.clock_timer.timeout.connect(self.refresh_epg_time)
self.clock_timer.start(1000)
self.epg_update_timer = QTimer()
self.epg_update_timer.timeout.connect(self.update_epg_data)
self.epg_update_timer.start(3600000) # Update EPG every hour
# Timer to auto-refresh displayed EPG info every 60 seconds
self.epg_live_timer = QTimer()
self.epg_live_timer.timeout.connect(self.update_epg_info)
self.epg_live_timer.start(60000) # 60 seconds
# Controls
self.controls = QWidget()
ctl_layout = QHBoxLayout(self.controls)
for text, slot in [("⏮️", self.play_previous_channel),
("⏯️", self.toggle_play_pause),
("⏭️", self.play_next_channel),
("🖵", self.toggle_fullscreen),
("📅", self.toggle_epg_grid),
("🔄", self.update_epg_data)]:
btn = QPushButton(text)
btn.clicked.connect(slot)
ctl_layout.addWidget(btn)
self.volume_slider = QSlider(Qt.Horizontal)
self.volume_slider.setRange(0, 200)
self.volume_slider.setValue(70)
self.volume_slider.setToolTip("Volume")
self.volume_slider.valueChanged.connect(self.set_volume)
ctl_layout.addWidget(self.volume_slider)
self.mute_button = QPushButton("🔊")
self.mute_button.setCheckable(True)
self.mute_button.clicked.connect(self.toggle_mute)
ctl_layout.addWidget(self.mute_button)
layout.addWidget(self.controls)
self.splitter.addWidget(right_panel)
self.splitter.setSizes([300, 980])
self.setCentralWidget(self.splitter)
self.last_epg_title = ""
self.last_epg_desc = ""
def set_volume(self, value):
self.player.media_player.audio_set_volume(value)
if value == 0:
self.player.media_player.audio_set_mute(True)
self.mute_button.setChecked(True)
self.mute_button.setText("🔇")
else:
self.player.media_player.audio_set_mute(False)
self.mute_button.setChecked(False)
self.mute_button.setText("🔊")
def toggle_mute(self):
is_muted = self.player.media_player.audio_get_mute()
self.player.media_player.audio_set_mute(not is_muted)
self.mute_button.setText("🔇" if not is_muted else "🔊")
self.mute_button.setChecked(not is_muted)
def update_epg_info(self, name=None, resolution=None):
if not hasattr(self, 'current_channel_name'):
return
# Create formatted EPG text
epg_text = ""
# Channel name (top left) and resolution (top right)
channel_font = QFont()
channel_font.setPointSize(14)
channel_font.setBold(True)
epg_text += "
"
epg_text += f"{name} | {resolution} |
"
epg_text += "
"
# Current program
if self.current_channel_epg:
# Title (big and bold)
epg_text += f""
epg_text += f"{self.current_channel_epg.get('title', 'No Programme Information')}"
epg_text += "
"
# Time and duration
if 'start' in self.current_channel_epg and 'stop' in self.current_channel_epg:
start = self.current_channel_epg['start']
stop = self.current_channel_epg['stop']
try:
# Convert GMT times to aware datetime objects
gmt_start = datetime.strptime(start[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
gmt_stop = datetime.strptime(stop[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
# Convert to local time (Pakistan time here, change if needed)
local_tz = pytz.timezone("Asia/Karachi")
start_local = gmt_start.astimezone(local_tz)
stop_local = gmt_stop.astimezone(local_tz)
now_local = datetime.now(local_tz)
# Format time display
start_time_str = start_local.strftime("%I:%M%p").lstrip('0')
stop_time_str = stop_local.strftime("%I:%M%p").lstrip('0')
duration = int((stop_local - start_local).total_seconds() // 60)
# Progress percentage
total_seconds = (stop_local - start_local).total_seconds()
elapsed_seconds = (now_local - start_local).total_seconds()
progress_percent = max(0, min(100, int((elapsed_seconds / total_seconds) * 100))) if total_seconds > 0 else 0
# Build HTML progress bar
progress_bar_html = f"""
"""
# Add everything to epg_text
epg_text += f""
epg_text += f"{start_time_str} - {stop_time_str} ({duration} mins)"
epg_text += progress_bar_html
epg_text += "
"
except:
pass
# Description
if 'desc' in self.current_channel_epg and self.current_channel_epg['desc']:
epg_text += f""
epg_text += f"{self.current_channel_epg['desc']}"
epg_text += "
"
# Next program
if self.next_program_epg:
epg_text += f""
epg_text += f"Next: {self.next_program_epg.get('title', 'No information')}"
epg_text += "
"
if 'start' in self.next_program_epg and 'stop' in self.next_program_epg:
start = self.next_program_epg['start']
stop = self.next_program_epg['stop']
try:
# Convert GMT times to aware datetime objects
gmt_start = datetime.strptime(start[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
gmt_stop = datetime.strptime(stop[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
# Convert to local time (Pakistan time here, change if needed)
local_tz = pytz.timezone("Asia/Karachi")
start_local = gmt_start.astimezone(local_tz)
stop_local = gmt_stop.astimezone(local_tz)
# Format time display
start_time_str = start_local.strftime("%I:%M%p").lstrip('0')
stop_time_str = stop_local.strftime("%I:%M%p").lstrip('0')
epg_text += f""
epg_text += f"{start_time_str} - {stop_time_str}"
epg_text += "
"
except:
pass
else:
epg_text += ""
epg_text += "No Programme Information"
epg_text += "
"
self.epg_display.setText(epg_text)
def update_current_epg(self, epg_data=None):
if epg_data is not None:
self.epg_data = epg_data
if self.epg_data is None or not hasattr(self, 'current_channel_id'):
return
try:
# Get current LOCAL time
now_local = datetime.now()
# Convert to equivalent GMT time string for matching with EPG (which is in GMT)
now_gmt = now_local.astimezone(pytz.utc)
current_time = now_gmt.strftime("%Y%m%d%H%M%S")
found_current = False
found_next = False
for programme in self.epg_data.findall('.//programme'):
channel_id = programme.get('channel')
if channel_id != self.current_channel_id:
continue
start = programme.get('start')
stop = programme.get('stop')
if not found_current and start <= current_time <= stop:
# Current program
self.current_channel_epg = {
'title': unquote(programme.find('title').text) if programme.find('title') is not None else 'No Programme Information',
'start': start,
'stop': stop,
'desc': unquote(programme.find('desc').text) if programme.find('desc') is not None else ''
}
found_current = True
elif found_current and not found_next and start > current_time:
# Next program
self.next_program_epg = {
'title': unquote(programme.find('title').text) if programme.find('title') is not None else 'No information',
'start': start,
'stop': stop,
'desc': unquote(programme.find('desc').text) if programme.find('desc') is not None else ''
}
found_next = True
break
if not found_current:
self.current_channel_epg = None
self.next_program_epg = None
elif not found_next:
self.next_program_epg = None
# Update the display
if self.current_channel_epg:
stop_time_str = self.current_channel_epg.get('stop')
try:
stop_time = datetime.strptime(stop_time_str[:14], "%Y%m%d%H%M%S").replace(tzinfo=pytz.utc).astimezone(pytz.timezone("Asia/Karachi"))
now = datetime.now(pytz.timezone("Asia/Karachi"))
if now >= stop_time:
if hasattr(self, 'current_channel_name') and hasattr(self, 'current_channel_resolution'):
self.update_epg_info(self.current_channel_name, self.current_channel_resolution)
except Exception as e:
print(f"[EPG Timing Check] Failed to parse stop time: {e}")
except Exception as e:
print(f"Error updating current EPG: {str(e)}")
def refresh_epg_time(self):
if not hasattr(self, 'current_channel_name') or not hasattr(self, 'current_channel_resolution'):
return
text = self.epg_display.text()
parts = text.split("\n", 1)
if len(parts) == 2:
first_line, epg_line = parts
if "|" in first_line:
name_resolution = first_line.split("|")
if len(name_resolution) >= 2:
name = name_resolution[0].replace("", "").replace("", "").strip()
resolution = name_resolution[1].strip()
self.update_epg_info(name, resolution, self.current_channel_epg)
def update_epg_data(self):
if not self.epg_url:
self.log_debug("No EPG URL available")
return
self.log_debug(f"Downloading EPG data from: {self.epg_url}")
self.epg_downloader = EPGDownloader(self.epg_url)
self.epg_downloader.epg_downloaded.connect(self.process_epg_data)
self.epg_downloader.error_occurred.connect(self.log_debug)
self.epg_downloader.start()
def process_epg_data(self, epg_bytes):
try:
# Try to decompress the data
try:
epg_xml = gzip.decompress(epg_bytes).decode('utf-8')
self.log_debug("EPG data decompressed successfully")
except:
epg_xml = epg_bytes.decode('utf-8')
self.log_debug("EPG data was not compressed")
# Save the raw XML for debugging
debug_epg_path = os.path.join(self.php_dir, "last_epg.xml")
with open(debug_epg_path, "w", encoding="utf-8") as f:
f.write(epg_xml)
self.log_debug(f"Saved EPG data to {debug_epg_path}")
# Parse the XML
self.epg_data = ET.fromstring(epg_xml)
self.insert_dummy_epg_gaps()
self.epg_last_update = datetime.now()
if self.epg_grid_window:
# Defer overlay creation safely inside update_epg()
self.epg_grid_window.update_epg(self.epg_data, self.channel_order, self.channel_video_info)
else:
# Create fresh EPGGridWindow AFTER full EPG is available
self.epg_grid_window = EPGGridWindow(self.epg_data, self.channel_order, self.channel_video_info, self)
self.epg_grid_window.show()
self.log_debug(f"EPG data parsed successfully, last update: {self.epg_last_update}")
# Update current channel's EPG if available
if hasattr(self, 'current_channel_id'):
self.update_current_epg()
except Exception as e:
self.log_debug(f"Error processing EPG data: {str(e)}")
def insert_dummy_epg_gaps(self):
if self.epg_data is None:
return
local_tz = pytz.timezone("Asia/Karachi")
timeline_start = self.epg_grid_window.timeline_start if self.epg_grid_window else datetime.now(local_tz)
timeline_end = self.epg_grid_window.timeline_end if self.epg_grid_window else timeline_start + timedelta(hours=6)
for _, ch_id in self.channel_order:
progs = [p for p in self.epg_data.findall(".//programme") if p.get("channel") == ch_id]
# Convert to (start, stop, element)
parsed = []
for p in progs:
try:
start = datetime.strptime(p.get("start")[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc).astimezone(local_tz)
stop = datetime.strptime(p.get("stop")[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc).astimezone(local_tz)
parsed.append((start, stop, p))
except:
continue
parsed.sort(key=lambda x: x[0])
new_programmes = []
current_time = timeline_start
for start, stop, orig_elem in parsed:
if start > current_time:
# Insert dummy gap
gap_start = current_time
gap_end = start
while gap_start < gap_end:
next_gap = min(gap_end, gap_start + timedelta(hours=1))
dummy = ET.Element("programme")
dummy.set("channel", ch_id)
dummy.set("start", gap_start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") + " +0000")
dummy.set("stop", next_gap.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") + " +0000")
title = ET.SubElement(dummy, "title")
title.text = "No Program Information"
new_programmes.append(dummy)
gap_start = next_gap
new_programmes.append(orig_elem)
current_time = max(current_time, stop)
# Fill any remaining gap to timeline_end
if current_time < timeline_end:
gap_start = current_time
while gap_start < timeline_end:
next_gap = min(timeline_end, gap_start + timedelta(hours=1))
dummy = ET.Element("programme")
dummy.set("channel", ch_id)
dummy.set("start", gap_start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") + " +0000")
dummy.set("stop", next_gap.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") + " +0000")
title = ET.SubElement(dummy, "title")
title.text = "No Program Information"
new_programmes.append(dummy)
gap_start = next_gap
# Remove old and insert new
for p in progs:
self.epg_data.remove(p)
for p in new_programmes:
self.epg_data.append(p)
def toggle_epg_grid(self):
if self.epg_grid_window is None:
# Use dummy XML if EPG not ready yet
dummy_epg = self.epg_data if self.epg_data is not None else ET.Element("tv")
self.epg_grid_window = EPGGridWindow(dummy_epg, self.channel_order, self.channel_video_info, self)
if self.epg_grid_window.isVisible():
self.epg_grid_window.hide()
else:
self.epg_grid_window.show()
def detect_resolution(self, item, base):
if not self.player.media_player.is_playing():
print("[VLC] Stream failed — displaying fallback message")
self.player.media_player.stop()
# Remove any previous error label
if self.stream_error_label:
self.stream_error_label.deleteLater()
self.stream_error_label = None
# Show persistent "Stream not available" message
self.stream_error_label = QLabel("Stream is not available", self.video_frame)
self.stream_error_label.setAlignment(Qt.AlignCenter)
self.stream_error_label.setStyleSheet("""
color: red;
font-size: 18pt;
background-color: rgba(0, 0, 0, 180);
""")
self.stream_error_label.setGeometry(0, 0, self.video_frame.width(), self.video_frame.height())
self.stream_error_label.show()
# ✅ Restart PHP only if needed and it's a PHP-based stream
url = item.data(Qt.UserRole)
if url and url.startswith("http://localhost:8888"):
if php_proc is None or php_proc.poll() is not None:
print("[PHP] Detected PHP server is not running. Restarting...")
restart_php_server(self.php_dir)
else:
print("[PHP] Forcing restart due to stuck stream...")
restart_php_server(self.php_dir)
self.current_channel_resolution = "Stream not available"
self.update_epg_info(base, "Stream not available")
return
# ✅ Stream is good — remove error label
if self.stream_error_label:
self.stream_error_label.deleteLater()
self.stream_error_label = None
# Continue with resolution and FPS
w = self.player.media_player.video_get_width()
h = self.player.media_player.video_get_height()
fps = self.player.media_player.get_fps()
if w > 0 and h > 0:
label = "SD"
if w >= 3840:
label = "4K"
elif w >= 1920:
label = "FHD"
elif w >= 1280:
label = "HD"
info = f"{label}-{fps:.2f} FPS"
self.channel_video_info[base] = (label, f"{fps:.2f}")
item.setText(f"{base} ({info})")
self.current_channel_name = base
self.current_channel_resolution = info
self.update_epg_info(base, info)
if self.epg_grid_window:
self.epg_grid_window.refresh_channel_fps_info()
def on_channel_clicked(self, item: QListWidgetItem):
url = item.data(Qt.UserRole)
if not url:
return
base = item.text().split('(')[0].strip()
item.setText(f"{base} (Loading...)")
self.current_channel_name = base
self.update_epg_info(base, "Loading...")
self.log_debug(f"Playing stream: {url}")
self.player.stop()
self.player.play_stream(url)
def retry_if_failed():
if self.player.media_player.is_playing():
return # ✅ First try succeeded
self.log_debug("[RETRY] First attempt failed, retrying stream...")
self.player.stop()
self.player.release()
self.player = VLCPlayer(self.video_frame)
if url.startswith("http://localhost:8888"):
self.log_debug("[RETRY] Waiting for PHP server to be ready...")
# ✅ Try to wait up to 3 seconds max
if not wait_for_php_server(timeout=3):
self.log_debug("[RETRY] PHP not ready. Restarting and waiting again...")
restart_php_server(self.php_dir)
time.sleep(1.0)
if not wait_for_php_server(timeout=3):
self.log_debug("[RETRY] PHP still not ready. Giving up.")
return
self.player.play_stream(url)
QTimer.singleShot(10000, retry_if_failed)
channel_id = item.data(Qt.UserRole + 1)
if channel_id:
self.current_channel_id = channel_id
self.update_current_epg()
QTimer.singleShot(5000, lambda: self.detect_resolution(item, base))
def toggle_play_pause(self):
mp = self.player.media_player
if mp.is_playing():
mp.pause()
else:
mp.play()
def play_next_channel(self):
idx = self.channel_list.currentRow()
if idx < self.channel_list.count() - 1:
self.channel_list.setCurrentRow(idx+1)
self.on_channel_clicked(self.channel_list.currentItem())
def play_previous_channel(self):
idx = self.channel_list.currentRow()
if idx > 0:
self.channel_list.setCurrentRow(idx-1)
self.on_channel_clicked(self.channel_list.currentItem())
def toggle_fullscreen(self, event=None):
if not self._is_fullscreen:
self._normal_geometry = self.geometry()
self._normal_flags = self.windowFlags()
self.channel_list.hide()
self.epg_display.hide()
self.controls.hide()
self.setWindowFlags(self._normal_flags | Qt.FramelessWindowHint)
self.showFullScreen()
else:
self.setWindowFlags(self._normal_flags)
self.showNormal()
if self._normal_geometry:
self.setGeometry(self._normal_geometry)
self.channel_list.show()
self.epg_display.show()
self.controls.show()
self._is_fullscreen = not self._is_fullscreen
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape and self._is_fullscreen:
self.toggle_fullscreen()
else:
super().keyPressEvent(event)
def download_playlist(self):
try:
os.makedirs(self.php_dir, exist_ok=True)
url = "https://iptv.nywebforum.com/playlist.m3u"
self.log_debug(f"Downloading playlist from: {url}")
response = requests.get(url, timeout=10)
content = response.text.replace(
"https://iptv.nywebforum.com", "http://localhost:8888"
)
# Extract EPG URL from the first line if present
first_line = response.text.split('\n')[0]
if first_line.startswith("#EXTM3U") and "url-tvg=" in first_line:
self.epg_url = first_line.split('url-tvg="')[1].split('"')[0]
self.log_debug(f"Found EPG URL: {self.epg_url}")
QTimer.singleShot(500, self.update_epg_data) # Delay download to avoid blocking UI
path = os.path.join(self.php_dir, "playlist.m3u")
with open(path, "w", encoding="utf-8") as f:
f.write(content)
self.log_debug(f"Playlist saved to: {path}")
self.load_playlist(path)
except Exception as e:
self.log_debug(f"Failed to download playlist: {e}")
def load_playlist(self, path):
try:
self.log_debug(f"Loading playlist from: {path}")
lines = open(path, encoding="utf-8").read().splitlines()
self.channel_list.clear()
first = None
name = None
channel_id = None
self.channel_order = [] # ✅ Reset and initialize channel order
for i, l in enumerate(lines):
if l.startswith("#EXTINF:"):
name = l.split(',')[-1].strip()
if 'tvg-id="' in l:
channel_id = l.split('tvg-id="')[1].split('"')[0]
self.log_debug(f"Found channel ID: {channel_id} for {name}")
elif l and not l.startswith("#") and name:
it = QListWidgetItem(f"{name} (Loading...)")
it.setData(Qt.UserRole, l) # stream URL
if channel_id:
it.setData(Qt.UserRole + 1, channel_id)
self.channel_order.append((name, channel_id)) # ✅ Maintain order for EPG
it.setData(Qt.UserRole + 2, len(self.channel_order) - 1) # ✅ Use correct index
self.channel_list.addItem(it)
if first is None:
first = it
name = None
channel_id = None
if first:
self.channel_list.setCurrentItem(first)
self.log_debug(f"Found {self.channel_list.count()} channels, selecting first one")
QTimer.singleShot(1000, lambda: self.on_channel_clicked(first))
if self.epg_grid_window is None:
self.epg_grid_window = EPGGridWindow(self.epg_data, self.channel_order, self.channel_video_info, self)
self.epg_grid_window.show()
except Exception as e:
self.log_debug(f"Failed to load playlist: {e}")
def closeEvent(self, event):
self.log_debug("Application closing - starting async cleanup")
# Hide the main window immediately for fast visual exit
self.hide()
# Defer cleanup after 100ms so UI can close first
QTimer.singleShot(100, self.cleanup_resources)
event.accept() # ✅ allow close to continue
super().closeEvent(event)
def cleanup_resources(self):
self.log_debug("Cleaning up timers and resources")
if self.epg_grid_window:
self.epg_grid_window.close()
self.epg_grid_window.deleteLater()
self.epg_grid_window = None
self.clock_timer.stop()
self.epg_update_timer.stop()
self.player.stop()
self.player.release()
global php_proc
if php_proc:
try:
php_proc.terminate()
php_proc.wait(2)
except Exception as e:
self.log_debug(f"[PHP Cleanup] Error: {e}")
php_proc = None
self.log_debug("Cleanup finished — exiting app")
QTimer.singleShot(100, QApplication.instance().quit) # exit only after UI fully cleaned
class TimelineLabel(QLabel):
def __init__(self, text, start_offset, duration, parent_grid, timeline_slot_width):
super().__init__()
self.parent_grid = parent_grid
self.full_text = text
self.start_offset = start_offset
self.duration = duration
self.parent_grid = parent_grid
self.timeline_slot_width = timeline_slot_width
self.setStyleSheet("""
border: 1px solid #555;
color: white;
""")
self.setWordWrap(True) # Allow multi-line
self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
self.setText(self.full_text) # Let QLabel handle the text
self.setStyleSheet("""
border: 1px solid #555;
color: white;
font-size: 10pt;
padding: 2px; /* reduced padding */
background-color: #333;
""")
self.setStyleSheet("border: 1px solid #444; padding: 2px; background-color: #333;")
def mouseDoubleClickEvent(self, event):
print("[DEBUG] Program grid double-click detected")
if self.parent_grid and self.parent_grid.main_window:
layout = self.parent_grid.epg_layout
index = layout.indexOf(self)
if index != -1:
row, _, _, _ = layout.getItemPosition(index)
print(f"[DEBUG] EPG row double-clicked: {row}")
if 0 <= row < self.parent_grid.main_window.channel_list.count():
item = self.parent_grid.main_window.channel_list.item(row)
print(f"[DEBUG] Attempting to play channel from program grid: {item.text()}")
self.parent_grid.main_window.channel_list.setCurrentItem(item)
self.parent_grid.main_window.on_channel_clicked(item)
event.accept()
class NowLineOverlay(QWidget):
def __init__(self, parent, grid_window):
super().__init__(parent)
self.grid_window = grid_window
self.slot_width = grid_window.timeline_slot_width # ✅ store slot width
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setStyleSheet("background: transparent;")
self.setVisible(True) # ✅ Ensure it's visible
def paintEvent(self, event):
from PyQt5.QtGui import QPainter, QPen
painter = QPainter(self)
pen = QPen(Qt.red, 2)
painter.setPen(pen)
now = datetime.now(pytz.timezone("Asia/Karachi"))
delta = now - self.grid_window.timeline_start
adjusted_seconds = delta.total_seconds() + RED_LINE_SHIFT_SECONDS
block_width = self.grid_window.timeline_slot_width # should be 200 if set correctly
# Total X position of the red line
x = (adjusted_seconds / 1800) * block_width
painter.drawLine(int(x), 0, int(x), self.height())
class EPGGridWindow(QMainWindow):
def __init__(self, epg_data, channel_order, channel_video_info, main_window=None):
super().__init__()
self.setWindowTitle("EPG (Excel-style View)")
screen = QGuiApplication.primaryScreen()
screen_geometry = screen.geometry()
width = screen_geometry.width() // 2
height = int(screen_geometry.height() * 0.9)
self.setGeometry(0, 0, width, height)
self.epg_data = epg_data
self.channel_order = channel_order
self.channel_video_info = channel_video_info
self.main_window = main_window
self.full_channel_order = list(self.channel_order)
self.channel_column_width = 310
self.timeline_slot_width = 150
self.row_height = 70
self.timeline_row_height = 100
now = datetime.now(pytz.timezone("Asia/Karachi"))
# Align to :00 or :30
if now.minute < 30:
aligned_minutes = 0
else:
aligned_minutes = 30
aligned_now = now.replace(minute=aligned_minutes, second=0, microsecond=0)
# Shift timeline 15 minutes earlier so labels align with block dividers
self.timeline_start = aligned_now - timedelta(days=1, minutes=15)
self.timeline_end = aligned_now + timedelta(days=2)
self.timeline = []
t = self.timeline_start
while t <= self.timeline_end:
self.timeline.append(t)
t += timedelta(minutes=30)
self.timeline_end = aligned_now + timedelta(days=2)
self.timeline = []
t = self.timeline_start
while t <= self.timeline_end:
self.timeline.append(t)
t += timedelta(minutes=30)
self.timeline_scene = QGraphicsScene()
self.channel_scene = QGraphicsScene()
self.grid_scene = QGraphicsScene()
self.timeline_scene.installEventFilter(self)
self.channel_scene.installEventFilter(self)
self.grid_scene.installEventFilter(self)
self.timeline_view = QGraphicsView(self.timeline_scene)
self.channel_view = QGraphicsView(self.channel_scene)
self.grid_view = QGraphicsView(self.grid_scene)
self.grid_view.setStyleSheet("border: none;")
self.timeline_view.setStyleSheet("border: none;")
self.channel_view.setStyleSheet("border-right: 1px solid #666;")
self.grid_view.horizontalScrollBar().valueChanged.connect(self.timeline_view.horizontalScrollBar().setValue)
self.grid_view.verticalScrollBar().valueChanged.connect(self.sync_vertical_scroll)
self.channel_view.verticalScrollBar().valueChanged.connect(self.sync_vertical_scroll)
self.timeline_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.timeline_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.channel_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# self.channel_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.timeline_view.setStyleSheet("border-bottom: 2px solid #666;")
container = QWidget()
layout = QGridLayout(container)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
self.clock_label = QLabel()
self.clock_label.setAlignment(Qt.AlignCenter)
self.clock_label.setFixedSize(self.channel_column_width, self.timeline_row_height)
self.clock_label.setStyleSheet("""
background-color: #222;
color: white;
font-size: 12pt;
font-weight: bold;
border: none;
""")
layout.addWidget(self.clock_label, 0, 0)
layout.addWidget(self.timeline_view, 0, 1)
layout.addWidget(self.channel_view, 1, 0)
layout.addWidget(self.grid_view, 1, 1)
layout.setRowStretch(0, 0) # timeline
layout.setRowStretch(1, 1) # grid
control_layout = QHBoxLayout()
control_height = 50 # or 40 for taller controls
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search channel...")
self.search_box.setFixedWidth(250)
self.search_box.setFixedHeight(control_height)
self.search_box.setStyleSheet("font-size: 14px; padding: 5px;")
self.search_box_timer = QTimer()
self.search_box_timer.setSingleShot(True)
self.search_box_timer.timeout.connect(lambda: self.filter_channels(self.search_box.text()))
self.search_box.textChanged.connect(lambda _: self.search_box_timer.start(200)) # 200ms delay
# Add the search box on the left
control_layout.addWidget(self.search_box)
self.clear_search_btn = QPushButton("❌Clear Search")
self.clear_search_btn.setFixedSize(105, control_height)
self.clear_search_btn.setToolTip("Clear search")
self.clear_search_btn.clicked.connect(lambda: self.search_box.setText(""))
control_layout.addWidget(self.clear_search_btn)
# Add stretch to separate it from buttons
control_layout.addStretch(1)
self.jump_now_btn = QPushButton("⏰ Jump to Now")
self.jump_now_btn.setFixedHeight(control_height)
self.jump_now_btn.clicked.connect(self.scroll_to_current_time)
control_layout.addWidget(self.jump_now_btn)
self.custom_epg_btn = QPushButton("📂 Add Custom EPG")
self.custom_epg_btn.setFixedHeight(control_height)
self.custom_epg_btn.clicked.connect(self.load_custom_epg)
control_layout.addWidget(self.custom_epg_btn)
self.clear_epg_btn = QPushButton("🧹 Clear EPG")
self.clear_epg_btn.setFixedHeight(control_height)
self.clear_epg_btn.clicked.connect(self.clear_epg)
control_layout.addWidget(self.clear_epg_btn)
self.close_btn = QPushButton("❌ Close EPG")
self.close_btn.setFixedHeight(control_height)
self.close_btn.clicked.connect(self.close)
control_layout.addWidget(self.close_btn)
layout.addLayout(control_layout, 2, 0, 1, 2, alignment=Qt.AlignLeft)
self.setCentralWidget(container)
self.build_timeline()
self.build_channels()
self.build_program_grid()
self.draw_now_line()
self.clock_timer = QTimer(self)
self.clock_timer.timeout.connect(self.update_clock)
self.clock_timer.start(1000)
self.now_line_timer = QTimer(self)
self.now_line_timer.timeout.connect(self.update_now_line)
self.now_line_timer.start(30000)
self.setup_now_autorefresh()
QTimer.singleShot(100, self.scroll_to_current_time)
def filter_channels(self, text):
text = text.strip().lower()
# Filter the full list
if text == "":
self.channel_order = self.full_channel_order # Reset to full list
else:
self.channel_order = [
ch for ch in self.full_channel_order if text in ch[0].lower()
]
# Rebuild only visible rows
self.channel_scene.clear()
self.grid_scene.clear()
self.build_channels()
self.build_program_grid()
self.draw_now_line()
# Reset scroll to top so filtered result starts at top
self.channel_view.verticalScrollBar().setValue(0)
self.grid_view.verticalScrollBar().setValue(0)
def setup_now_autorefresh(self):
now = datetime.now()
next_half_hour = now.replace(minute=30 if now.minute < 30 else 0, second=0, microsecond=0)
if now.minute >= 30:
next_half_hour += timedelta(hours=1)
delay_ms = int((next_half_hour - now).total_seconds() * 1000)
QTimer.singleShot(delay_ms, self.start_now_timer)
def start_now_timer(self):
self.scroll_to_current_time()
self._now_timer = QTimer()
self._now_timer.timeout.connect(self.scroll_to_current_time)
self._now_timer.start(30 * 60 * 1000) # Every 30 minutes
def update_clock(self):
now = datetime.now(pytz.timezone("Asia/Karachi"))
self.clock_label.setText(now.strftime("%a %b %d
%I:%M:%S %p"))
def build_channels(self):
self.channel_scene.clear()
for row, (ch_name, ch_id) in enumerate(self.channel_order):
y = row * self.row_height
# Attempt to find original index (channel number) from full_channel_order
try:
original_index = self.full_channel_order.index((ch_name, ch_id))
except ValueError:
original_index = row # fallback
# Background rectangle for row
rect = QGraphicsRectItem(0, y, self.channel_column_width, self.row_height)
rect.setBrush(QBrush(QColor("#444") if row % 2 == 0 else QColor("#333")))
rect.setPen(QPen(QColor("#222")))
rect.setAcceptHoverEvents(True)
rect.setAcceptedMouseButtons(Qt.LeftButton) # ✅ required for double-click
rect.setFlag(QGraphicsRectItem.ItemIsSelectable, True)
rect.setData(0, row) # visual row index
rect.setData(1, original_index) # ✅ channel number (for playback mapping)
self.channel_scene.addItem(rect)
# Channel name with number
label_text = f"{original_index + 1}. {ch_name}"
name_item = QGraphicsSimpleTextItem(label_text)
name_item.setFont(QFont("Arial", 10, QFont.Bold))
name_item.setBrush(QColor("white"))
name_item.setPos(10, y + 4)
self.channel_scene.addItem(name_item)
# FPS and resolution info, if available
res, fps = self.channel_video_info.get(ch_name, ("NA", "NA"))
info_item = QGraphicsSimpleTextItem(f"({res}, {fps} FPS)")
info_item.setFont(QFont("Arial", 9))
info_item.setBrush(QColor("#CCCCCC"))
info_width = info_item.boundingRect().width()
info_item.setPos(self.channel_column_width - info_width - 10, y + self.row_height / 2)
self.channel_scene.addItem(info_item)
def build_timeline(self):
shift_px = self.timeline_slot_width // 2 # Shift timeline 15 mins earlier visually
for index, t in enumerate(self.timeline):
x = index * self.timeline_slot_width - shift_px
# Draw background block (as before)
rect = QGraphicsRectItem(x, 0, self.timeline_slot_width, self.timeline_row_height)
rect.setBrush(QBrush(QColor("#333")))
rect.setPen(QPen(QColor("#333")))
self.timeline_scene.addItem(rect)
# 🔁 Instead of showing t (start), label with t+30min (end of slot)
label_time = t + timedelta(minutes=15)
date_str = label_time.strftime("%a %b %d") # e.g., "Fri Jun 07"
time_str = label_time.strftime("%I:%M %p").lstrip("0") # e.g., "3:30 PM"
label = QGraphicsTextItem()
label.setDefaultTextColor(QColor("white"))
label.setHtml(f"""
{date_str}
{time_str}
""")
text_rect = label.boundingRect()
label.setPos(
x + (self.timeline_slot_width - text_rect.width()) / 2,
(self.timeline_row_height - text_rect.height()) / 2
)
self.timeline_scene.addItem(label)
def build_program_grid(self):
local_tz = pytz.timezone("Asia/Karachi")
for row, (ch_name, ch_id) in enumerate(self.channel_order):
programs = [
p for p in self.epg_data.findall(".//programme")
if p.get("channel") == ch_id
]
# Convert to (start, stop, title)
blocks = []
for prog in programs:
try:
start = datetime.strptime(prog.get("start")[:14], "%Y%m%d%H%M%S").replace(tzinfo=pytz.utc).astimezone(local_tz)
stop = datetime.strptime(prog.get("stop")[:14], "%Y%m%d%H%M%S").replace(tzinfo=pytz.utc).astimezone(local_tz)
if stop <= self.timeline_start or start >= self.timeline_end:
continue
raw_title = prog.findtext("title", "No Title")
title = self.decode_epg_text(raw_title)
blocks.append((start, stop, title))
except Exception as e:
print(f"[ERROR] Parsing program failed: {e}")
blocks.sort(key=lambda x: x[0])
current_time = self.timeline_start
for start, stop, title in blocks:
# Fill any gap before this program
if start > current_time:
gap_start = current_time
gap_end = start
while gap_start < gap_end:
next_gap = min(gap_end, gap_start + timedelta(minutes=60))
start_offset = int((gap_start - self.timeline_start).total_seconds() // 1800)
duration = int((next_gap - gap_start).total_seconds() // 1800)
self.draw_program_block(row, start_offset, duration, "No Program Information")
gap_start = next_gap
# Draw real program
start_offset = max(0, int((start - self.timeline_start).total_seconds() // 1800))
end_offset = min(len(self.timeline), int((stop - self.timeline_start).total_seconds() // 1800))
duration = max(1, end_offset - start_offset)
self.draw_program_block(row, start_offset, duration, title)
current_time = stop
# Fill gap after last program to timeline_end
if current_time < self.timeline_end:
gap_start = current_time
gap_end = self.timeline_end
while gap_start < gap_end:
next_gap = min(gap_end, gap_start + timedelta(minutes=60))
start_offset = int((gap_start - self.timeline_start).total_seconds() // 1800)
duration = int((next_gap - gap_start).total_seconds() // 1800)
self.draw_program_block(row, start_offset, duration, "No Program Information")
gap_start = next_gap
def draw_now_line(self):
self.now_line = QGraphicsLineItem()
self.now_line.setPen(QPen(QColor("red"), 2))
self.grid_scene.addItem(self.now_line)
self.update_now_line()
def update_now_line(self):
now = datetime.now(pytz.timezone("Asia/Karachi"))
now -= timedelta(minutes=15) # ✅ align red line with program grid
if self.timeline_start <= now <= self.timeline_end:
minutes_since_start = (now - self.timeline_start).total_seconds() / 60
x = (minutes_since_start / 30) * self.timeline_slot_width
self.now_line.setLine(x, 0, x, len(self.channel_order) * self.row_height)
self.now_line.show()
else:
self.now_line.hide()
def scroll_to_current_time(self):
now = datetime.now(pytz.timezone("Asia/Karachi"))
now += timedelta(minutes=30) # match shifted timeline
# Snap to start of current 30-minute block
minute = now.minute
aligned_minute = 0 if minute < 30 else 30
aligned_now = now.replace(minute=aligned_minute, second=0, microsecond=0)
# ✅ Calculate X coordinate
minutes_since_start = (aligned_now - self.timeline_start).total_seconds() / 60
x = (minutes_since_start / 30)* self.timeline_slot_width
scroll_x = int(x - self.channel_column_width) +40
self.grid_view.horizontalScrollBar().setValue(max(scroll_x, 0))
def sync_vertical_scroll(self, value):
self.channel_view.verticalScrollBar().setValue(value)
self.grid_view.verticalScrollBar().setValue(value)
def channel_double_click_handler(self, graphics_item):
row = graphics_item.data(0)
if row is not None and self.main_window:
if 0 <= row < self.main_window.channel_list.count():
channel_number = graphics_item.data(1)
if channel_number is not None and self.main_window:
for i in range(self.main_window.channel_list.count()):
channel_item = self.main_window.channel_list.item(i)
if channel_item.data(Qt.UserRole + 2) == channel_number:
self.main_window.channel_list.setCurrentItem(channel_item)
self.main_window.on_channel_clicked(channel_item)
break
def eventFilter(self, watched, event):
if event.type() in [QEvent.GraphicsSceneHoverEnter, QEvent.GraphicsSceneHoverLeave, QEvent.GraphicsSceneMouseDoubleClick]:
if hasattr(event, "scenePos"):
pos = event.scenePos()
items = watched.items(pos)
for item in items:
if isinstance(item, QGraphicsRectItem):
row = item.data(0)
if event.type() == QEvent.GraphicsSceneHoverEnter:
item.setBrush(QBrush(QColor("#555")))
return True
elif event.type() == QEvent.GraphicsSceneHoverLeave:
color = "#444" if row % 2 == 0 else "#333"
item.setBrush(QBrush(QColor(color)))
return True
elif event.type() == QEvent.GraphicsSceneMouseDoubleClick:
channel_number = item.data(1)
if channel_number is not None and self.main_window:
for i in range(self.main_window.channel_list.count()):
channel_item = self.main_window.channel_list.item(i)
if channel_item.data(Qt.UserRole + 2) == channel_number:
self.main_window.channel_list.setCurrentItem(channel_item)
self.main_window.on_channel_clicked(channel_item)
break
return True
return False
def decode_epg_text(self, text):
if not text:
return ""
return unquote(text)
def refresh_channel_fps_info(self):
self.channel_scene.clear()
self.build_channels()
def update_epg(self, new_epg_data, channel_order, channel_video_info):
self.epg_data = new_epg_data
self.channel_order = channel_order
self.channel_video_info = channel_video_info
# ✅ Stop timers first
if hasattr(self, "_scroll_timers"):
for t in self._scroll_timers:
t.stop()
self._scroll_timers.clear()
self.timeline_scene.clear()
self.channel_scene.clear()
self.grid_scene.clear()
self.build_timeline()
self.build_channels()
self.build_program_grid()
self.draw_now_line()
# ✅ Force both scenes and views to match height exactly
total_rows = len(self.channel_order)
scene_height = total_rows * self.row_height
scene_width = len(self.timeline) * self.timeline_slot_width
self.grid_scene.setSceneRect(0, 0, scene_width, scene_height)
self.channel_scene.setSceneRect(0, 0, self.channel_column_width, scene_height)
self.grid_view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.channel_view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def load_custom_epg(self):
path, _ = QFileDialog.getOpenFileName(
self, "Select Custom EPG File", "", "EPG Files (*.xml *.gz)"
)
if not path:
return
try:
if path.endswith(".gz"):
with gzip.open(path, 'rb') as f:
xml_data = f.read().decode('utf-8')
else:
with open(path, "r", encoding="utf-8") as f:
xml_data = f.read()
custom_epg = ET.fromstring(xml_data)
print("[Custom EPG] Parsed successfully")
for programme in custom_epg.findall("programme"):
self.epg_data.append(programme)
self.main_window.epg_data = self.epg_data # sync to main
self.main_window.insert_dummy_epg_gaps()
# Update timeline start/end before rendering (forces alignment logic to work)
now = datetime.now(pytz.timezone("Asia/Karachi"))
if now.minute < 30:
aligned_minutes = 0
else:
aligned_minutes = 30
aligned_now = now.replace(minute=aligned_minutes, second=0, microsecond=0)
self.timeline_start = aligned_now - timedelta(days=1, minutes=15)
self.timeline_end = aligned_now + timedelta(days=2)
# Rebuild timeline to force block layout updates
self.timeline = []
t = self.timeline_start
while t <= self.timeline_end:
self.timeline.append(t)
t += timedelta(minutes=30)
self.update_epg(self.epg_data, self.channel_order, self.channel_video_info)
if self.main_window:
self.main_window.update_current_epg(self.main_window.epg_data)
print("[Custom EPG] Merged and displayed")
except Exception as e:
print(f"[Custom EPG] Failed: {e}")
def clear_epg(self):
self.epg_data.clear() # remove all
self.main_window.epg_data = self.epg_data # sync
# Reset view with dummy-only blocks
self.main_window.insert_dummy_epg_gaps()
self.update_epg(self.epg_data, self.channel_order, self.channel_video_info)
print("[EPG] Cleared to dummy state")
def draw_program_block(self, row, col_start, duration, title):
x = col_start * self.timeline_slot_width
y = row * self.row_height
w = duration * self.timeline_slot_width
h = self.row_height - 1
bg_color = "#444" if row % 2 == 0 else "#333"
rect = QGraphicsRectItem(x, y, w, h)
rect.setBrush(QBrush(QColor(bg_color)))
rect.setPen(QPen(QColor("#222")))
rect.setAcceptHoverEvents(True)
rect.setAcceptedMouseButtons(Qt.LeftButton)
rect.setData(0, row)
self.grid_scene.addItem(rect)
rect.channel_index = row
repeat_width = self.timeline_slot_width * 2 # 1 hour = 2 x 30min slots
if not hasattr(self, "_scroll_timers"):
self._scroll_timers = []
# Check if program ends within 30 mins and is longer than 1 hour
now = datetime.now(pytz.timezone("Asia/Karachi"))
start_time = self.timeline_start + timedelta(minutes=30 * col_start)
stop_time = self.timeline_start + timedelta(minutes=30 * (col_start + duration))
# Only apply right-alignment if:
# 1. Duration >= 2 (i.e. > 30 mins)
# 2. Program ENDS within next 30 mins
# 3. Program STARTED more than 30 mins ago
starts_within_30_mins = (now - start_time).total_seconds() <= 1800
ends_within_30_mins = (stop_time - now).total_seconds() <= 1800
ends_soon = (
duration == 2 and
ends_within_30_mins and
not starts_within_30_mins # ✅ program must NOT have started recently
)
def create_text_block(tx, tw, align="center"):
container = QGraphicsRectItem(tx, y, tw, h)
container.setPen(QPen(Qt.NoPen))
container.setBrush(QBrush(Qt.NoBrush))
container.setFlag(QGraphicsItem.ItemClipsChildrenToShape, True)
self.grid_scene.addItem(container)
text_item = QGraphicsTextItem(container)
text_item.setDefaultTextColor(QColor("white"))
font = QFont("Arial", 9)
text_item.setFont(font)
text_item.setTextWidth(tw)
html = f"""
{title.replace('\n', '
')}
"""
text_item.setHtml(html)
text_rect = text_item.boundingRect()
# Horizontal offset
if align == "left":
offset_x = 5
elif align == "right":
offset_x = max(0, tw - text_rect.width() - 5)
else: # center
offset_x = max(5, (tw - text_rect.width()) / 2)
# Vertical offset
if text_rect.height() <= h:
offset_y = y + (h - text_rect.height()) / 2
else:
offset_y = y
text_item.setPos(tx + offset_x, offset_y)
if text_rect.height() > h:
scroll_offset = text_rect.height() - h
scroll_speed = 10
interval_ms = 50
pixels_per_tick = scroll_speed * (interval_ms / 1000)
scroll_state = {
"base_x": tx + offset_x,
"y_pos": offset_y,
"max_offset": offset_y - scroll_offset,
"waiting": False,
"direction": -1
}
def scroll_text(text_item=text_item, state=scroll_state):
if state["waiting"]:
return
state["y_pos"] += state["direction"] * pixels_per_tick
if state["direction"] == -1 and state["y_pos"] < state["max_offset"]:
state["y_pos"] = state["max_offset"]
state["waiting"] = True
QTimer.singleShot(2000, lambda: change_direction(1, state))
elif state["direction"] == 1 and state["y_pos"] > offset_y:
state["y_pos"] = offset_y
state["waiting"] = True
QTimer.singleShot(2000, lambda: change_direction(-1, state))
text_item.setPos(state["base_x"], state["y_pos"])
def change_direction(new_direction, state):
state["direction"] = new_direction
state["waiting"] = False
timer = QTimer()
timer.timeout.connect(lambda: scroll_text())
timer.start(interval_ms)
def on_text_item_destroyed():
timer.stop()
text_item.destroyed.connect(on_text_item_destroyed)
self._scroll_timers.append(timer)
if ends_soon:
# Just one right-aligned title at end
create_text_block(x + w - repeat_width, repeat_width, align="right")
else:
# Repeat title every hour
num_repeats = max(1, int(w // repeat_width))
for i in range(num_repeats):
tx = x + i * repeat_width
tw = min(repeat_width, x + w - tx)
create_text_block(tx, tw, align="center")
def start_php_server(php_dir, port=8888):
global php_proc
try:
os.chdir(php_dir)
php_proc = subprocess.Popen(
["php", "-S", f"localhost:{port}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
return php_proc
except Exception as e:
print(f"Failed to start PHP server: {e}")
return None
def restart_php_server(php_dir):
global php_proc
try:
if php_proc:
print("[PHP] Restarting embedded PHP server...")
php_proc.terminate()
php_proc.wait(timeout=2)
except Exception as e:
print(f"[PHP] Error terminating PHP server: {e}")
# Start again
php_proc = start_php_server(php_dir)
def monitor_php_server(php_dir):
global php_proc
if php_proc and php_proc.poll() is not None:
print("[WARNING] PHP server exited. Restarting...")
start_php_server(php_dir)
def wait_for_php_server(host='localhost', port=8888, timeout=3):
"""Wait until PHP server is accepting connections."""
start_time = time.time()
while time.time() - start_time < timeout:
try:
with socket.create_connection((host, port), timeout=0.5):
return True
except (ConnectionRefusedError, socket.timeout, OSError):
time.sleep(0.1)
return False
if __name__ == "__main__":
php_dir = os.path.join(os.path.dirname(__file__), "php_files")
os.makedirs(php_dir, exist_ok=True)
php_proc = start_php_server(php_dir)
app = QApplication(sys.argv)
window = IPTVWindow(php_dir)
# ✅ Add this backup cleanup line
app.aboutToQuit.connect(window.close)
window.show()
# ✅ Move PHP monitor timer here BEFORE app.exec_()
php_timer = QTimer()
php_timer.timeout.connect(lambda: monitor_php_server(php_dir))
php_timer.start(15000)
# ✅ Debug active threads after 1 second
QTimer.singleShot(1000, lambda: print(f"[DEBUG] Threads at 1s: {[t.name for t in threading.enumerate()]}"))
exit_code = app.exec_()
# Stop PHP if still running
if php_proc:
php_proc.terminate()
php_proc.wait(2)
sys.exit(exit_code)