import os import sys import time import requests import subprocess import gzip import threading import hashlib import socket import platform from urllib.parse import unquote from datetime import datetime, timedelta,timezone from PyQt5.QtWidgets import ( QMainWindow, QVBoxLayout, QListWidget, QWidget, QHBoxLayout, QDialog, QDialogButtonBox, QListWidgetItem, QLabel, QSplitter, QFrame, QPushButton, QSlider, QSizePolicy, QGridLayout,QFileDialog,QGraphicsView, QGraphicsScene, QGraphicsRectItem, QGraphicsSimpleTextItem, QApplication, QGraphicsLineItem,QGraphicsTextItem,QGraphicsItem,QLineEdit,QInputDialog, QMessageBox ) from PyQt5.QtCore import Qt, QTimer, QThread, pyqtSignal,QEvent from PyQt5.QtGui import QPixmap, QFont, QColor,QBrush, QColor, QPen,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 CACHE_DIR = os.path.join(os.path.expanduser("~"), ".dsiptv", "logos") os.makedirs(CACHE_DIR, exist_ok=True) class EPGGridWindow(QMainWindow): # ... (rest of your class code before __init__) ... def __init__(self, epg_data, channel_order, channel_video_info, main_window=None, channel_logos=None): # <-- Restore original parameters here super().__init__() print("[DEBUG] EPGGridWindow __init__ called.") self.setWindowTitle("Playlist with Electronic Programme Guide (EPG)") self.channel_logos = channel_logos or {} control_height = 50 # or 60 for taller controls screen = QGuiApplication.primaryScreen() available_rect = screen.availableGeometry() left = available_rect.left() - 1 top = available_rect.top() width = available_rect.width() // 2 - 3 height = available_rect.height() - control_height + 12 self.setWindowFlags(self.windowFlags() | Qt.Window) self.showNormal() self.move(left, top) self.resize(width, height) self.epg_data = epg_data self.main_window = main_window # <--- Ensure main_window is still stored # Add these lines to check channel data sync if self.main_window: print(f"[DEBUG] EPGGridWindow __init__: main_window.channel_order count: {len(self.main_window.channel_order)}") print(f"[DEBUG] EPGGridWindow __init__: main_window.channel_video_info count: {len(self.main_window.channel_video_info)}") # Check the first few items of channel_order if it's not too long if self.main_window.channel_order: print(f"[DEBUG] EPGGridWindow __init__: First 3 channels in main_window.channel_order: {self.main_window.channel_order[:3]}") # Initialize channel data as empty, as it will be populated by initialize_grid later self.channel_order = [] self.channel_video_info = {} self.full_channel_order = [] # Will be populated by initialize_grid print(f"[DEBUG] EPGGridWindow __init__: Initial self.channel_order count: {len(self.channel_order)}") self.channel_column_width = 310 self.timeline_slot_width = 150 self.row_height = 50 self.timeline_row_height = 60 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) 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.timeline_view.setFixedHeight(self.timeline_row_height) 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.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: 10pt; 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) layout.setRowStretch(1, 1) control_layout = QHBoxLayout() 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)) 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) 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.update_epg_btn = QPushButton("๐Ÿ” Update All EPG") self.update_epg_btn.setFixedHeight(control_height) self.update_epg_btn.clicked.connect(self.update_all_epg_sources) control_layout.addWidget(self.update_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.initial_dummy_channel_count = 25 self.initialize_grid() # This will build an empty grid initially 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 build_blank_program_grid(self): print("[DEBUG] Building blank program grid...") self.grid_scene.clear() # Use self.displayed_channel_order for row count to match channels total_rows = len(self.displayed_channel_order) total_cols = len(self.timeline) for row in range(total_rows): for col in range(total_cols): x = col * self.timeline_slot_width y = row * self.row_height w = self.timeline_slot_width h = self.row_height - 1 rect = QGraphicsRectItem(x, y, w, h) rect.setBrush(QBrush(QColor("#222"))) rect.setPen(QPen(QColor("#111"))) self.grid_scene.addItem(rect) # Set scene rect for grid_scene to enable vertical and horizontal scrolling total_grid_width = total_cols * self.timeline_slot_width total_grid_height = total_rows * self.row_height self.grid_scene.setSceneRect(0, 0, total_grid_width, total_grid_height) print("[DEBUG] Finished building blank program grid.") def build_channels(self): print("[DEBUG] Building channels...") self.channel_scene.clear() total_scene_height = len(self.displayed_channel_order) * self.row_height self.channel_scene.setSceneRect(0, 0, self.channel_column_width, total_scene_height) for row, channel_item in enumerate(self.displayed_channel_order): is_real_channel = isinstance(channel_item, tuple) ch_name = channel_item[0] if is_real_channel else channel_item # CRITICAL CHANGE HERE: ch_id will now be the numeric ID from channel_order # which we modified in IPTVWindow.load_playlist to be the numeric index. ch_id = channel_item[1] if is_real_channel else None # This will now be the numeric ID string ##### MODIFIED LINE ##### 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.setAcceptedMouseButtons(Qt.LeftButton) rect.setFlag(QGraphicsRectItem.ItemIsSelectable, True) rect.setData(0, row) # Visual row index # Store the numeric channel ID for double-click retrieval if ch_id is not None: # Ensure ch_id is not None, especially for dummy channels rect.setData(1, ch_id) ##### MODIFIED LINE ##### print(f"[DEBUG] Storing EPG item ID: '{ch_id}' for channel '{ch_name}' (data role 1).") else: # Fallback for dummy channels or if ch_id is unexpectedly None rect.setData(1, str(row)) ##### MODIFIED LINE ##### print(f"[DEBUG] Storing EPG item ID (fallback, using row): '{str(row)}' for channel '{ch_name}' (data role 1).") self.channel_scene.addItem(rect) # Draw channel content (number, name, logo, info) ONLY if it's a real channel from the playlist if is_real_channel: try: # Use channel_item (the tuple) for lookup in full_channel_order original_index = int(ch_id) # Convert ch_id back to int for display (e.g., 10 for "10.") ##### MODIFIED LINE ##### except (ValueError, TypeError): original_index = row # Fallback if ch_id isn't a valid int gap_after_num = 8 gap_after_logo = 8 logo_width = 40 logo_height = 30 logo_area_width = logo_width logo_x = 10 text_y = y + 4 logo_pixmap = None logo_url = self.main_window.channel_logos.get(ch_name) # Assuming load_logo is a global function or accessible if logo_url: if hasattr(self, 'load_logo') and callable(getattr(self, 'load_logo')): logo_pixmap = self.load_logo(logo_url) elif 'load_logo' in globals() and callable(globals()['load_logo']): logo_pixmap = globals()['load_logo'](logo_url) else: print(f"[WARNING] load_logo function not found or not callable for {ch_name}.") if logo_pixmap and not logo_pixmap.isNull(): scaled_pixmap = logo_pixmap.scaled(logo_width, logo_height, Qt.KeepAspectRatio, Qt.SmoothTransformation) logo_item = self.channel_scene.addPixmap(scaled_pixmap) offset_x = logo_x + (logo_area_width - scaled_pixmap.width()) / 2 offset_y = y + (self.row_height - scaled_pixmap.height()) / 2 # Center vertically within row logo_item.setPos(offset_x, offset_y) logo_end_x = logo_x + logo_area_width + gap_after_logo else: logo_end_x = logo_x # Channel number num_item = QGraphicsSimpleTextItem(f"{original_index + 1}.") # Display the proper 1-based channel number ##### MODIFIED LINE ##### num_item.setFont(QFont("Arial", 10, QFont.Bold)) num_item.setBrush(QColor("white")) num_item.setPos(logo_end_x, text_y) self.channel_scene.addItem(num_item) # Channel name num_end_x = logo_end_x + num_item.boundingRect().width() + gap_after_num name_item = QGraphicsSimpleTextItem(ch_name) name_item.setFont(QFont("Arial", 10, QFont.Bold)) name_item.setBrush(QColor("white")) name_item.setPos(num_end_x, text_y) self.channel_scene.addItem(name_item) # FPS and resolution 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 + 2) self.channel_scene.addItem(info_item) # Draw horizontal separator lines for ALL rows (dummy or real) line = QGraphicsLineItem(0, y + self.row_height, self.channel_column_width, y + self.row_height) line.setPen(QPen(QColor("#444"), 1)) self.channel_scene.addItem(line) print(f"[DEBUG] Finished building {len(self.displayed_channel_order)} channels.") 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 EPGStore.get().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 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 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 clear_epg(self): EPGStore.clear() # Reset view with dummy-only blocks self.main_window.insert_dummy_epg_gaps() self.update_epg(self.channel_order, self.channel_video_info) print("[EPG] Cleared to dummy state") def decode_epg_text(self, text): if not text: return "" return unquote(text) 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 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 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: # --- ADD THESE DEBUG PRINTS (START) --- print(f"[DEBUG] Double-click detected on QGraphicsRectItem. Item data at role 0 (row): {item.data(0)}") channel_number_from_epg = item.data(1) # This should be the ch_id stored in build_channels print(f"[DEBUG] Retrieved channel ID from EPG item (data role 1): '{channel_number_from_epg}'") # --- ADD THESE DEBUG PRINTS (END) --- if channel_number_from_epg is not None and self.main_window: # --- ADD THIS DEBUG PRINT (START) --- print(f"[DEBUG] Attempting to find channel '{channel_number_from_epg}' in main_window.channel_list.") # --- ADD THIS DEBUG PRINT (END) --- found_item = None # Initialize found_item for i in range(self.main_window.channel_list.count()): channel_item = self.main_window.channel_list.item(i) # --- ADD THESE DEBUG PRINTS (START) --- list_item_text = channel_item.text() list_item_ch_id = channel_item.data(Qt.UserRole + 2) # This is where channel_id is stored in IPTVWindow print(f"[DEBUG] Checking list item: Text='{list_item_text}', Stored ID='{list_item_ch_id}'") # --- ADD THESE DEBUG PRINTS (END) --- if channel_item.data(Qt.UserRole + 2) == channel_number_from_epg: # --- ADD THESE DEBUG PRINTS (START) --- print(f"[DEBUG] Match found for channel ID: '{channel_number_from_epg}'") # --- ADD THESE DEBUG PRINTS (END) --- found_item = channel_item # Store the found item self.main_window.channel_list.setCurrentItem(channel_item) self.main_window.on_channel_clicked(channel_item) break # --- ADD THIS DEBUG PRINT (START) --- if not found_item: print(f"[ERROR] Channel item not found in main window list for EPG ID: '{channel_number_from_epg}'.") # --- ADD THIS DEBUG PRINT (END) --- return True # Event handled return False def filter_channels(self, search_text): # Using 'search_text' as parameter for clarity, but 'text' is fine too print(f"\n[DEBUG] filter_channels called with text: '{search_text}'") print(f"[DEBUG] filter_channels: self.full_channel_order count (before filter logic): {len(self.full_channel_order)}") # Use .strip() for better whitespace handling search_text = search_text.strip().lower() if search_text: try: # This line assumes self.full_channel_order contains (channel_name, channel_id) tuples. # If your channel_order contains something else (e.g., just channel names as strings), # the ValueError block will catch it. filtered_channels = [ ch for ch in self.full_channel_order if search_text in ch[0].lower() ] self.displayed_channel_order = filtered_channels # Use displayed_channel_order here for filtered list print(f"[DEBUG] filter_channels: Filtered self.displayed_channel_order count: {len(self.displayed_channel_order)}") if not filtered_channels and search_text: print(f"[DEBUG] filter_channels: No channels found for search term '{search_text}'.") elif filtered_channels: print(f"[DEBUG] filter_channels: First 3 filtered channels: {self.displayed_channel_order[:3]}") # Added this print except IndexError as e: # Changed to IndexError as ch[0] would fail if elements are not iterable print(f"[ERROR] filter_channels: Error accessing channel name. Is self.full_channel_order a list of (name, id) tuples? Error: {e}") self.displayed_channel_order = [] # Reset to empty to prevent further errors except Exception as e: # Catch any other unexpected errors during filtering print(f"[ERROR] filter_channels: An unexpected error occurred during filtering: {e}") self.displayed_channel_order = [] # Reset to empty else: self.displayed_channel_order = list(self.full_channel_order) # Reset to full list print(f"[DEBUG] filter_channels: Reset to full list. self.displayed_channel_order count: {len(self.displayed_channel_order)}") # Clear existing scenes self.channel_scene.clear() self.grid_scene.clear() print("[DEBUG] Scenes cleared.") # Rebuild channels and grid with filtered (or full) list print("[DEBUG] Calling build_timeline() from filter_channels.") # Added this print self.build_timeline() # Ensure timeline is also rebuilt if needed print("[DEBUG] Calling build_channels() from filter_channels.") self.build_channels() # Check if epg_data exists and has content before building program grid has_actual_epg_data_content = bool(self.epg_data) and self.epg_data.tag == "tv" and len(self.epg_data) > 0 if has_actual_epg_data_content: print("[DEBUG] Calling build_program_grid() from filter_channels.") # Added this print self.build_program_grid() else: print("[DEBUG] Calling build_blank_program_grid() from filter_channels.") # Added this print self.build_blank_program_grid() # Call blank if no actual EPG data print("[DEBUG] Calling draw_now_line...") self.draw_now_line() print("[DEBUG] Build functions called.") # Adjust scene rectangles based on new content height - CRITICAL FOR VISIBILITY total_height = len(self.displayed_channel_order) * self.row_height # Use displayed_channel_order self.channel_scene.setSceneRect(0, 0, self.channel_column_width, total_height) # The grid_scene width depends on timeline, not channel_order length. Ensure timeline has content. grid_scene_width = len(self.timeline) * self.timeline_slot_width if self.timeline else self.grid_view.width() self.grid_scene.setSceneRect(0, 0, grid_scene_width, total_height) # Updated grid_scene_width print(f"[DEBUG] Scene Rects adjusted. Channel Scene Height: {total_height}, Grid Scene Width: {grid_scene_width}") # Reset scrollbars to top - Includes horizontal for full view self.channel_view.verticalScrollBar().setValue(0) self.grid_view.verticalScrollBar().setValue(0) self.grid_view.horizontalScrollBar().setValue(0) # <-- ADD THIS LINE print("[DEBUG] Scrollbars reset. filter_channels complete.") def initialize_grid(self): print("[DEBUG] Initializing EPG grid...") # Sync channel data and EPG data from main_window if self.main_window: print(f"[DEBUG] initialize_grid: Syncing channel_order from main_window. Count: {len(self.main_window.channel_order)}") self.channel_order = list(self.main_window.channel_order) self.channel_video_info = dict(self.main_window.channel_video_info) self.epg_data = self.main_window.epg_data # Update epg_data as well # Keep the debug prints to confirm values print(f"[DEBUG] EPGGridWindow.initialize_grid: Synced self.epg_data. Type: {type(self.epg_data)}, Bool: {bool(self.epg_data)}") self.full_channel_order = list(self.channel_order) print(f"[DEBUG] initialize_grid: self.full_channel_order updated to count: {len(self.full_channel_order)}") if self.full_channel_order: print(f"[DEBUG] initialize_grid: First 3 items of self.full_channel_order: {self.full_channel_order[:3]}") else: print("[DEBUG] initialize_grid: No main_window reference available during sync. Using empty data.") self.channel_order = [] self.channel_video_info = {} self.full_channel_order = [] # Ensure epg_data is initialized as an empty ElementTree "tv" element if no main_window import xml.etree.ElementTree as ET # Ensure ET is imported if not already self.epg_data = ET.Element("tv") # Clear existing content from all scenes before redrawing self.timeline_scene.clear() self.channel_scene.clear() self.grid_scene.clear() # --- REVISED LOGIC FOR DISPLAYING CHANNELS --- # Channels should always be displayed if available from the playlist, # otherwise, dummy channels are used. This is independent of EPG data. if len(self.channel_order) > 0: print(f"[DEBUG] Displaying {len(self.channel_order)} actual channels from playlist.") self.displayed_channel_order = list(self.channel_order) # Use real channels from the playlist else: print(f"[DEBUG] No actual channels from playlist. Displaying {self.initial_dummy_channel_count} dummy channels.") self.displayed_channel_order = [f"Channel {i+1}" for i in range(self.initial_dummy_channel_count)] # --- END REVISED LOGIC FOR DISPLAYING CHANNELS --- # Add this print to verify displayed_channel_order after selection print(f"[DEBUG] initialize_grid: self.displayed_channel_order count: {len(self.displayed_channel_order)}") if self.displayed_channel_order: print(f"[DEBUG] initialize_grid: First 3 items of self.displayed_channel_order: {self.displayed_channel_order[:3]}") # Now, call the existing methods to build the grid based on the chosen channel list self.build_timeline() self.build_channels() # This will now use self.displayed_channel_order (real or dummy) # --- REVISED LOGIC FOR PROGRAM GRID CONTENT --- # Determine if we have actual, non-empty EPG data to build the program grid. # This is for the *programs* within the grid, not the channel names. has_actual_epg_data_content = bool(self.epg_data) and self.epg_data.tag == "tv" and len(self.epg_data) > 0 if has_actual_epg_data_content: print("[DEBUG] Building program grid with actual EPG data content.") self.build_program_grid() # Build grid with real program blocks else: print("[DEBUG] No actual EPG data content available or is empty. Building blank program grid.") self.build_blank_program_grid() # Build an empty grid (e.g., for channels without EPG or when EPG download failed) # --- END REVISED LOGIC FOR PROGRAM GRID CONTENT --- self.draw_now_line() # Draw the current time line # Ensure the view scrolls to the current time after building QTimer.singleShot(100, self.scroll_to_current_time) print("[DEBUG] EPG grid initialization complete.") def load_custom_epg(self): dialog = QDialog(self) dialog.setWindowTitle("Add Custom EPG Source") layout = QVBoxLayout(dialog) label = QLabel("Enter EPG URL or click Browse to select a file (.xml or .gz):") layout.addWidget(label) h_layout = QHBoxLayout() url_input = QLineEdit() browse_btn = QPushButton("Browse") h_layout.addWidget(url_input) h_layout.addWidget(browse_btn) layout.addLayout(h_layout) format_hint = QLabel("Accepted formats: .xml, .gz") format_hint.setStyleSheet("color: gray; font-size: 10pt;") layout.addWidget(format_hint) button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) layout.addWidget(button_box) def on_browse(): file_path, _ = QFileDialog.getOpenFileName(self, "Select EPG File", "", "EPG Files (*.xml *.gz)") if file_path: url_input.setText(file_path) browse_btn.clicked.connect(on_browse) def on_accept(): source = url_input.text().strip() if source: if source not in self.main_window.custom_epg_sources: self.main_window.custom_epg_sources.append(source) self.main_window.log_debug(f"Added custom EPG source: {source}") # Load only this newly added source without clearing EPGStore try: if source.startswith("http://") or source.startswith("https://"): response = requests.get(source, timeout=10) response.raise_for_status() try: xml_data = gzip.decompress(response.content).decode('utf-8') except: xml_data = response.content.decode('utf-8') else: if source.endswith(".gz"): with gzip.open(source, 'rb') as f: xml_data = f.read().decode('utf-8') else: with open(source, 'r', encoding='utf-8') as f: xml_data = f.read() root = ET.fromstring(xml_data) for programme in root.findall("programme"): EPGStore.append(programme) self.main_window.insert_dummy_epg_gaps() self.main_window.update_current_epg() self.main_window.update_epg_info(self.main_window.current_channel_name, self.main_window.current_channel_resolution) self.update_epg(self.channel_order, self.channel_video_info) except Exception as e: self.main_window.log_debug(f"[Custom EPG] Failed to load from {source}: {e}") dialog.accept() button_box.accepted.connect(on_accept) button_box.rejected.connect(dialog.reject) dialog.exec_() def refresh_channel_fps_info(self): self.channel_scene.clear() self.build_channels() 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 setup_now_autorefresh(self): now = datetime.now() # Align to next :00 or :30 if now.minute < 30: next_run = now.replace(minute=30, second=0, microsecond=0) else: next_run = now.replace(hour=now.hour + 1 if now.hour < 23 else 0, minute=0, second=0, microsecond=0) delay_ms = max(0, int((next_run - now).total_seconds() * 1000)) QTimer.singleShot(delay_ms, self.start_now_timer) def start_now_timer(self): self.trigger_jump_to_now() self._now_timer = QTimer() self._now_timer.timeout.connect(self.trigger_jump_to_now) self._now_timer.start(30 * 60 * 1000) # run every 30 mins *after* correct alignment def sync_vertical_scroll(self, value): self.channel_view.verticalScrollBar().setValue(value) self.grid_view.verticalScrollBar().setValue(value) def trigger_jump_to_now(self): print("[EPG Grid] Auto-jumping to current time (on clock boundary)") self.scroll_to_current_time() def update_all_epg_sources(self): if not self.main_window: print("[EPG] Main window reference is missing.") return print("[EPG] Refreshing all default and custom EPG sources...") # Only clear if we know we have EPG sources to re-download if self.main_window.default_epg_urls or self.main_window.custom_epg_sources: EPGStore.clear() # Download and merge all default EPGs for url in self.main_window.default_epg_urls: print(f"[EPG] Downloading default EPG: {url}") try: response = requests.get(url, timeout=10) response.raise_for_status() try: xml_data = gzip.decompress(response.content).decode('utf-8') except: xml_data = response.content.decode('utf-8') root = ET.fromstring(xml_data) for programme in root.findall("programme"): EPGStore.append(programme) except Exception as e: print(f"[EPG] Failed to download from {url}: {e}") # Load all custom EPGs (local files or URLs) for source in self.main_window.custom_epg_sources: print(f"[Custom EPG] Loading: {source}") try: if source.startswith("http://") or source.startswith("https://"): response = requests.get(source, timeout=10) response.raise_for_status() try: xml_data = gzip.decompress(response.content).decode('utf-8') except: xml_data = response.content.decode('utf-8') else: if source.endswith(".gz"): with gzip.open(source, 'rb') as f: xml_data = f.read().decode('utf-8') else: with open(source, 'r', encoding='utf-8') as f: xml_data = f.read() root = ET.fromstring(xml_data) for programme in root.findall("programme"): EPGStore.append(programme) except Exception as e: print(f"[Custom EPG] Failed to load from {source}: {e}") self.main_window.epg_data = EPGStore.get() # Fill gaps and update UI self.main_window.insert_dummy_epg_gaps() self.main_window.update_current_epg() self.main_window.update_epg_info(self.main_window.current_channel_name, self.main_window.current_channel_resolution) self.update_epg(self.channel_order, self.channel_video_info) total_programs = len(EPGStore.get().findall(".//programme")) print(f"[EPG] Update complete. Total programs: {total_programs}") self.main_window.log_debug(f"EPG updated: {total_programs} entries") def update_channels_data(self, new_channel_order, new_epg_data=None): """ Updates the channel list and EPG data in EPGGridWindow after the main IPTVWindow has fully loaded them. """ print(f"[DEBUG] EPGGridWindow.update_channels_data called. New channel count: {len(new_channel_order)}") self.channel_order = new_channel_order self.full_channel_order = list(new_channel_order) # This is key: Update the full list! if new_epg_data: self.epg_data = new_epg_data print("[DEBUG] EPGGridWindow.update_channels_data: EPG data updated.") # Now, rebuild the grid with the actual data self.channel_scene.clear() self.grid_scene.clear() self.build_channels() self.build_program_grid() self.draw_now_line() # Make sure this is called if it draws the 'now' line # Adjust scene rectangles total_height = len(self.channel_order) * self.row_height self.channel_scene.setSceneRect(0, 0, self.channel_column_width, total_height) self.grid_scene.setSceneRect(0, 0, len(self.timeline) * self.timeline_slot_width, total_height) # Reset scrollbars to top self.channel_view.verticalScrollBar().setValue(0) self.grid_view.verticalScrollBar().setValue(0) self.grid_view.horizontalScrollBar().setValue(0) print("[DEBUG] EPGGridWindow.update_channels_data complete. UI rebuilt.") 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 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 update_epg(self, channel_order, channel_video_info): self.channel_order = channel_order self.channel_video_info = channel_video_info # --- THIS IS THE KEY LINE: Update self.full_channel_order here --- # This ensures your search function has the full, updated list to work with. self.full_channel_order = list(self.channel_order) # โœ… 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) class EPGStore: _tree = ET.Element("tv") # โœ… Initialize with an empty root @classmethod def set(cls, tree): cls._tree = tree if tree is not None else ET.Element("tv") @classmethod def get(cls): return cls._tree @classmethod def append(cls, programme): if cls._tree is not None: cls._tree.append(programme) @classmethod def clear(cls): cls._tree = ET.Element("tv") class IPTVWindow(QMainWindow): def __init__(self, php_dir): self.channel_video_info = {} self.epg_grid_window = None self.channel_logos = {} # โœ… initialize before it's used super().__init__() self.setWindowTitle("DSPT IPTV Player") 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_last_update = None self.debug_log = [] self.default_epg_urls = [] # Holds default EPG URLs from playlist(s) self.custom_epg_sources = [] # Holds both custom EPG files and URLs self.channel_order = [] # โœ… Prevent AttributeError self.full_channel_order = list(self.channel_order) self.init_ui() # Get usable screen area screen = QGuiApplication.primaryScreen().availableGeometry() screen_left = screen.left() screen_top = screen.top() screen_width = screen.width() screen_height = screen.height() # Dimensions for top-right quadrant window_width = screen_width // 2 window_height = screen_height // 2 # Shift down a bit to show title bar and avoid EPG overlap safe_margin = 40 # ensures title bar is visible window_top = screen_top + safe_margin window_left = screen_left + window_width # right half # Slightly reduce height to prevent overlapping EPGGrid adjusted_height = screen_height // 2 - safe_margin self.setGeometry(window_left, window_top, window_width, adjusted_height) self.epg_data = ET.Element("tv") # placeholder in case it's accessed early self.show() # โœ… Show Main Window Immediately # STEP 2: Show blank EPG Grid window (structure only) self.epg_grid_window = EPGGridWindow(ET.Element("tv"), [], {}, self) self.epg_grid_window.show() # STEP 3: Show loading message and schedule playlist download in background self.show_status_message("๐Ÿ“ฅ Downloading Default Playlist...") QTimer.singleShot(1000, self.download_playlist) self.stream_error_label = None # for "Stream not available" overlay 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 # 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 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 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 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(self.current_channel_name or 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 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) lines = response.text.splitlines() fixed_lines = [] for line in lines: if line.startswith("http") and "iptv.nywebforum.com" in line: line = line.replace("https://iptv.nywebforum.com", "http://localhost:8888") fixed_lines.append(line) content = "\n".join(fixed_lines) # Save modified playlist (with local proxy) 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}") # Load into app self.load_playlist(path) # Step 4: Fill dummy EPG data self.insert_dummy_epg_gaps() if self.epg_grid_window: self.epg_grid_window.update_epg(self.channel_order, self.channel_video_info) # STEP 5: Start internal PHP server in background threading.Thread(target=lambda: restart_php_server(self.php_dir), daemon=True).start() # Step 6: Start playing first channel immediately if self.channel_list.count() > 0: first = self.channel_list.item(0) self.channel_list.setCurrentItem(first) QTimer.singleShot(500, lambda: self.on_channel_clicked(first)) # Step 7: Extract EPG URLs from first line if present first_line = response.text.split('\n')[0] if first_line.startswith("#EXTM3U") and "url-tvg=" in first_line: epg_raw = first_line.split('url-tvg="')[1].split('"')[0] epg_urls = [url.strip() for url in epg_raw.split(',') if url.strip()] new_urls = [url for url in epg_urls if url not in self.default_epg_urls] self.default_epg_urls.extend(new_urls) if epg_urls: self.log_debug(f"Found EPG URLs: {epg_urls}") QTimer.singleShot(2000, self.update_epg_data) # Delayed fetch in background # โœ… Step 8: Load logos in background (after everything else settles) def start_logo_prefetch(): def prefetch_logos(): os.makedirs(CACHE_DIR, exist_ok=True) for name, url in self.channel_logos.items(): if not url: continue try: logo_filename = hashlib.md5(url.encode('utf-8')).hexdigest() + ".png" logo_path = os.path.join(CACHE_DIR, logo_filename) if not os.path.exists(logo_path): response = requests.get(url, timeout=5) if response.status_code == 200: with open(logo_path, "wb") as f: f.write(response.content) except Exception as e: print(f"[LOGO] Failed to fetch logo for {name}: {e}") # Refresh EPG visuals from Qt thread if self.epg_grid_window: QTimer.singleShot(0, self.epg_grid_window.refresh_channel_fps_info) threading.Thread(target=prefetch_logos, daemon=True).start() self.log_debug("๐Ÿ–ผ๏ธ Background logo fetcher started...") # Delay logo prefetching so it runs AFTER first channel is loaded QTimer.singleShot(4000, start_logo_prefetch) except Exception as e: self.log_debug(f"Failed to download playlist: {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 keyPressEvent(self, event): if event.key() == Qt.Key_Escape and self._is_fullscreen: self.toggle_fullscreen() else: super().keyPressEvent(event) 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 tvg_id = None # Renamed to tvg_id for clarity (original id from playlist) ##### MODIFIED LINE ##### self.channel_order = [] # Reset and initialize channel order self.channel_video_info = {} # Ensure this is reset or cleared if loading new playlist for i, l in enumerate(lines): if l.startswith("#EXTINF:"): name = l.split(',')[-1].strip() tvg_id = None # Reset for each channel ##### ADDED LINE ##### if 'tvg-id="' in l: tvg_id = l.split('tvg-id="')[1].split('"')[0] self.log_debug(f"Found tvg-id: {tvg_id} for {name}") ##### MODIFIED LINE ##### if 'tvg-logo="' in l: logo = l.split('tvg-logo="')[1].split('"')[0] if name: self.channel_logos[name] = logo self.log_debug(f"Logo for {name}: {logo}") elif l and not l.startswith("#") and name: it = QListWidgetItem(f"{name} (Loading...)") it.setData(Qt.UserRole, l) # stream URL # Store the tvg-id explicitly if available (e.g., for EPG program matching) if tvg_id: ##### MODIFIED LINE ##### it.setData(Qt.UserRole + 1, tvg_id) ##### MODIFIED LINE ##### # CRITICAL CHANGE: Use the current *index* as the stable channel ID # This ID will be used for both the QListWidgetItem and EPG grid items. current_channel_id = len(self.channel_order) ##### MODIFIED LINE ##### it.setData(Qt.UserRole + 2, str(current_channel_id)) # Store as string for consistency with Qt.UserRole values ##### MODIFIED LINE ##### # Update channel_order with (channel_name, numeric_channel_id_as_string) self.channel_order.append((name, str(current_channel_id))) # Use the numeric ID here ##### MODIFIED LINE ##### self.channel_list.addItem(it) # Placeholder for video info (will be updated when channel plays) self.channel_video_info[name] = ("NA", "NA") if first is None: first = it name = None tvg_id = None # Reset tvg_id after processing channel entry ##### MODIFIED LINE ##### 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)) # CRITICAL ADDITION: Re-initialize the EPG grid after loading playlist if self.epg_grid_window: # Ensure the EPG window object exists print("[DEBUG] Playlist loaded. Re-initializing EPG grid with new channel data.") self.epg_grid_window.initialize_grid() QMessageBox.information(self, "Playlist Loaded", "Playlist and channels loaded successfully!") except Exception as e: self.log_debug(f"Failed to load playlist: {e}") QMessageBox.critical(self, "Error", f"Failed to load playlist: {e}") 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 on_channel_clicked(self, item: QListWidgetItem): url = item.data(Qt.UserRole) if not url: return base = item.text().split('(')[0].strip() # Load logo if available logo_pixmap = None logo_url = self.channel_logos.get(base) if logo_url: logo_pixmap = load_logo(logo_url) self.current_logo_pixmap = logo_pixmap # Store for use in update_epg_info item.setText(f"{base} (Loading...)") # NEW โ”€ store 1-based channel number for the EPG header self.current_channel_number = int(item.data(Qt.UserRole + 2) or 0) + 1 # Convert to int before adding 1 self.current_channel_name = base self.current_channel_resolution = "Loading..." channel_id = item.data(Qt.UserRole + 1) if channel_id: self.current_channel_id = channel_id self.update_current_epg() # โœ… Now that EPG is ready, display it 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 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 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 EPGStore.set(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.channel_order, self.channel_video_info) else: # Create fresh EPGGridWindow AFTER full EPG is available self.epg_grid_window = EPGGridWindow( EPGStore.get(), self.channel_order, self.channel_video_info, self, channel_logos=self.channel_logos ) 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') and self.current_channel_id: print("[EPG] Initializing current channel EPG immediately after download") self.update_current_epg() self.update_epg_info(self.current_channel_name, self.current_channel_resolution) # Setup auto-refresh aligned to clock self.setup_epg_auto_refresh() except Exception as e: self.log_debug(f"Error processing EPG data: {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 retry_detect_resolution(self, item, base, retries=6): def try_fps_only(): info = self.player.get_stream_info() if info and info != "NA": self.current_channel_resolution = info self.update_epg_info(self.current_channel_name or base, info) return else: self.current_channel_resolution = "Stream not available" self.update_epg_info(self.current_channel_name or base, "Stream not available") if retries > 0: QTimer.singleShot(10000, lambda: self.retry_detect_resolution(item, base, retries - 1)) try_fps_only() 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 setup_epg_auto_refresh(self): now = datetime.now() # Align to next :00 or :30 if now.minute < 30: next_run = now.replace(minute=30, second=0, microsecond=0) else: next_run = now.replace(hour=now.hour + 1 if now.hour < 23 else 0, minute=0, second=0, microsecond=0) delay_ms = int((next_run - now).total_seconds() * 1000) QTimer.singleShot(delay_ms, self.start_half_hour_timer) def show_status_message(self, msg): self.epg_display.setText(f"
{msg}
") def start_half_hour_timer(self): self.update_current_epg() # Update EPG data self.update_epg_info(self.current_channel_name, self.current_channel_resolution) # Start timer for next 30-minute interval self._epg_timer = QTimer() self._epg_timer.timeout.connect(self.trigger_epg_update) self._epg_timer.start(30 * 60 * 1000) # every 30 mins 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 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 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 toggle_play_pause(self): mp = self.player.media_player if mp.is_playing(): mp.pause() else: mp.play() def trigger_epg_update(self): print("[EPG] Auto-updating at 30-minute boundary") self.update_current_epg() self.update_epg_info(self.current_channel_name, self.current_channel_resolution) self.scroll_to_current_time() # If you want to auto-scroll like the grid does 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 in Asia/Karachi local_tz = pytz.timezone("Asia/Karachi") now_local = datetime.now(local_tz) current_prog = None next_prog = None for programme in EPGStore.get().findall(".//programme"): if programme.get("channel") != self.current_channel_id: continue try: # Parse UTC times from EPG start_utc = datetime.strptime(programme.get("start")[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc) stop_utc = datetime.strptime(programme.get("stop")[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc) # Convert to local time start_local = start_utc.astimezone(local_tz) stop_local = stop_utc.astimezone(local_tz) if start_local <= now_local < stop_local: current_prog = programme elif start_local > now_local: next_prog = programme break # EPG is sorted by time, so first match is sufficient except Exception as e: print(f"[EPG Parse Error] Failed to parse start/stop: {e}") continue # Set current program if current_prog is not None: self.current_channel_epg = { 'title': unquote(current_prog.findtext('title', 'No Programme Information')), 'start': current_prog.get('start'), 'stop': current_prog.get('stop'), 'desc': unquote(current_prog.findtext('desc', '')) } else: self.current_channel_epg = None # Set next program if next_prog is not None: self.next_program_epg = { 'title': unquote(next_prog.findtext('title', 'No information')), 'start': next_prog.get('start'), 'stop': next_prog.get('stop'), 'desc': unquote(next_prog.findtext('desc', '')) } else: self.next_program_epg = None # โœ… If current program expired, force immediate display update if self.current_channel_epg: stop_time_str = self.current_channel_epg.get('stop') try: stop_time_utc = datetime.strptime(stop_time_str[:14], "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc) stop_time_local = stop_time_utc.astimezone(local_tz) if now_local >= stop_time_local: 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 Stop Check] Failed to parse stop time: {e}") except Exception as e: print(f"Error updating current EPG: {str(e)}") def update_epg_data(self): if not self.default_epg_urls: self.log_debug("No default EPG URLs available") return # Clear previous EPG before bulk reload EPGStore.clear() for url in self.default_epg_urls: self.log_debug(f"Downloading EPG from: {url}") try: response = requests.get(url, timeout=10) response.raise_for_status() self.process_epg_data(response.content) except Exception as e: self.log_debug(f"[EPG] Failed to download from {url}: {e}") self.setup_epg_auto_refresh() def update_epg_info(self, name=None, resolution=None): if not hasattr(self, "current_channel_name"): return logo_html = "" if getattr(self, "current_logo_pixmap", None): from PyQt5.QtCore import QBuffer buffer = QBuffer() buffer.open(QBuffer.ReadWrite) self.current_logo_pixmap.save(buffer, "PNG") base64_data = bytes(buffer.data().toBase64()).decode() logo_html = f"" ch_num = getattr(self, "current_channel_number", "") num_prefix = f"{ch_num}. " if ch_num != "" else "" # โ”€โ”€โ”€โ”€โ”€ Top table (name, resolution, title, time โ€” with logo) epg_text = f"""
{num_prefix}{name}
{resolution}
""" # โ”€โ”€โ”€โ”€โ”€ Current program (title + time + progress) if self.current_channel_epg: title = self.current_channel_epg.get("title", "No Programme Information") epg_text += f"""
{title}
""" if "start" in self.current_channel_epg and "stop" in self.current_channel_epg: try: local_tz = pytz.timezone("Asia/Karachi") start_local = datetime.strptime( self.current_channel_epg["start"][:14], "%Y%m%d%H%M%S" ).replace(tzinfo=timezone.utc).astimezone(local_tz) stop_local = datetime.strptime( self.current_channel_epg["stop"][:14], "%Y%m%d%H%M%S" ).replace(tzinfo=timezone.utc).astimezone(local_tz) now_local = datetime.now(local_tz) start_str = start_local.strftime("%I:%M%p").lstrip("0") stop_str = stop_local.strftime("%I:%M%p").lstrip("0") duration = int((stop_local - start_local).total_seconds() // 60) elapsed = (now_local - start_local).total_seconds() total = (stop_local - start_local).total_seconds() percent = max(0, min(100, int((elapsed / total) * 100))) if total > 0 else 0 progress_bar = f"""
""" epg_text += f"""
{start_str} โ€“ {stop_str} ({duration} mins)
{progress_bar} """ except Exception: pass else: epg_text += """
No Programme Information
""" epg_text += f"""
{logo_html}
""" # โ”€โ”€โ”€โ”€โ”€ Current program description (placed BELOW table!) if self.current_channel_epg and self.current_channel_epg.get("desc"): epg_text += f"""
{self.current_channel_epg['desc']}
""" # โ”€โ”€โ”€โ”€โ”€ Next program (after current description) if self.next_program_epg: epg_text += f"""
Next: {self.next_program_epg.get('title', 'No information')}
""" if self.next_program_epg.get("desc"): epg_text += f"""
{self.next_program_epg['desc']}
""" if "start" in self.next_program_epg and "stop" in self.next_program_epg: try: local_tz = pytz.timezone("Asia/Karachi") start_local = datetime.strptime( self.next_program_epg["start"][:14], "%Y%m%d%H%M%S" ).replace(tzinfo=timezone.utc).astimezone(local_tz) stop_local = datetime.strptime( self.next_program_epg["stop"][:14], "%Y%m%d%H%M%S" ).replace(tzinfo=timezone.utc).astimezone(local_tz) start_str = start_local.strftime("%I:%M%p").lstrip("0") stop_str = stop_local.strftime("%I:%M%p").lstrip("0") epg_text += f"""
{start_str} โ€“ {stop_str}
""" except Exception: pass self.epg_display.setText(epg_text) 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 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 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 check_playback_timeout(self): if not self.media_player.is_playing(): print("[VLC] Stream timeout โ€“ stopping playback") self.media_player.stop() 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 release(self): try: self.media_player.release() except Exception: pass try: self.instance.release() except Exception: pass def stop(self): self.media_player.stop() def get_logo_path(url): """Returns local cache path for a logo URL""" hashname = hashlib.md5(url.encode('utf-8')).hexdigest() return os.path.join(CACHE_DIR, f"{hashname}.png") def load_logo(url): """Returns QPixmap (cached or downloaded later)""" path = get_logo_path(url) if os.path.exists(path): return QPixmap(path) # Instead of blocking download, skip if not in cache return None # Later, we can trigger background fetch 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 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 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 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() # Now create and show EPG window AFTER main window is visible if window.epg_grid_window is None: window.epg_grid_window = EPGGridWindow( epg_data=window.epg_data, main_window=window, # Explicitly pass the main window instance channel_logos=window.channel_logos # channel_order and channel_video_info are now handled by initialize_grid() ) # Note: If your EPG window is not showing, ensure this line is present window.epg_grid_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()]}")) if platform.system() == "Windows": try: import ctypes # Set Per Monitor v2 DPI Awareness awareness_context = 3 # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(awareness_context)) print("[DPI] Enabled Per-Monitor V2 DPI Awareness") except Exception as e: print("[DPI] DPI awareness setting failed:", e) exit_code = app.exec_() # Stop PHP if still running if php_proc: php_proc.terminate() php_proc.wait(2) sys.exit(exit_code)