import os import sys import time import requests import subprocess import gzip import io from datetime import datetime, timedelta,timezone from PyQt5.QtWidgets import ( QApplication, QMainWindow, QVBoxLayout, QListWidget, QWidget, QHBoxLayout, QListWidgetItem, QLabel, QSplitter, QFrame, QPushButton, QSlider, QSizePolicy, QScrollArea,QGridLayout ) from PyQt5.QtCore import Qt, QTimer, QEvent, QTime, QUrl, QThread, pyqtSignal from PyQt5.QtGui import QFont,QPainter, QColor import pytz import vlc import xml.etree.ElementTree as ET 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())) def play_stream(self, url): media = self.instance.media_new(url) media.add_option(":network-caching=1000") self.media_player.set_media(media) self.media_player.play() self.media_player.audio_set_volume(70) def stop(self): self.media_player.stop() def release(self): self.media_player.release() self.instance.release() 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): super().__init__() self.setWindowTitle("IPTV Player with 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.epg_grid_window = None self.init_ui() self.installEventFilter(self) self.download_playlist() 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) # 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) 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, resolution): 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 not self.epg_data 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 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"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.epg_last_update = datetime.now() 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 toggle_epg_grid(self): if not self.epg_data: self.log_debug("EPG data not loaded yet.") return if self.epg_grid_window is None: self.epg_grid_window = EPGGridWindow(self.epg_data, self.channel_order) if self.epg_grid_window.isVisible(): self.epg_grid_window.hide() else: self.epg_grid_window.show() def detect_resolution(self, item, base): 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" item.setText(f"{base} ({info})") self.current_channel_name = base self.current_channel_resolution = info self.update_epg_info(base, info) def on_channel_clicked(self, item: QListWidgetItem): url = item.data(Qt.UserRole) self.player.play_stream(url) # Get channel ID from item data channel_id = item.data(Qt.UserRole + 1) if channel_id: self.current_channel_id = channel_id self.update_current_epg() base = item.text().split('(')[0].strip() item.setText(f"{base} (Loading...)") self.current_channel_name = base self.update_epg_info(base, "Loading...") QTimer.singleShot(4000, 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}") self.update_epg_data() # Download EPG immediately 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)) except Exception as e: self.log_debug(f"Failed to load playlist: {e}") def closeEvent(self, event): self.log_debug("Application closing - cleaning up resources") if self.epg_grid_window and self.epg_grid_window.isVisible(): self.epg_grid_window.close() # Stop timers self.clock_timer.stop() self.epg_update_timer.stop() # Stop player self.player.stop() self.player.release() # Stop any ongoing EPG download if hasattr(self, 'epg_downloader') and self.epg_downloader.isRunning(): self.epg_downloader.quit() self.epg_downloader.wait() super().closeEvent(event) class EPGGridWindow(QMainWindow): def __init__(self, epg_data, channel_order): super().__init__() self.setWindowTitle("Full EPG") self.resize(1400, 800) self.setStyleSheet(""" background-color: #222; color: white; font-size: 10pt; QLabel { padding: 5px; } QScrollArea { border: none; } """) self.epg_data = epg_data self.channel_order = channel_order self.row_height = 60 self.timeline_slot_width = 150 # Align timeline start to previous 30-min mark # Snap timeline_start to the closest 30-minute mark BEFORE now now = datetime.now(pytz.timezone("Asia/Karachi")) rounded_minute = 0 if now.minute < 30 else 30 aligned_now = now.replace(minute=rounded_minute, second=0, microsecond=0) # Keep full EPG span: yesterday to +2 or +3 days self.timeline_start = aligned_now - timedelta(days=1) self.timeline_end = aligned_now + timedelta(days=2) # or timedelta(days=3) # Build timeline list self.timeline = [] t = self.timeline_start while t <= self.timeline_end: self.timeline.append(t) t += timedelta(minutes=30) # Main layout self.main_widget = QWidget() self.setCentralWidget(self.main_widget) self.main_layout = QGridLayout(self.main_widget) self.main_layout.setSpacing(0) self.main_layout.setContentsMargins(0, 0, 0, 0) # Top-left corner self.corner_widget = QWidget() self.corner_widget.setFixedSize(300, 40) self.corner_widget.setStyleSheet("background-color: #333;") self.main_layout.addWidget(self.corner_widget, 0, 0) # Timeline header (centered above dividing lines) self.timeline_scroll = QScrollArea() self.timeline_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.timeline_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.timeline_scroll.setWidgetResizable(True) self.timeline_widget = QWidget() self.timeline_widget.setStyleSheet("background-color: #333;") # Shift the whole timeline label row left by 0.5 * slot width left_offset = self.timeline_slot_width // 2 self.timeline_widget.setContentsMargins(left_offset, 0, 0, 0) self.timeline_layout = QGridLayout(self.timeline_widget) self.timeline_layout.setSpacing(1) self.timeline_layout.setContentsMargins(0, 0, 0, 0) # Shifted timeline labels: create one every 30 minutes, but 15 mins earlier for i in range(len(self.timeline) - 1): t = self.timeline[i + 1] # Label at the NEXT time point text = f"{t.strftime('%a %b %d')}
{t.strftime('%I:%M %p').lstrip('0')}" label = QLabel(text) label.setTextFormat(Qt.RichText) label.setAlignment(Qt.AlignCenter) label.setWordWrap(True) label.setFixedHeight(60) label.setStyleSheet(""" background-color: #333; font-weight: bold; color: white; """) self.timeline_layout.addWidget(label, 0, i) # Ensure columns align with program grid for col in range(len(self.timeline)): self.timeline_layout.setColumnMinimumWidth(col, self.timeline_slot_width) self.timeline_scroll.setWidget(self.timeline_widget) self.main_layout.addWidget(self.timeline_scroll, 0, 1) # Channel names (left) self.channel_scroll = QScrollArea() self.channel_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.channel_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.channel_scroll.setWidgetResizable(True) self.channel_widget = QWidget() self.channel_layout = QVBoxLayout(self.channel_widget) self.channel_layout.setSpacing(1) self.channel_layout.setContentsMargins(0, 0, 0, 0) for row, (ch_name, ch_id) in enumerate(self.channel_order): label = QLabel(ch_name) label.setWordWrap(True) label.setStyleSheet(f"background-color: {'#333' if row % 2 else '#444'};") label.setFixedHeight(self.row_height) self.channel_layout.addWidget(label) self.channel_scroll.setWidget(self.channel_widget) self.main_layout.addWidget(self.channel_scroll, 1, 0) # Program grid (right bottom) self.program_scroll = QScrollArea() self.program_scroll.setWidgetResizable(True) self.program_widget = QWidget() self.epg_layout = QGridLayout(self.program_widget) self.epg_layout.setSpacing(1) self.epg_layout.setContentsMargins(0, 0, 0, 0) # ✅ Apply fixed column widths after epg_layout is ready for col in range(len(self.timeline)): self.epg_layout.setColumnMinimumWidth(col, self.timeline_slot_width) for row, (ch_name, ch_id) in enumerate(self.channel_order): self.add_channel_epg(row, ch_id) self.program_scroll.setWidget(self.program_widget) self.main_layout.addWidget(self.program_scroll, 1, 1) # Synchronize scrolling self.program_scroll.horizontalScrollBar().valueChanged.connect( self.timeline_scroll.horizontalScrollBar().setValue ) self.timeline_scroll.horizontalScrollBar().valueChanged.connect( self.program_scroll.horizontalScrollBar().setValue ) self.program_scroll.verticalScrollBar().valueChanged.connect( self.channel_scroll.verticalScrollBar().setValue ) self.channel_scroll.verticalScrollBar().valueChanged.connect( self.program_scroll.verticalScrollBar().setValue ) # Close button self.close_btn = QPushButton("❌ Close EPG") self.close_btn.setStyleSheet(""" font-size: 14px; background-color: red; color: white; padding: 5px; margin: 5px; """) self.close_btn.clicked.connect(self.close) self.main_layout.addWidget(self.close_btn, 2, 0, 1, 2) # Stretch factors self.main_layout.setColumnStretch(0, 0) self.main_layout.setColumnStretch(1, 1) self.main_layout.setRowStretch(0, 0) self.main_layout.setRowStretch(1, 1) # Clock in corner self.clock_label = QLabel() self.clock_label.setStyleSheet("background-color: #333; font-weight: bold;") self.clock_label.setAlignment(Qt.AlignCenter) self.corner_widget.layout = QVBoxLayout(self.corner_widget) self.corner_widget.layout.addWidget(self.clock_label) self.update_clock() self.clock_timer = QTimer(self) self.clock_timer.timeout.connect(self.update_clock) self.clock_timer.start(1000) # Auto-scroll to current time - 30 min target_time = datetime.now(pytz.timezone("Asia/Karachi")) - timedelta(minutes=30) start_index = max(0, int((target_time - self.timeline_start).total_seconds() // 1800)) x_pos = start_index * self.timeline_slot_width QTimer.singleShot(100, lambda: self.program_scroll.horizontalScrollBar().setValue(x_pos)) # Overlay for current time line self.overlay = QWidget(self.program_widget) self.overlay.setAttribute(Qt.WA_TransparentForMouseEvents) self.overlay.setStyleSheet("background: transparent;") self.overlay.raise_() self.overlay.resize(self.program_widget.size()) self.overlay.paintEvent = self.paint_now_line self.program_widget.resizeEvent = lambda e: self.overlay.resize(self.program_widget.size()) # Timer to update vertical line self.now_line_timer = QTimer(self) self.now_line_timer.timeout.connect(self.overlay.update) self.now_line_timer.start(60000) # Every minute QTimer.singleShot(200, self.overlay.update) 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 add_channel_epg(self, row, channel_id): bg = "#333" if row % 2 else "#444" local_tz = pytz.timezone("Asia/Karachi") now = datetime.now(local_tz) programs = [p for p in self.epg_data.findall(".//programme") if p.get("channel") == channel_id] # If no EPG at all, fill entire row with repeating "No Program Info" if not programs: current_col = 0 while current_col < len(self.timeline): block = min(len(self.timeline) - current_col, 4) filler = TimelineLabel( text="No Program Information Available", start_offset=current_col, duration=block, parent_grid=self, timeline_slot_width=self.timeline_slot_width ) filler.setFixedHeight(self.row_height) self.epg_layout.addWidget(filler, row, current_col, 1, block) current_col += block return # Convert all valid program blocks cells = [] 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") # Clamp within timeline 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) cells.append((start_offset, duration, title)) except: continue cells.sort() current_col = 0 for start_offset, duration, title in cells: # Fill gap before program with "No Info" if start_offset > current_col: gap_duration = start_offset - current_col while gap_duration > 0: block = min(gap_duration, 4) filler = TimelineLabel( text="No Program Information Available", start_offset=current_col, duration=block, parent_grid=self, timeline_slot_width=self.timeline_slot_width ) filler.setFixedHeight(self.row_height) self.epg_layout.addWidget(filler, row, current_col, 1, block) current_col += block gap_duration -= block # Actual program cell cell = TimelineLabel( text=title, start_offset=start_offset, duration=duration, parent_grid=self, timeline_slot_width=self.timeline_slot_width ) cell.setFixedHeight(self.row_height) self.epg_layout.addWidget(cell, row, start_offset, 1, duration) current_col = start_offset + duration # Fill remaining timeline with "No Info" while current_col < len(self.timeline): block = min(len(self.timeline) - current_col, 4) filler = TimelineLabel( text="No Program Information Available", start_offset=current_col, duration=block, parent_grid=self, timeline_slot_width=self.timeline_slot_width ) filler.setFixedHeight(self.row_height) self.epg_layout.addWidget(filler, row, current_col, 1, block) current_col += block def build_timeline_header(self): # Timeline scroll area self.timeline_scroll = QScrollArea() self.timeline_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.timeline_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.timeline_scroll.setWidgetResizable(True) # Timeline widget and layout self.timeline_widget = QWidget() self.timeline_widget.setStyleSheet("background-color: #333;") # ✅ Shift timeline labels left by 15 minutes (half slot width) self.timeline_widget.setContentsMargins(self.timeline_slot_width // 2, 0, 0, 0) self.timeline_layout = QGridLayout(self.timeline_widget) self.timeline_layout.setSpacing(1) self.timeline_layout.setContentsMargins(0, 0, 0, 0) # Create labels centered across dividing lines for i in range(len(self.timeline) - 1): t = self.timeline[i + 1] # Label marks the dividing line label = QLabel(f"{t.strftime('%I:%M %p').lstrip('0')}") label.setAlignment(Qt.AlignHCenter | Qt.AlignBottom) label.setFixedHeight(40) label.setStyleSheet("background-color: #333; font-weight: bold;") # Add label with column span = 2 self.timeline_layout.addWidget(label, 0, i, 1, 2) # Ensure each column in timeline matches program grid column width for col in range(len(self.timeline)): self.timeline_layout.setColumnMinimumWidth(col, self.timeline_slot_width) self.timeline_scroll.setWidget(self.timeline_widget) self.main_layout.addWidget(self.timeline_scroll, 0, 1) def paint_now_line(self, event): from PyQt5.QtGui import QPainter, QPen painter = QPainter(self.overlay) pen = QPen(Qt.red, 2) painter.setPen(pen) now = datetime.now(pytz.timezone("Asia/Karachi")) seconds_since_start = (now - self.timeline_start).total_seconds() x = (seconds_since_start / 1800) * self.timeline_slot_width painter.drawLine(int(x), 0, int(x), self.overlay.height()) def update_now_line_position(self): now = datetime.now(pytz.timezone("Asia/Karachi")) offset = int((now - self.timeline_start).total_seconds() // 1800) x = offset * self.timeline_slot_width self.now_line.move(x, 0) self.now_line.setFixedHeight(self.program_widget.height()) class TimelineLabel(QLabel): def __init__(self, text, start_offset, duration, parent_grid, timeline_slot_width): super().__init__() 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(False) self.setAlignment(Qt.AlignVCenter) self.setText("") # We draw text ourselves def paintEvent(self, event): super().paintEvent(event) painter = QPainter(self) # Get visible scroll offset in pixels scroll_px = self.parent_grid.program_scroll.horizontalScrollBar().value() cell_x = self.start_offset * self.timeline_slot_width visible_x = cell_x - scroll_px # Draw text if any part of the cell is visible if visible_x + self.width() > 0 and visible_x < self.parent_grid.program_scroll.viewport().width(): painter.setPen(QColor("white")) # Draw text inside visible area painter.drawText(self.rect(), Qt.AlignCenter, self.full_text) def start_php_server(php_dir, port=8888): try: os.chdir(php_dir) return subprocess.Popen([ "php", "-S", f"localhost:{port}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 ) except Exception as e: print(f"Failed to start PHP server: {e}") return None 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) window.show() exit_code = app.exec_() # Ensure clean exit if php_proc: php_proc.terminate() php_proc.wait(2) sys.exit(exit_code)