import os import sys import time import requests import subprocess import gzip import math import threading import socket 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 ) 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.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"" epg_text += "
{name}{resolution}
" # 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): 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': programme.find('title').text if programme.find('title') is not None else 'No Programme Information', 'start': start, 'stop': stop, 'desc': 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': programme.find('title').text if programme.find('title') is not None else 'No information', 'start': start, 'stop': stop } 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) if channel_id: it.setData(Qt.UserRole + 1, channel_id) self.channel_list.addItem(it) self.channel_order.append((name, channel_id)) # ✅ Maintain order for EPG 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.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() self.jump_now_btn = QPushButton("\u23F0 Jump to Now") self.jump_now_btn.setStyleSheet("font-size: 14px; padding: 5px;") self.jump_now_btn.clicked.connect(self.scroll_to_current_time) control_layout.addWidget(self.jump_now_btn) self.close_btn = QPushButton("\u274C Close EPG") self.close_btn.setStyleSheet("font-size: 14px; background-color: red; color: white; padding: 5px;") self.close_btn.clicked.connect(self.close) control_layout.addWidget(self.close_btn) layout.addLayout(control_layout, 2, 1, 1, 2, alignment=Qt.AlignRight) 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 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_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" # Combine with line break full_label = f"{date_str}\n{time_str}" 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_channels(self): for row, (ch_name, _) in enumerate(self.channel_order): y = row * self.row_height 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.setData(0, row) rect.setAcceptHoverEvents(True) rect.setFlag(QGraphicsRectItem.ItemIsSelectable, True) self.channel_scene.addItem(rect) 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 title = prog.findtext("title", "No 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_item = self.main_window.channel_list.item(row) self.main_window.channel_list.setCurrentItem(channel_item) self.main_window.on_channel_clicked(channel_item) 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: if row is not None and self.main_window: if 0 <= row < self.main_window.channel_list.count(): channel_item = self.main_window.channel_list.item(row) self.main_window.channel_list.setCurrentItem(channel_item) self.main_window.on_channel_clicked(channel_item) return True return False 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() def build_channels(self): for row, (ch_name, _) in enumerate(self.channel_order): y = row * self.row_height 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.setData(0, row) self.channel_scene.addItem(rect) rect.setFlag(QGraphicsRectItem.ItemIsSelectable, True) name_item = QGraphicsSimpleTextItem(f"{row + 1}. {ch_name}") 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) 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 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.setData(0, row) self.grid_scene.addItem(rect) 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)