From ddeed788c31f80305db5fa7fa55daa3f4c8a301b Mon Sep 17 00:00:00 2001 From: Ignacio Rivero Date: Fri, 7 Mar 2025 19:11:27 -0300 Subject: [PATCH] Major refactoring of code, added volume controls --- __init__.py | 3 + __pycache__/channels.cpython-313.pyc | Bin 0 -> 2526 bytes __pycache__/osd.cpython-313.pyc | Bin 0 -> 12798 bytes __pycache__/player.cpython-313.pyc | Bin 0 -> 7358 bytes __pycache__/qt_process.cpython-313.pyc | Bin 0 -> 2999 bytes __pycache__/server.cpython-313.pyc | Bin 0 -> 16124 bytes __pycache__/utils.cpython-313.pyc | Bin 0 -> 4272 bytes __pycache__/volume.cpython-313.pyc | Bin 0 -> 7221 bytes __pycache__/volume_osd.cpython-313.pyc | Bin 0 -> 10493 bytes channels.py | 57 ++ main.py | 734 ++----------------------- osd.py | 250 +++++++++ player.py | 151 +++++ qt_process.py | 94 ++++ server.py | 274 +++++++++ templates/index.html | 74 ++- utils.py | 98 ++++ volume.py | 181 ++++++ volume_osd.py | 211 +++++++ 19 files changed, 1445 insertions(+), 682 deletions(-) create mode 100644 __init__.py create mode 100644 __pycache__/channels.cpython-313.pyc create mode 100644 __pycache__/osd.cpython-313.pyc create mode 100644 __pycache__/player.cpython-313.pyc create mode 100644 __pycache__/qt_process.cpython-313.pyc create mode 100644 __pycache__/server.cpython-313.pyc create mode 100644 __pycache__/utils.cpython-313.pyc create mode 100644 __pycache__/volume.cpython-313.pyc create mode 100644 __pycache__/volume_osd.cpython-313.pyc create mode 100644 channels.py mode change 100755 => 100644 main.py create mode 100644 osd.py create mode 100644 player.py create mode 100644 qt_process.py create mode 100644 server.py create mode 100644 utils.py create mode 100644 volume.py create mode 100644 volume_osd.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b6183bc --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +"""IPMPV - IPTV Player with MPV""" + +__version__ = "0.1.0" diff --git a/__pycache__/channels.cpython-313.pyc b/__pycache__/channels.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b2e320739deb6e0af184c82e3635cdb81c38376 GIT binary patch literal 2526 zcma)8OKclO7@pnr+8cZGs@*1iC6iYh2ginnKuKw7)VgV!S7p~FQV!PEo;WMnyYB2d zO`=Lfa3HOynm|Jeryy~FTaFw-qT;|MR-lQdr79|y-da)wf&HUP791*K(V-HSD3u0W7i26$ z5O*B_*d3SY?8%5h_srol6PjV5EX-+!mO(jW*l^s$u>a!F#mnKR%*$|4Af8-TAz8rL zRLQMF6go@vxA}rbvk1nv_u5OG76PCFjPY1O%(X4D%{1@O>lU__4Uhma&$h@m(_P{q z7-<^w@#VP0^&l8i`D(0+b7k60^DoQ33t~0pJ zJ0$AMJDlq-ormEXJIL3@4s$Lc@(4NggY%0381iVFOod{NT(V5#ZZ6ADK23cLQJV=@ ztf?%=xh+dv$8aayTu-Z|I__8tmn_rr$&CnckKy@WItuqebt(*{ltEkBD(B)}E*bZ+ zERBlMwz#X4a?Y1GA;pez*)kpdd~_!zV$9a-?2Zcji%%b^gY z9TFJW~5;Tn}vMXJ99XSG40uLWTz%{BeTn84;-E`o&BDzrcTm;l8M+LG)x;> z$PUAcS)^Hr@ibuFg2*@}^-SA|N7fxlnku zP@5=&rih>unI0fDQb~7+R!HwCdw829@aH3UH|y{mwjt>p}F%LYG7S$E2?cj zt4CjWK}FSS)q5|fZm;-I@!eK#D)Y4;`5vo*+e0^o*45Uc+PbD5nT2+Ul%@^DV z>|YvO7+l`_Mg3Z!V=nrrtz*7>>BPc`yI1d=S`p{YK05Nsd~|7GVPILfGrUqa7v1pJ zFC1SwwQy>=WzBzN85jK>b7wd9)ZOjqRH1sb8;dFIKm20+Hsg)~*I#TWMPHukPvoNqu9(zxP)EKhI>k^|!^a`Bq1? z0^F+zMBkL|9j=ZZlkSBC#vhaEoMQFP2LmkURZLT65xhzfCIKjhQ~WZ-gb>>)Thd9; zx0N(%U6U8?J<(EPq<(m_1CS%8}ca!eP2d|5^a6hfcGAUTXb;RafAF|KOZw zL#ba^T8c``a^F{d#n#tXuB<6fHD=lPi=2*R(R dG0IDAV?=N8g@2(~xuz60X6b?BC{sX{BI=BD; literal 0 HcmV?d00001 diff --git a/__pycache__/osd.cpython-313.pyc b/__pycache__/osd.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed23d3bec485cb23574d50f828f74119976f4e6a GIT binary patch literal 12798 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 literal 0 HcmV?d00001 diff --git a/__pycache__/player.cpython-313.pyc b/__pycache__/player.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9c4749b30b7c4271dc5f942645e7c15347b4239 GIT binary patch literal 7358 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 E-ztyhEC2ui literal 0 HcmV?d00001 diff --git a/__pycache__/qt_process.cpython-313.pyc b/__pycache__/qt_process.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..949d16ed43ba063605fc038d9e2a9f3390085857 GIT binary patch literal 2999 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 literal 0 HcmV?d00001 diff --git a/__pycache__/volume.cpython-313.pyc b/__pycache__/volume.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ecb8aef691b2a623868360e43f374555512507c GIT binary patch literal 7221 zcmb_hU2q)7ah~1Ty`Q~5`~!ptVnF~T4gvvCGNlOofsX+HWD^##1WgICuNHTU!-1M5(0*rk+}2R@_+ZpJARrMa{}}ysVt)%EzsCos2<6W9G<0qeg&0KP z6#hG$!LgP%c-B&bvQ{tz){2JMMfMVfb`wQ7#M@j9SAq|VxuHnPROQQhD!Z)7F+H6z z^pw1qO~*1xJ#EU1x*?x=|iBSiEu#B{U*2e#=^BNspUIpU^ULENd8AI#Z?}0fsR{Q%!9wuGyW+ zh@__DFe{NrY4UP5qm4>4G3Pi@m?%mLf7?HX>K3`!l!DeS7v&UJ>5r!zp~KP7krd8h zW!~fTW|;yxPj9Q5BNKfit7Wwb`5ZQEf~lqBs2j_gX{rf0cR0Fp85z_d z25%V2_mpXAChW1K>eej*m$iH~Os#;ub~LjDXB@Y@b_={M;k$b5lI1Ipr`SU@2~vwP zj$r}VX{klS4MH~XoD45iCZ0bE&Dt}zq%Uh@$wXQmJC)3gC0CYLE|1ylMpv#`o~X?& zYIR(wwT4mWeJiqi5voO&x5yqck0h+jRy5FgLjYVrmumI7Jankc zvrbd-<5@us-f32lJkL8bK?~h|wX5%--EN}(f{-*fU**hy9cP8kall`w@~`yY&Y=u@bKMP~ zP_^sP+@O+;p>Zu{Q>kl!i4JM4$qh{D*;HIk>lt}538<{fW;U5&%T&Jt;J~W_?lYHSiDz+rX1|um_F{=E2H=zx z#3>QU@w545k%wwpV^(>;_U(gbIN{HkzPk z1abPV2FtI)OFvHePpH87g-&|-v!-3QL+^#wgr9~!2o;;gSHE?AVv~CEzVV;8{cG

|fXa?8fKh;0b^Mfz^Ja(_|UTfZabe$gitfRNo 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 literal 0 HcmV?d00001 diff --git a/__pycache__/volume_osd.cpython-313.pyc b/__pycache__/volume_osd.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1367ae02ac767da01b0d8ee1a43019170f84431e GIT binary patch literal 10493 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 literal 0 HcmV?d00001 diff --git a/channels.py b/channels.py new file mode 100644 index 0000000..390fc73 --- /dev/null +++ b/channels.py @@ -0,0 +1,57 @@ +#!/usr/bin/python +"""Channel management for IPMPV.""" + +import re +import requests +import sys +from utils import m3u_url + +def get_channels(): + """ + Get a list of channels from the M3U playlist. + + Returns: + list: A list of channel dictionaries with name, url, logo, and group. + """ + if m3u_url: + try: + response = requests.get(m3u_url) + response.raise_for_status() # Raise exception for HTTP errors + except requests.RequestException as e: + print(f"Error fetching M3U playlist: {e}") + return [] + else: + print("Error: IPMPV_M3U_URL not set. Please set this environment variable to the URL of your IPTV list, in M3U format.") + sys.exit(1) + + lines = response.text.splitlines() + + channels = [] + regex = re.compile(r'tvg-logo="(.*?)".*?group-title="(.*?)"', re.IGNORECASE) + + for i in range(len(lines)): + if lines[i].startswith("#EXTINF"): + match = regex.search(lines[i]) + logo = match.group(1) if match else "" + group = match.group(2) if match else "Other" + name = lines[i].split(",")[-1] + url = lines[i + 1] + + channels.append({"name": name, "url": url, "logo": logo, "group": group}) + + return channels + +def group_channels(channels): + """ + Group channels by their group title. + + Args: + channels (list): List of channel dictionaries. + + Returns: + dict: Dictionary of channel groups. + """ + grouped_channels = {} + for channel in channels: + grouped_channels.setdefault(channel["group"], []).append(channel) + return grouped_channels diff --git a/main.py b/main.py old mode 100755 new mode 100644 index d04486d..2fa1268 --- a/main.py +++ b/main.py @@ -1,687 +1,71 @@ #!/usr/bin/python -import sys -import mpv -import requests -import flask -import re -import subprocess -import os -import time +"""Entry point for IPMPV.""" + import multiprocessing - -from flask import request, jsonify, send_from_directory - -from PyQt5.QtWidgets import * -from PyQt5.QtCore import * -from PyQt5.QtGui import * - from multiprocessing import Queue +import sys -os.environ["LC_ALL"] = "C" -os.environ["LANG"] = "C" +# Set up utils first +from utils import setup_environment, get_current_resolution, ipmpv_retroarch_cmd -is_wayland = "WAYLAND_DISPLAY" in os.environ -osd_corner_radius = os.environ.get("IPMPV_CORNER_RADIUS") -ipmpv_retroarch_cmd = os.environ.get("IPMPV_RETROARCH_CMD") +# Initialize environment +setup_environment() -to_qt_queue = Queue() -from_qt_queue = Queue() +# Set up channel data +from channels import get_channels -M3U_URL = os.environ.get('IPMPV_M3U_URL') +# Import remaining modules +from player import Player +from server import IPMPVServer +from qt_process import qt_process +from volume import VolumeControl -class OsdWidget(QWidget): - def __init__(self, channel_info, width=600, height=165, close_time=5, corner_radius=int(osd_corner_radius) if osd_corner_radius is not None else 15): +def main(): + """Main entry point for IPMPV.""" + # Create communication queues + to_qt_queue = Queue() + from_qt_queue = Queue() + + # Get initial data + channels = get_channels() + resolution = get_current_resolution() + + # Initialize player + player = Player(to_qt_queue) - QFontDatabase.addApplicationFont('FiraSans-Regular.ttf') - QFontDatabase.addApplicationFont('FiraSans-Bold.ttf') - - global is_wayland - super().__init__() - - self.channel_info = channel_info - self.orig_width = width - self.orig_height = height - self.close_time = close_time - self.corner_radius = corner_radius - self.video_codec = None - self.audio_codec = None - self.video_res = None - self.interlaced = None - - # Setup window - self.setWindowTitle("OSD") - self.setFixedSize(width, height) - - # Check if we're running on Wayland - self.is_wayland = is_wayland - - # Set appropriate window flags and size - if self.is_wayland: - # For Wayland, use fullscreen transparent approach - self.setWindowFlags( - Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint | - Qt.WindowDoesNotAcceptFocus - ) - - # Set fullscreen size - self.screen_geometry = QApplication.desktop().screenGeometry() - self.setFixedSize(self.screen_geometry.width(), self.screen_geometry.height()) - - # Calculate content positioning - self.content_x = (self.screen_geometry.width() - self.orig_width) // 2 - self.content_y = 20 # 20px from top - else: - # For X11, use the original approach - self.setWindowFlags( - Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint | - Qt.X11BypassWindowManagerHint | - Qt.Tool | - Qt.ToolTip - ) - self.setFixedSize(width, height) - self.content_x = 0 - self.content_y = 0 - - # Enable transparency - self.setAttribute(Qt.WA_TranslucentBackground) - - # Position window at the top center of the screen - self.position_window() - - # Load logo if available - self.logo_pixmap = None - if channel_info["logo"]: - self.load_logo() - - if self.is_wayland: - self.setAttribute(Qt.WA_TransparentForMouseEvents) - - def position_window(self): - if self.is_wayland: - # For Wayland, we just position at 0,0 (fullscreen) - self.move(0, 0) - - # Ensure window stays on top - self.stay_on_top_timer = QTimer(self) - self.stay_on_top_timer.timeout.connect(lambda: self.raise_()) - self.stay_on_top_timer.start(100) # Check every second - else: - # For X11, center at top - screen_geometry = QApplication.desktop().screenGeometry() - x = (screen_geometry.width() - self.orig_width) // 2 - y = 20 # 20px from top - self.setGeometry(x, y, self.orig_width, self.orig_height) - - # X11 specific window hints - self.setAttribute(Qt.WA_X11NetWmWindowTypeNotification) - QTimer.singleShot(100, lambda: self.move(x, y)) - QTimer.singleShot(500, lambda: self.move(x, y)) - - # Periodically ensure window stays on top - self.stay_on_top_timer = QTimer(self) - self.stay_on_top_timer.timeout.connect(lambda: self.raise_()) - self.stay_on_top_timer.start(1000) # Check every second - - def load_logo(self): - try: - response = requests.get(self.channel_info["logo"]) - if response.ok: - pixmap = QPixmap() - pixmap.loadFromData(response.content) - if not pixmap.isNull(): - self.logo_pixmap = pixmap.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.update() # Trigger repaint - except Exception as e: - print(f"Failed to load logo: {e}") - - def paintEvent(self, a0): - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - - if self.is_wayland: - # For Wayland, we're drawing the content in the right position on a fullscreen widget - self.draw_osd_content(painter, self.content_x, self.content_y) - else: - # For X11, we're drawing directly at (0,0) since the widget is already positioned - self.draw_osd_content(painter, 0, 0) - def draw_osd_content(self, painter, x_offset, y_offset): - # Create a path for rounded rectangle background - path = QPainterPath() - path.addRoundedRect( - x_offset, y_offset, - self.orig_width, self.orig_height, - self.corner_radius, self.corner_radius - ) - - # Fill the rounded rectangle with semi-transparent background - painter.setPen(Qt.NoPen) - painter.setBrush(QColor(0, 50, 100, 200)) # RGBA - painter.drawPath(path) - - # Setup text drawing - painter.setPen(QColor(255, 255, 255)) - - try: - font = QFont("Fira Sans", 18) - font.setBold(True) - painter.setFont(font) - - # Draw channel name - painter.drawText(x_offset + 20, y_offset + 40, self.channel_info["name"]) - - font.setPointSize(14) - font.setBold(False) - painter.setFont(font) - - # Draw deinterlace status - painter.drawText(x_offset + 20, y_offset + 70, f"Deinterlacing {'on' if self.channel_info['deinterlace'] else 'off'}") - - # Draw latency mode - painter.drawText(x_offset + 20, y_offset + 100, f"{'Low' if self.channel_info['low_latency'] else 'High'} latency") - - # Draw codec badges if available - if self.video_codec: - self.draw_badge(painter, self.video_codec, x_offset + 80, y_offset + self.orig_height - 40) - if self.audio_codec: - self.draw_badge(painter, self.audio_codec, x_offset + 140, y_offset + self.orig_height - 40) - if self.video_res: - self.draw_badge(painter, f"{self.video_res}{self.interlaced if self.interlaced is not None else ''}", x_offset + 20, y_offset + self.orig_height - 40) - # Draw logo if available - if self.logo_pixmap: - painter.drawPixmap(x_offset + self.orig_width - 100, y_offset + 20, self.logo_pixmap) - - except Exception as e: - print(f"Error in painting: {e}") - import traceback - traceback.print_exc() - - def draw_badge(self, painter, text, x, y): - # Save current painter state - painter.save() - - # Draw rounded badge - painter.setPen(QPen(QColor(255, 255, 255, 255), 2)) - painter.setBrush(Qt.NoBrush) - - # Use QPainterPath for consistent rounded corners - badge_path = QPainterPath() - badge_path.addRoundedRect(x, y, 48, 20, 7, 7) - painter.drawPath(badge_path) - - # Draw text - painter.setPen(QColor(255, 255, 255)) - font = painter.font() - font.setBold(True) - font.setPointSize(8) - painter.setFont(font) - - # Center text in badge - font_metrics = painter.fontMetrics() - text_width = font_metrics.width(text) - text_height = font_metrics.height() - - # We need to use integer coordinates for drawText, not floats - text_x = int(x + (48 - text_width) / 2) - text_y = int(y + text_height) - - # Use the int, int, string version of drawText - painter.drawText(text_x, text_y, text) - - # Restore painter state - painter.restore() - - def update_codecs(self, video_codec, audio_codec, video_res, interlaced): - if video_codec: - self.video_codec = video_codec - if audio_codec: - self.audio_codec = audio_codec - if video_res: - self.video_res = video_res - if interlaced is not None: - self.interlaced = f"{'i' if interlaced else 'p'}" - self.update() # Trigger repaint - - def close_widget(self): - # Stop any active timers - if hasattr(self, 'stay_on_top_timer') and self.stay_on_top_timer.isActive(): - self.stay_on_top_timer.stop() - # Close the widget - self.hide() - - def start_close_timer(self, seconds=5): - """ - Starts a timer to close the widget after the specified number of seconds. - - Parameters: - seconds (int): Number of seconds before closing the widget (default: 3) - """ - # Cancel any existing close timer - if hasattr(self, 'close_timer') and self.close_timer.isActive(): - self.close_timer.stop() - - # Create and start a new timer - self.close_timer = QTimer(self) - self.close_timer.setSingleShot(True) - self.close_timer.timeout.connect(self.close_widget) - self.close_timer.start(seconds * 1000) # Convert seconds to milliseconds - -osd = None - -def qt_process(): - """Run Qt application in a separate process""" - from PyQt5.QtWidgets import QApplication - from PyQt5.QtCore import QTimer - - app = QApplication(sys.argv) - osd = None - - # Check the queue periodically for commands - - def check_queue(): - global osd - if not to_qt_queue.empty(): - command = to_qt_queue.get() - if command['action'] == 'show_osd': - if osd is not None: - osd.close_widget() - # Create new OSD - osd = OsdWidget(command['channel_info']) - if is_wayland: - osd.showFullScreen() - else: - osd.show() - elif command['action'] == 'start_close': - if osd is not None: - osd.start_close_timer() - elif command['action'] == 'close_osd': - if osd is not None: - osd.close_widget() - osd = None - elif command['action'] == 'update_codecs': - if osd is not None: - osd.update_codecs(command['vcodec'], command['acodec'], command['video_res'], command['interlaced']) - # Schedule next check - QTimer.singleShot(100, check_queue) - - # Start the queue check - check_queue() - - # Run Qt event loop - app.exec_() - -def get_channels(): - if M3U_URL: - response = requests.get(M3U_URL) - else: - print("Error: IPMPV_M3U_URL not set. Please set this environment variable to the URL of your IPTV list, in M3U format.") - exit(1) - lines = response.text.splitlines() - - channels = [] - regex = re.compile(r'tvg-logo="(.*?)".*?group-title="(.*?)"', re.IGNORECASE) - - for i in range(len(lines)): - if lines[i].startswith("#EXTINF"): - match = regex.search(lines[i]) - logo = match.group(1) if match else "" - group = match.group(2) if match else "Other" - name = lines[i].split(",")[-1] - url = lines[i + 1] - - channels.append({"name": name, "url": url, "logo": logo, "group": group}) - - return channels - -def get_current_resolution(): - global is_wayland + # Initialize volume control + volume_control = VolumeControl(to_qt_queue=to_qt_queue) + + # Start Qt process + qt_proc = multiprocessing.Process( + target=qt_process, + args=(to_qt_queue, from_qt_queue), + daemon=True + ) + qt_proc.start() + + # Start Flask server + server = IPMPVServer( + channels=channels, + player=player, + to_qt_queue=to_qt_queue, + from_qt_queue=from_qt_queue, + resolution=resolution, + ipmpv_retroarch_cmd=ipmpv_retroarch_cmd, + volume_control=volume_control + ) + try: - if is_wayland: - wlr_randr_env = os.environ.copy() - output = subprocess.check_output(["wlr-randr"], universal_newlines=True, env=wlr_randr_env) - if "Composite-1" in output.split("\n")[0]: - for line in output.split("\n"): - if "720x480" in line and "current" in line: - return "480i" - elif "720x240" in line and "current" in line: - return "240p" - elif "720x576" in line and "current" in line: - return "576i" - elif "720x288" in line and "current" in line: - return "288p" - else: - xrandr_env = os.environ.copy() - output = subprocess.check_output(["xrandr"], universal_newlines=True, env=xrandr_env) - for line in output.split("\n"): - if "Composite-1" in line: - if "720x480" in line: - return "480i" - elif "720x240" in line: - return "240p" - elif "720x576" in line: - return "576i" - elif "720x288" in line: - return "288p" - except subprocess.CalledProcessError: - if is_wayland: - print("Error: Cannot get display resolution. Is this a wl-roots compatible compositor?") - else: - print("Error: Cannot get display resolution. Is an X session running?") - except FileNotFoundError: - if is_wayland: - print("Error: Could not find wlr-randr, resolution will be unknown") - else: - print("Error: Could not find xrandr, resolution will be unknown") - return "UNK" - -def error_check(loglevel, component, message): - global player - print(f"[{loglevel}] {component}: {message}") - if loglevel == 'error' and (component == 'ffmpeg' or component == 'cplayer') and 'Failed' in message: - player.loadfile("./nosignal.png") - to_qt_queue.put({ - 'action': 'start_close' - }) - -player = mpv.MPV( - # it's a bit weird that i have to use the logs to get errors, - # but catch_errors is apparently broken - log_handler = error_check, - vo = 'gpu', - hwdec = 'auto-safe', - demuxer_lavf_o = 'reconnect=1', - deinterlace = 'no', - keepaspect = 'no', - geometry = '100%:100%', - fullscreen = 'yes', - loop_playlist = 'inf' -) -deinterlace = False -channels = get_channels() -current_index = None -retroarch_p = None -resolution = get_current_resolution() -low_latency = False -vcodec = None -acodec = None -video_res = None -interlaced = None - -@player.property_observer('video-format') -def video_codec_observer(name, value): - global vcodec, acodec - if value: - vcodec = value.upper() - -@player.property_observer('audio-codec-name') -def audio_codec_observer(name, value): - global acodec, vcodec - if value: - acodec = value.upper() - -def play_channel(index): - global current_index, vcodec, acodec, video_res, interlaced - print(f"\n=== Starting channel change to index {index} ===") - - to_qt_queue.put({ - 'action': 'close_osd' - }) - print("Closed OSD") - - vcodec = None - acodec = None - current_index = index % len(channels) - print(f"Playing channel: {channels[current_index]['name']} ({channels[current_index]['url']})") - - try: - player.loadfile("./novideo.png") - player.wait_until_playing() - - channel_info = { - "name": channels[current_index]["name"], - "deinterlace": deinterlace, - "low_latency": low_latency, - "logo": channels[current_index]["logo"] - } - - to_qt_queue.put({ - 'action': 'show_osd', - 'channel_info': channel_info - }) - - player.loadfile(channels[current_index]['url']) - time.sleep(0.5) - player.wait_until_playing() - - video_params = player.video_params - video_frame_info = player.video_frame_info - if video_params and video_frame_info: - video_res = video_params.get('h') - interlaced = video_frame_info.get('interlaced') - to_qt_queue.put({ - 'action': 'update_codecs', - 'vcodec': vcodec, - 'acodec': acodec, - 'video_res': video_res, - 'interlaced': interlaced - }) - - to_qt_queue.put({ - 'action': 'start_close', - }) - - except Exception as e: - print(f"\033[91mError in play_channel: {str(e)}\033[0m") - traceback.print_exc() - -app = flask.Flask(__name__) -# Flask routes here - -@app.route("/") -def index(): - grouped_channels = {} - for channel in channels: - grouped_channels.setdefault(channel["group"], []).append(channel) - - flat_channel_list = [channel for channel in channels] - - # Create the channel groups HTML - channel_groups_html = "" - for group, ch_list in grouped_channels.items(): - channel_groups_html += f'

{group}' - for channel in ch_list: - index = flat_channel_list.index(channel) # Get correct global index - channel_groups_html += f''' -
- - -
- ''' - channel_groups_html += '
' - - # Replace placeholders with actual values - html = open("templates/index.html").read() - html = html.replace("%CURRENT_CHANNEL%", channels[current_index]['name'] if current_index is not None else "None") - html = html.replace("%RETROARCH_STATE%", "ON" if retroarch_p and retroarch_p.poll() is None else "OFF") - html = html.replace("%RETROARCH_LABEL%", "Stop RetroArch" if retroarch_p and retroarch_p.poll() is None else "Start RetroArch") - html = html.replace("%DEINTERLACE_STATE%", "ON" if deinterlace else "OFF") - html = html.replace("%RESOLUTION%", resolution) - html = html.replace("%LATENCY_STATE%", "ON" if low_latency else "OFF") - html = html.replace("%LATENCY_LABEL%", "Lo" if low_latency else "Hi") - html = html.replace("%CHANNEL_GROUPS%", channel_groups_html) - - return html - -def is_valid_url(url): - return re.match(r"^(https?|rtmp|rtmps|udp|tcp):\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/?].*)?$", url) is not None - -@app.route("/play_custom") -def play_custom(): - global current_index - current_index = None - url = request.args.get("url") - - if not url or not is_valid_url(url): - return jsonify(success=False, error="Invalid or unsupported URL") - - player.loadfile(url) - return jsonify(success=True) - -@app.route("/hide_osd") -def hide_osd(): - to_qt_queue.put({ - 'action': 'close_osd', - }) - return "", 204 - -@app.route("/show_osd") -def show_osd(): - if current_index is not None: - channel_info = { - "name": channels[current_index]["name"], - "deinterlace": deinterlace, - "low_latency": low_latency, - "logo": channels[current_index]["logo"] - } - to_qt_queue.put({ - 'action': 'show_osd', - 'channel_info': channel_info - }) - to_qt_queue.put({ - 'action': 'update_codecs', - 'vcodec': vcodec, - 'acodec': acodec, - 'video_res': video_res, - 'interlaced': interlaced - }) - return "", 204 - -@app.route("/channel") -def switch_channel(): - index = int(request.args.get("index", current_index)) - play_channel(index) - return "", 204 - -@app.route("/toggle_deinterlace") -def toggle_deinterlace(): - global deinterlace - deinterlace = not deinterlace - if deinterlace: - player['vf'] = 'yadif=0' - else: - player['vf'] = '' - return jsonify(state=deinterlace) - -@app.route("/stop_player") -def stop_player(): - global current_index - global osd - current_index = None - to_qt_queue.put({ - 'action': 'close_osd', - }) - player.stop() - return "", 204 - -@app.route("/toggle_retroarch") -def toggle_retroarch(): - global retroarch_p - retroarch_pid = subprocess.run(["pgrep", "-fx", "retroarch"], stdout=subprocess.PIPE).stdout.strip() - if retroarch_pid: - print("Retroarch already open. Trying to close it.") - subprocess.run(["kill", retroarch_pid]) - retroarch_p.terminate() - return jsonify(state=False) - else: - print("Launching RetroArch") - retroarch_env = os.environ.copy() - retroarch_env["MESA_GL_VERSION_OVERRIDE"] = "3.3" - retroarch_p = subprocess.Popen(re.split("\\s", ipmpv_retroarch_cmd if ipmpv_retroarch_cmd is not None else 'retroarch'), env=retroarch_env) - return jsonify(state=True) - -@app.route("/toggle_latency") -def toggle_latency(): - global low_latency - low_latency = not low_latency - player['audio-buffer'] = '0' if low_latency else '0.2' - player['vd-lavc-threads'] = '1' if low_latency else '0' - player['cache-pause'] = 'no' if low_latency else 'yes' - player['demuxer-lavf-o'] = 'reconnect=1,fflags=+nobuffer' if low_latency else 'reconnect=1' - player['demuxer-lavf-probe-info'] = 'nostreams' if low_latency else 'auto' - player['demuxer-lavf-analyzeduration'] = '0.1' if low_latency else '0' - player['video-sync'] = 'audio' - player['interpolation'] = 'no' - player['video-latency-hacks'] = 'yes' if low_latency else 'no' - player['stream-buffer-size'] = '4k' if low_latency else '128k' - print("JSON: ",jsonify(state=low_latency)) - return jsonify(state=low_latency) - -@app.route("/toggle_resolution") -def toggle_resolution(): - global resolution,is_wayland - new_res = "" - if is_wayland: - if resolution == "480i": - new_res = "720x240" - elif resolution == "240p": - new_res = "720x480" - elif resolution == "576i": - new_res = "720x288" - elif resolution == "288p": - new_res = "720x576" - - else: - if resolution == "480i": - new_res = "720x240" - elif resolution == "240p": - new_res = "720x480i" - elif resolution == "576i": - new_res = "720x288" - elif resolution == "288p": - new_res = "720x576i" - - if new_res: - if is_wayland: - wlr_randr_env = os.environ.copy() - wlr_randr_env["DISPLAY"] = ":0" - subprocess.run(["wlr-randr", "--output", "Composite-1", "--mode", new_res], check=False, env=wlr_randr_env) - resolution = get_current_resolution() - else: - xrandr_env = os.environ.copy() - xrandr_env["DISPLAY"] = ":0" - subprocess.run(["xrandr", "--output", "Composite-1", "--mode", new_res], check=False, env=xrandr_env) - resolution = get_current_resolution() - - return jsonify(res=get_current_resolution()) - -@app.route('/manifest.json') -def serve_manifest(): - return send_from_directory("static", 'manifest.json', - mimetype='application/manifest+json') - -# Serve icon files from root -@app.route('/icon512_rounded.png') -def serve_rounded_icon(): - return send_from_directory("static", 'icon512_rounded.png', - mimetype='image/png') - -@app.route('/icon512_maskable.png') -def serve_maskable_icon(): - return send_from_directory("static", 'icon512_maskable.png', - mimetype='image/png') - -@app.route('/screenshot1.png') -def serve_screenshot_1(): - return send_from_directory("static", 'screenshot1.png', - mimetype='image/png') - + # Run the Flask server (this will block) + server.run(host="0.0.0.0", port=5000) + except KeyboardInterrupt: + print("Shutting down...") + finally: + # Clean up + if qt_proc.is_alive(): + qt_proc.terminate() + qt_proc.join(timeout=1) + sys.exit(0) if __name__ == "__main__": - # Start Qt process - qt_proc = multiprocessing.Process(target=qt_process) - qt_proc.daemon = True - qt_proc.start() - - # Start Flask in main thread - app.run(host="0.0.0.0", port=5000) + main() diff --git a/osd.py b/osd.py new file mode 100644 index 0000000..9f6ef55 --- /dev/null +++ b/osd.py @@ -0,0 +1,250 @@ +#!/usr/bin/python +"""On-screen display widget for IPMPV.""" + +import os +import requests +import traceback +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from utils import is_wayland, osd_corner_radius + +class OsdWidget(QWidget): + """Widget for the on-screen display.""" + + def __init__(self, channel_info, width=600, height=165, close_time=5, corner_radius=int(osd_corner_radius) if osd_corner_radius is not None else 15): + """Initialize the OSD widget.""" + QFontDatabase.addApplicationFont('FiraSans-Regular.ttf') + QFontDatabase.addApplicationFont('FiraSans-Bold.ttf') + + super().__init__() + + self.channel_info = channel_info + self.orig_width = width + self.orig_height = height + self.close_time = close_time + self.corner_radius = corner_radius + self.video_codec = None + self.audio_codec = None + self.video_res = None + self.interlaced = None + + # Setup window + self.setWindowTitle("OSD") + self.setFixedSize(width, height) + + # Check if we're running on Wayland + self.is_wayland = is_wayland + + # Set appropriate window flags and size + if self.is_wayland: + # For Wayland, use fullscreen transparent approach + self.setWindowFlags( + Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint + ) + + # Set fullscreen size + self.screen_geometry = QApplication.desktop().screenGeometry() + self.setFixedSize(self.screen_geometry.width(), self.screen_geometry.height()) + + # Calculate content positioning + self.content_x = (self.screen_geometry.width() - self.orig_width) // 2 + self.content_y = 20 # 20px from top + else: + # For X11, use the original approach + self.setWindowFlags( + Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint | + Qt.X11BypassWindowManagerHint | + Qt.Tool | + Qt.ToolTip + ) + self.setFixedSize(width, height) + self.content_x = 0 + self.content_y = 0 + + # Enable transparency + self.setAttribute(Qt.WA_TranslucentBackground) + + # Position window at the top center of the screen + self.position_window() + + # Load logo if available + self.logo_pixmap = None + if channel_info["logo"]: + self.load_logo() + + if self.is_wayland: + self.setAttribute(Qt.WA_TransparentForMouseEvents) + + def position_window(self): + """Position the window on the screen.""" + if self.is_wayland: + # For Wayland, we just position at 0,0 (fullscreen) + self.move(0, 0) + + # Ensure window stays on top + self.stay_on_top_timer = QTimer(self) + self.stay_on_top_timer.timeout.connect(lambda: self.raise_()) + self.stay_on_top_timer.start(100) # Check every 100ms + else: + # For X11, center at top + screen_geometry = QApplication.desktop().screenGeometry() + x = (screen_geometry.width() - self.orig_width) // 2 + y = 20 # 20px from top + self.setGeometry(x, y, self.orig_width, self.orig_height) + + # X11 specific window hints + self.setAttribute(Qt.WA_X11NetWmWindowTypeNotification) + QTimer.singleShot(100, lambda: self.move(x, y)) + QTimer.singleShot(500, lambda: self.move(x, y)) + + # Periodically ensure window stays on top + self.stay_on_top_timer = QTimer(self) + self.stay_on_top_timer.timeout.connect(lambda: self.raise_()) + self.stay_on_top_timer.start(1000) # Check every second + + def load_logo(self): + """Load the channel logo.""" + try: + response = requests.get(self.channel_info["logo"]) + if response.ok: + pixmap = QPixmap() + pixmap.loadFromData(response.content) + if not pixmap.isNull(): + self.logo_pixmap = pixmap.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.update() # Trigger repaint + except Exception as e: + print(f"Failed to load logo: {e}") + + def paintEvent(self, a0): + """Paint event handler.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + if self.is_wayland: + # For Wayland, we're drawing the content in the right position on a fullscreen widget + self.draw_osd_content(painter, self.content_x, self.content_y) + else: + # For X11, we're drawing directly at (0,0) since the widget is already positioned + self.draw_osd_content(painter, 0, 0) + + def draw_osd_content(self, painter, x_offset, y_offset): + """Draw the OSD content.""" + # Create a path for rounded rectangle background + path = QPainterPath() + path.addRoundedRect( + x_offset, y_offset, + self.orig_width, self.orig_height, + self.corner_radius, self.corner_radius + ) + + # Fill the rounded rectangle with semi-transparent background + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(0, 50, 100, 200)) # RGBA + painter.drawPath(path) + + # Setup text drawing + painter.setPen(QColor(255, 255, 255)) + + try: + font = QFont("Fira Sans", 18) + font.setBold(True) + painter.setFont(font) + + # Draw channel name + painter.drawText(x_offset + 20, y_offset + 40, self.channel_info["name"]) + + font.setPointSize(14) + font.setBold(False) + painter.setFont(font) + + # Draw deinterlace status + painter.drawText(x_offset + 20, y_offset + 70, f"Deinterlacing {'on' if self.channel_info['deinterlace'] else 'off'}") + + # Draw latency mode + painter.drawText(x_offset + 20, y_offset + 100, f"{'Low' if self.channel_info['low_latency'] else 'High'} latency") + + # Draw codec badges if available + if self.video_codec: + self.draw_badge(painter, self.video_codec, x_offset + 80, y_offset + self.orig_height - 40) + if self.audio_codec: + self.draw_badge(painter, self.audio_codec, x_offset + 140, y_offset + self.orig_height - 40) + if self.video_res: + self.draw_badge(painter, f"{self.video_res}{self.interlaced if self.interlaced is not None else ''}", x_offset + 20, y_offset + self.orig_height - 40) + # Draw logo if available + if self.logo_pixmap: + painter.drawPixmap(x_offset + self.orig_width - 100, y_offset + 20, self.logo_pixmap) + + except Exception as e: + print(f"Error in painting: {e}") + traceback.print_exc() + + def draw_badge(self, painter, text, x, y): + """Draw a badge with text.""" + # Save current painter state + painter.save() + + # Draw rounded badge + painter.setPen(QPen(QColor(255, 255, 255, 255), 2)) + painter.setBrush(Qt.NoBrush) + + # Use QPainterPath for consistent rounded corners + badge_path = QPainterPath() + badge_path.addRoundedRect(x, y, 48, 20, 7, 7) + painter.drawPath(badge_path) + + # Draw text + painter.setPen(QColor(255, 255, 255)) + font = painter.font() + font.setBold(True) + font.setPointSize(8) + painter.setFont(font) + + # Center text in badge + font_metrics = painter.fontMetrics() + text_width = font_metrics.width(text) + text_height = font_metrics.height() + + # We need to use integer coordinates for drawText, not floats + text_x = int(x + (48 - text_width) / 2) + text_y = int(y + text_height) + + # Use the int, int, string version of drawText + painter.drawText(text_x, text_y, text) + + # Restore painter state + painter.restore() + + def update_codecs(self, video_codec, audio_codec, video_res, interlaced): + """Update codec information.""" + if video_codec: + self.video_codec = video_codec + if audio_codec: + self.audio_codec = audio_codec + if video_res: + self.video_res = video_res + if interlaced is not None: + self.interlaced = f"{'i' if interlaced else 'p'}" + self.update() # Trigger repaint + + def close_widget(self): + """Close the widget.""" + # Stop any active timers + if hasattr(self, 'stay_on_top_timer') and self.stay_on_top_timer.isActive(): + self.stay_on_top_timer.stop() + # Close the widget + self.hide() + + def start_close_timer(self, seconds=5): + """Start a timer to close the widget.""" + # Cancel any existing close timer + if hasattr(self, 'close_timer') and self.close_timer.isActive(): + self.close_timer.stop() + + # Create and start a new timer + self.close_timer = QTimer(self) + self.close_timer.setSingleShot(True) + self.close_timer.timeout.connect(self.close_widget) + self.close_timer.start(seconds * 1000) # Convert seconds to milliseconds diff --git a/player.py b/player.py new file mode 100644 index 0000000..791c85a --- /dev/null +++ b/player.py @@ -0,0 +1,151 @@ +#!/usr/bin/python +"""MPV player functionality for IPMPV.""" + +import mpv +import threading +import time +import traceback + +class Player: + """MPV player wrapper with IPMPV-specific functionality.""" + + def __init__(self, to_qt_queue): + """Initialize the player.""" + self.to_qt_queue = to_qt_queue + self.player = mpv.MPV( + log_handler=self.error_check, + vo='gpu', + hwdec='auto-safe', + demuxer_lavf_o='reconnect=1', + deinterlace='no', + keepaspect='no', + geometry='100%:100%', + fullscreen='yes', + loop_playlist='inf' + ) + + self.deinterlace = False + self.low_latency = False + self.current_index = None + self.vcodec = None + self.acodec = None + self.video_res = None + self.interlaced = None + + # Set up property observers + self.player.observe_property('video-format', self.video_codec_observer) + self.player.observe_property('audio-codec-name', self.audio_codec_observer) + + # Channel change management + self.channel_change_lock = threading.Lock() + self.current_channel_thread = None + self.channel_change_counter = 0 # To track the most recent channel change + + def error_check(self, loglevel, component, message): + """Check for errors in MPV logs.""" + print(f"[{loglevel}] {component}: {message}") + if loglevel == 'error' and (component == 'ffmpeg' or component == 'cplayer') and 'Failed' in message: + self.player.loadfile("./nosignal.png") + self.to_qt_queue.put({ + 'action': 'start_close' + }) + + def video_codec_observer(self, name, value): + """Observe changes to the video codec.""" + if value: + self.vcodec = value.upper() + + def audio_codec_observer(self, name, value): + """Observe changes to the audio codec.""" + if value: + self.acodec = value.upper() + + def play_channel(self, index, channels): + """ + Play a channel by index. + + Args: + index (int): Index of the channel to play. + channels (list): List of channel dictionaries. + """ + + print(f"\n=== Changing channel to index {index} ===") + + self.vcodec = None + self.acodec = None + + self.current_index = index % len(channels) + print(f"Playing channel: {channels[self.current_index]['name']} ({channels[self.current_index]['url']})") + + try: + self.player.loadfile("./novideo.png") + self.player.wait_until_playing() + + channel_info = { + "name": channels[self.current_index]["name"], + "deinterlace": self.deinterlace, + "low_latency": self.low_latency, + "logo": channels[self.current_index]["logo"] + } + + self.to_qt_queue.put({ + 'action': 'show_osd', + 'channel_info': channel_info + }) + + + self.player.loadfile(channels[self.current_index]['url']) + self.player.wait_until_playing() + + video_params = self.player.video_params + video_frame_info = self.player.video_frame_info + if video_params and video_frame_info: + self.video_res = video_params.get('h') + self.interlaced = video_frame_info.get('interlaced') + self.to_qt_queue.put({ + 'action': 'update_codecs', + 'vcodec': self.vcodec, + 'acodec': self.acodec, + 'video_res': self.video_res, + 'interlaced': self.interlaced + }) + + self.to_qt_queue.put({ + 'action': 'start_close', + }) + + + except Exception as e: + print(f"\033[91mError in play_channel: {str(e)}\033[0m") + traceback.print_exc() + + return + + def toggle_deinterlace(self): + """Toggle deinterlacing.""" + self.deinterlace = not self.deinterlace + if self.deinterlace: + self.player['vf'] = 'yadif=0' + else: + self.player['vf'] = '' + return self.deinterlace + + def toggle_latency(self): + """Toggle low latency mode.""" + self.low_latency = not self.low_latency + self.player['audio-buffer'] = '0' if self.low_latency else '0.2' + self.player['vd-lavc-threads'] = '1' if self.low_latency else '0' + self.player['cache-pause'] = 'no' if self.low_latency else 'yes' + self.player['demuxer-lavf-o'] = 'reconnect=1,fflags=+nobuffer' if self.low_latency else 'reconnect=1' + self.player['demuxer-lavf-probe-info'] = 'nostreams' if self.low_latency else 'auto' + self.player['demuxer-lavf-analyzeduration'] = '0.1' if self.low_latency else '0' + self.player['video-sync'] = 'audio' + self.player['interpolation'] = 'no' + self.player['video-latency-hacks'] = 'yes' if self.low_latency else 'no' + self.player['stream-buffer-size'] = '4k' if self.low_latency else '128k' + return self.low_latency + + def stop(self): + """Stop the player.""" + self.player.stop() + self.current_index = None diff --git a/qt_process.py b/qt_process.py new file mode 100644 index 0000000..0d25a36 --- /dev/null +++ b/qt_process.py @@ -0,0 +1,94 @@ +#!/usr/bin/python +"""Qt process for IPMPV OSD.""" + +import sys +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import QTimer +from osd import OsdWidget +from volume_osd import VolumeOsdWidget +from utils import is_wayland + +def qt_process(to_qt_queue, from_qt_queue): + """ + Run Qt application in a separate process. + + Args: + to_qt_queue: Queue for messages to Qt process + from_qt_queue: Queue for messages from Qt process + """ + app = QApplication(sys.argv) + osd = None + volume_osd = None + + # Check the queue periodically for commands + def check_queue(): + nonlocal osd, volume_osd + if not to_qt_queue.empty(): + command = to_qt_queue.get() + + # Channel OSD commands + if command['action'] == 'show_osd': + if osd is not None: + osd.close_widget() + # Create new OSD + osd = OsdWidget(command['channel_info']) + if is_wayland: + osd.showFullScreen() + else: + osd.show() + elif command['action'] == 'start_close': + if osd is not None: + osd.start_close_timer() + elif command['action'] == 'close_osd': + if osd is not None: + osd.close_widget() + osd = None + elif command['action'] == 'update_codecs': + if osd is not None: + osd.update_codecs(command['vcodec'], command['acodec'], command['video_res'], command['interlaced']) + + # Volume OSD commands + elif command['action'] == 'show_volume_osd': + # Close existing volume OSD if present + if volume_osd is not None: + volume_osd.close_widget() + + # Get volume level and mute state + volume_level = command.get('volume_level', 0) + is_muted = command.get('is_muted', False) + + # If muted, override volume display to 0 + display_volume = 0 if is_muted else volume_level + + # Create new volume OSD + volume_osd = VolumeOsdWidget(display_volume) + if is_wayland: + volume_osd.showFullScreen() + else: + volume_osd.show() + + # Start the close timer + volume_osd.start_close_timer() + elif command['action'] == 'update_volume_osd': + if volume_osd is not None: + volume_level = command.get('volume_level', 0) + is_muted = command.get('is_muted', False) + + # If muted, override volume display to 0 + display_volume = 0 if is_muted else volume_level + + volume_osd.update_volume(display_volume) + volume_osd.start_close_timer() + elif command['action'] == 'close_volume_osd': + if volume_osd is not None: + volume_osd.close_widget() + volume_osd = None + + # Schedule next check + QTimer.singleShot(100, check_queue) + + # Start the queue check + check_queue() + + # Run Qt event loop + app.exec_() diff --git a/server.py b/server.py new file mode 100644 index 0000000..a2cb739 --- /dev/null +++ b/server.py @@ -0,0 +1,274 @@ +#!/usr/bin/python +"""Flask server for IPMPV.""" + +import os +import re +import subprocess +import threading +import flask +from flask import request, jsonify, send_from_directory +from utils import is_valid_url, change_resolution, get_current_resolution, is_wayland + +class IPMPVServer: + """Flask server for IPMPV web interface.""" + + def __init__(self, channels, player, to_qt_queue, from_qt_queue, resolution, ipmpv_retroarch_cmd, volume_control=None): + """Initialize the server.""" + self.app = flask.Flask(__name__, + static_folder='static', + template_folder='templates') + self.channels = channels + self.player = player + self.to_qt_queue = to_qt_queue + self.from_qt_queue = from_qt_queue + self.resolution = resolution + self.ipmpv_retroarch_cmd = ipmpv_retroarch_cmd + self.retroarch_p = None + self.volume_control = volume_control + + # Register routes + self._register_routes() + + + def run(self, host="0.0.0.0", port=5000): + """Run the Flask server.""" + self.app.run(host=host, port=port) + def _register_routes(self): + """Register Flask routes.""" + + @self.app.route("/") + def index(): + return self._handle_index() + + @self.app.route("/play_custom") + def play_custom(): + return self._handle_play_custom() + + @self.app.route("/hide_osd") + def hide_osd(): + return self._handle_hide_osd() + + @self.app.route("/show_osd") + def show_osd(): + return self._handle_show_osd() + + @self.app.route("/channel") + def switch_channel(): + return self._handle_switch_channel() + + @self.app.route("/toggle_deinterlace") + def toggle_deinterlace(): + return self._handle_toggle_deinterlace() + + @self.app.route("/stop_player") + def stop_player(): + return self._handle_stop_player() + + @self.app.route("/toggle_retroarch") + def toggle_retroarch(): + return self._handle_toggle_retroarch() + + @self.app.route("/toggle_latency") + def toggle_latency(): + return self._handle_toggle_latency() + + @self.app.route("/toggle_resolution") + def toggle_resolution(): + return self._handle_toggle_resolution() + + @self.app.route("/volume_up") + def volume_up(): + return self._handle_volume_up() + + @self.app.route("/volume_down") + def volume_down(): + return self._handle_volume_down() + + @self.app.route("/toggle_mute") + def toggle_mute(): + return self._handle_toggle_mute() + + def _handle_index(self): + """Handle the index route.""" + from channels import group_channels + + grouped_channels = group_channels(self.channels) + flat_channel_list = [channel for channel in self.channels] + + # Create the channel groups HTML + channel_groups_html = "" + for group, ch_list in grouped_channels.items(): + channel_groups_html += f'
{group}' + for channel in ch_list: + index = flat_channel_list.index(channel) # Get correct global index + channel_groups_html += f''' +
+ + +
+ ''' + channel_groups_html += '
' + + # Replace placeholders with actual values + html = open("templates/index.html").read() + html = html.replace("%CURRENT_CHANNEL%", + self.channels[self.player.current_index]['name'] + if self.player.current_index is not None else "None") + html = html.replace("%RETROARCH_STATE%", + "ON" if self.retroarch_p and self.retroarch_p.poll() is None else "OFF") + html = html.replace("%RETROARCH_LABEL%", + "Stop RetroArch" if self.retroarch_p and self.retroarch_p.poll() is None else "Start RetroArch") + html = html.replace("%DEINTERLACE_STATE%", "ON" if self.player.deinterlace else "OFF") + html = html.replace("%RESOLUTION%", self.resolution) + html = html.replace("%LATENCY_STATE%", "ON" if self.player.low_latency else "OFF") + html = html.replace("%LATENCY_LABEL%", "Lo" if self.player.low_latency else "Hi") + html = html.replace("%CHANNEL_GROUPS%", channel_groups_html) + + return html + + def _handle_play_custom(self): + """Handle the play_custom route.""" + url = request.args.get("url") + + if not url or not is_valid_url(url): + return jsonify(success=False, error="Invalid or unsupported URL") + + self.player.player.loadfile(url) + self.player.current_index = None + return jsonify(success=True) + + def _handle_hide_osd(self): + """Handle the hide_osd route.""" + self.to_qt_queue.put({ + 'action': 'close_osd', + }) + return "", 204 + + def _handle_show_osd(self): + """Handle the show_osd route.""" + if self.player.current_index is not None: + channel_info = { + "name": self.channels[self.player.current_index]["name"], + "deinterlace": self.player.deinterlace, + "low_latency": self.player.low_latency, + "logo": self.channels[self.player.current_index]["logo"] + } + self.to_qt_queue.put({ + 'action': 'show_osd', + 'channel_info': channel_info + }) + self.to_qt_queue.put({ + 'action': 'update_codecs', + 'vcodec': self.player.vcodec, + 'acodec': self.player.acodec, + 'video_res': self.player.video_res, + 'interlaced': self.player.interlaced + }) + return "", 204 + + def _handle_switch_channel(self): + """Handle the switch_channel route.""" + self.player.stop() + index = int(request.args.get("index", self.player.current_index)) + thread = threading.Thread( + target=self.player.play_channel, + args=(index,self.channels), + daemon=True + ) + thread.start() + return "", 204 + + def _handle_toggle_deinterlace(self): + """Handle the toggle_deinterlace route.""" + state = self.player.toggle_deinterlace() + return jsonify(state=state) + + def _handle_stop_player(self): + """Handle the stop_player route.""" + self.to_qt_queue.put({ + 'action': 'close_osd', + }) + self.player.stop() + return "", 204 + + def _handle_toggle_retroarch(self): + """Handle the toggle_retroarch route.""" + retroarch_pid = subprocess.run(["pgrep", "-fx", "retroarch"], stdout=subprocess.PIPE).stdout.strip() + if retroarch_pid: + print("Retroarch already open. Trying to close it.") + subprocess.run(["kill", retroarch_pid]) + if self.retroarch_p: + self.retroarch_p.terminate() + return jsonify(state=False) + else: + print("Launching RetroArch") + retroarch_env = os.environ.copy() + retroarch_env["MESA_GL_VERSION_OVERRIDE"] = "3.3" + self.retroarch_p = subprocess.Popen(re.split("\\s", self.ipmpv_retroarch_cmd + if self.ipmpv_retroarch_cmd is not None + else 'retroarch'), env=retroarch_env) + return jsonify(state=True) + + def _handle_toggle_latency(self): + """Handle the toggle_latency route.""" + state = self.player.toggle_latency() + return jsonify(state=state) + + def _handle_toggle_resolution(self): + """Handle the toggle_resolution route.""" + self.resolution = change_resolution(self.resolution) + return jsonify(res=self.resolution) + + @self.app.route('/manifest.json') + def serve_manifest(): + return send_from_directory("static", 'manifest.json', + mimetype='application/manifest+json') + + @self.app.route('/icon512_rounded.png') + def serve_rounded_icon(): + return send_from_directory("static", 'icon512_rounded.png', + mimetype='image/png') + + @self.app.route('/icon512_maskable.png') + def serve_maskable_icon(): + return send_from_directory("static", 'icon512_maskable.png', + mimetype='image/png') + + @self.app.route('/screenshot1.png') + def serve_screenshot_1(): + return send_from_directory("static", 'screenshot1.png', + mimetype='image/png') + + def _handle_volume_up(self): + """Handle the volume_up route.""" + if self.volume_control: + step = request.args.get("step") + step = int(step) if step and step.isdigit() else None + new_volume = self.volume_control.volume_up(step) + return jsonify(volume=new_volume, muted=self.volume_control.is_muted()) + return jsonify(error="Volume control not available"), 404 + + def _handle_volume_down(self): + """Handle the volume_down route.""" + if self.volume_control: + step = request.args.get("step") + step = int(step) if step and step.isdigit() else None + new_volume = self.volume_control.volume_down(step) + return jsonify(volume=new_volume, muted=self.volume_control.is_muted()) + return jsonify(error="Volume control not available"), 404 + + def _handle_toggle_mute(self): + """Handle the toggle_mute route.""" + if self.volume_control: + is_muted = self.volume_control.toggle_mute() + volume = self.volume_control.get_volume() + return jsonify(muted=is_muted, volume=volume) + return jsonify(error="Volume control not available"), 404 + + def _handle_get_volume(self): + """Handle the get_volume route.""" + if self.volume_control: + volume = self.volume_control.get_volume() + is_muted = self.volume_control.is_muted() + return jsonify(volume=volume, muted=is_muted) + return jsonify(error="Volume control not available"), 404 diff --git a/templates/index.html b/templates/index.html index f32f130..4596df9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -143,20 +143,46 @@ margin: 0; } - #osd-on-btn { + .leftbtn { border-radius: var(--border-radius) 0 0 var(--border-radius); - min-width: 80px; margin-right: 0; border-right: 0; } - #osd-off-btn { + .rightbtn { border-radius: 0 var(--border-radius) var(--border-radius) 0; - min-width: 80px; margin-left: 0; border-left: 0; } + .midbtn { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 0; + border-radius: 0; + } + + #osd-on-btn { + min-width: 80px; + } + + #osd-off-btn { + min-width: 80px; + } + + #vol-up-btn { + min-width: 60px; + } + + #vol-dn-btn { + min-width: 60px; + } + + #vol-mute-btn { + min-width: 80px; + } + #latency-btn { min-width: 60px; width: 60px; @@ -355,11 +381,20 @@ +
+

Volume

+
+ + + +
+
+

Toggle OSD

- - + +
@@ -497,6 +532,31 @@ fetch(`/hide_osd`).then(response => response.json()); } + function volumeUp() { + fetch(`/volume_up`) + .then(response => response.json()) + .then(data => { + showToast("Volume: "+data.volume+"%"); + }); + } + + function volumeDown() { + fetch(`/volume_down`) + .then(response => response.json()) + .then(data => { + showToast("Volume: "+data.volume+"%"); + }); + } + + function toggleMute() { + fetch(`/toggle_mute`) + .then(response => response.json()) + .then(data => { + muted = data.muted ? "yes" : "no"; + showToast("Muted: " + muted); + }); + } + // Mobile-friendly toast notification function showToast(message) { const toast = document.createElement('div'); @@ -526,4 +586,4 @@ - \ No newline at end of file + diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..9abb244 --- /dev/null +++ b/utils.py @@ -0,0 +1,98 @@ +#!/usr/bin/python +"""Utility functions for IPMPV.""" + +import os +import re +import subprocess + +# Environment variables +is_wayland = "WAYLAND_DISPLAY" in os.environ +osd_corner_radius = os.environ.get("IPMPV_CORNER_RADIUS") +ipmpv_retroarch_cmd = os.environ.get("IPMPV_RETROARCH_CMD") +m3u_url = os.environ.get('IPMPV_M3U_URL') + +def setup_environment(): + """Set up environment variables.""" + os.environ["LC_ALL"] = "C" + os.environ["LANG"] = "C" + +def is_valid_url(url): + """Check if a URL is valid.""" + return re.match(r"^(https?|rtmp|rtmps|udp|tcp):\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?([\/?].*)?$", url) is not None + +def get_current_resolution(): + """Get the current display resolution.""" + try: + if is_wayland: + wlr_randr_env = os.environ.copy() + output = subprocess.check_output(["wlr-randr"], universal_newlines=True, env=wlr_randr_env) + if "Composite-1" in output.split("\n")[0]: + for line in output.split("\n"): + if "720x480" in line and "current" in line: + return "480i" + elif "720x240" in line and "current" in line: + return "240p" + elif "720x576" in line and "current" in line: + return "576i" + elif "720x288" in line and "current" in line: + return "288p" + else: + xrandr_env = os.environ.copy() + output = subprocess.check_output(["xrandr"], universal_newlines=True, env=xrandr_env) + for line in output.split("\n"): + if "Composite-1" in line: + if "720x480" in line: + return "480i" + elif "720x240" in line: + return "240p" + elif "720x576" in line: + return "576i" + elif "720x288" in line: + return "288p" + except subprocess.CalledProcessError: + if is_wayland: + print("Error: Cannot get display resolution. Is this a wl-roots compatible compositor?") + else: + print("Error: Cannot get display resolution. Is an X session running?") + except FileNotFoundError: + if is_wayland: + print("Error: Could not find wlr-randr, resolution will be unknown") + else: + print("Error: Could not find xrandr, resolution will be unknown") + return "UNK" + +def change_resolution(current_resolution): + """Change the display resolution.""" + new_res = "" + if is_wayland: + if current_resolution == "480i": + new_res = "720x240" + elif current_resolution == "240p": + new_res = "720x480" + elif current_resolution == "576i": + new_res = "720x288" + elif current_resolution == "288p": + new_res = "720x576" + else: + if current_resolution == "480i": + new_res = "720x240" + elif current_resolution == "240p": + new_res = "720x480i" + elif current_resolution == "576i": + new_res = "720x288" + elif current_resolution == "288p": + new_res = "720x576i" + + if new_res: + if is_wayland: + wlr_randr_env = os.environ.copy() + wlr_randr_env["DISPLAY"] = ":0" + subprocess.run(["wlr-randr", "--output", "Composite-1", "--mode", new_res], check=False, env=wlr_randr_env) + return get_current_resolution() + else: + xrandr_env = os.environ.copy() + xrandr_env["DISPLAY"] = ":0" + subprocess.run(["xrandr", "--output", "Composite-1", "--mode", new_res], check=False, env=xrandr_env) + return get_current_resolution() + + return current_resolution diff --git a/volume.py b/volume.py new file mode 100644 index 0000000..bb4440a --- /dev/null +++ b/volume.py @@ -0,0 +1,181 @@ +#!/usr/bin/python +"""Alsa volume control functions for IPMPV.""" + +import subprocess +import alsaaudio +import threading +import traceback +import time + +class VolumeControl: + """ + Class for controlling audio volume with ALSA. + + This class provides an interface to control the volume with PyAlsa, + with methods to get current volume, increase/decrease volume, + and toggle mute. + """ + + def __init__(self, mixer_name='Master', step=5, to_qt_queue=None): + """ + Initialize the volume control. + + Args: + mixer_name (str): Name of the ALSA mixer to control + step (int): Default step size for volume increments + to_qt_queue: Queue for sending messages to Qt process for OSD + """ + self.mixer_name = mixer_name + self.step = step + self.to_qt_queue = to_qt_queue + self.volume_thread = None + self.volume_lock = threading.Lock() + self._init_mixer() + + def _init_mixer(self): + """Initialize the ALSA mixer.""" + try: + # Try to get the requested mixer + self.mixer = alsaaudio.Mixer(self.mixer_name) + print(f"Successfully initialized ALSA mixer: {self.mixer_name}") + except alsaaudio.ALSAAudioError as e: + print(f"Error initializing mixer '{self.mixer_name}': {e}") + # Try to get a list of available mixers + available_mixers = alsaaudio.mixers() + print(f"Available mixers: {available_mixers}") + + # Try some common alternatives + fallback_mixers = ['PCM', 'Speaker', 'Master', 'Front', 'Headphone'] + for mixer in fallback_mixers: + if mixer in available_mixers: + try: + self.mixer = alsaaudio.Mixer(mixer) + self.mixer_name = mixer + print(f"Using fallback mixer: {mixer}") + return + except alsaaudio.ALSAAudioError: + continue + + # If we still don't have a mixer, raise an exception + raise Exception("Could not find a suitable ALSA mixer") + + def get_volume(self): + """ + Get the current volume level. + + Returns: + int: Current volume as a percentage (0-100) + """ + try: + # Get all channels and average them + volumes = self.mixer.getvolume() + return sum(volumes) // len(volumes) + except Exception as e: + print(f"Error getting volume: {e}") + traceback.print_exc() + return 0 + + def is_muted(self): + """ + Check if audio is muted. + + Returns: + bool: True if muted, False otherwise + """ + try: + # Get mute state for all channels (returns list of 0/1 values) + mutes = self.mixer.getmute() + # If any channel is muted (1), consider it muted + return any(mute == 1 for mute in mutes) + except Exception as e: + print(f"Error checking mute state: {e}") + traceback.print_exc() + return False + + def volume_up(self, step=None): + """ + Increase volume. + + Args: + step (int, optional): Amount to increase volume by. Defaults to self.step. + + Returns: + int: New volume level + """ + return self._adjust_volume(step if step is not None else self.step) + + def volume_down(self, step=None): + """ + Decrease volume. + + Args: + step (int, optional): Amount to decrease volume by. Defaults to self.step. + + Returns: + int: New volume level + """ + return self._adjust_volume(-(step if step is not None else self.step)) + + def toggle_mute(self): + """ + Toggle mute state. + + Returns: + bool: New mute state + """ + try: + muted = self.is_muted() + # Set all channels to the opposite of current mute state + self.mixer.setmute(0 if muted else 1) + + new_mute_state = not muted + + # Update OSD if queue is available + if self.to_qt_queue is not None: + self.to_qt_queue.put({ + 'action': 'show_volume_osd', + 'volume_level': 0 if new_mute_state else self.get_volume(), + 'is_muted': new_mute_state + }) + + return new_mute_state + except Exception as e: + print(f"Error toggling mute: {e}") + traceback.print_exc() + return self.is_muted() + + def _adjust_volume(self, change): + """ + Internal method to adjust volume. + + Args: + change (int): Amount to change volume by (positive or negative) + + Returns: + int: New volume level + """ + with self.volume_lock: + try: + current = self.get_volume() + new_volume = max(0, min(100, current + change)) + + # Set new volume on all channels + self.mixer.setvolume(new_volume) + + # If we were muted and increasing volume, unmute + if change > 0 and self.is_muted(): + self.mixer.setmute(0) + + # Update OSD if queue is available + if self.to_qt_queue is not None: + self.to_qt_queue.put({ + 'action': 'show_volume_osd', + 'volume_level': new_volume, + 'is_muted': False + }) + + return new_volume + except Exception as e: + print(f"Error adjusting volume: {e}") + traceback.print_exc() + return self.get_volume() diff --git a/volume_osd.py b/volume_osd.py new file mode 100644 index 0000000..824961b --- /dev/null +++ b/volume_osd.py @@ -0,0 +1,211 @@ +#!/usr/bin/python +"""Volume on-screen display widget for IPMPV.""" + +import os +import traceback +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from utils import is_wayland, osd_corner_radius + +class VolumeOsdWidget(QWidget): + """Widget for the volume on-screen display.""" + + def __init__(self, volume_level, width=300, height=80, close_time=2, corner_radius=int(osd_corner_radius) if osd_corner_radius is not None else 15): + """ + Initialize the volume OSD widget. + + Args: + volume_level (int): Current volume level (0-100) + width (int): Width of the widget + height (int): Height of the widget + close_time (int): Time in seconds before the widget closes + corner_radius (int): Corner radius for the widget + """ + QFontDatabase.addApplicationFont('FiraSans-Regular.ttf') + QFontDatabase.addApplicationFont('FiraSans-Bold.ttf') + + super().__init__() + + self.volume_level = volume_level + self.orig_width = width + self.orig_height = height + self.close_time = close_time + self.corner_radius = corner_radius + + # Setup window + self.setWindowTitle("Volume OSD") + self.setFixedSize(width, height) + + # Check if we're running on Wayland + self.is_wayland = is_wayland + + # Set appropriate window flags and size + if self.is_wayland: + # For Wayland, use fullscreen transparent approach + self.setWindowFlags( + Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint + ) + + # Set fullscreen size + self.screen_geometry = QApplication.desktop().screenGeometry() + self.setFixedSize(self.screen_geometry.width(), self.screen_geometry.height()) + + # Calculate content positioning + self.content_x = (self.screen_geometry.width() - self.orig_width) // 2 + self.content_y = self.screen_geometry.height() - self.orig_height - 20 # 20px from bottom + else: + # For X11, use the original approach + self.setWindowFlags( + Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint | + Qt.X11BypassWindowManagerHint | + Qt.Tool | + Qt.ToolTip + ) + self.setFixedSize(width, height) + self.content_x = 0 + self.content_y = 0 + + # Enable transparency + self.setAttribute(Qt.WA_TranslucentBackground) + + # Position window at the bottom center of the screen + self.position_window() + + if self.is_wayland: + self.setAttribute(Qt.WA_TransparentForMouseEvents) + + def position_window(self): + """Position the window on the screen.""" + if self.is_wayland: + # For Wayland, we just position at 0,0 (fullscreen) + self.move(0, 0) + + # Ensure window stays on top + self.stay_on_top_timer = QTimer(self) + self.stay_on_top_timer.timeout.connect(lambda: self.raise_()) + self.stay_on_top_timer.start(100) # Check every 100ms + else: + # For X11, center at bottom + screen_geometry = QApplication.desktop().screenGeometry() + x = (screen_geometry.width() - self.orig_width) // 2 + y = screen_geometry.height() - self.orig_height - 20 # 20px from bottom + self.setGeometry(x, y, self.orig_width, self.orig_height) + + # X11 specific window hints + self.setAttribute(Qt.WA_X11NetWmWindowTypeNotification) + QTimer.singleShot(100, lambda: self.move(x, y)) + QTimer.singleShot(500, lambda: self.move(x, y)) + + # Periodically ensure window stays on top + self.stay_on_top_timer = QTimer(self) + self.stay_on_top_timer.timeout.connect(lambda: self.raise_()) + self.stay_on_top_timer.start(1000) # Check every second + + def paintEvent(self, a0): + """Paint event handler.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + if self.is_wayland: + # For Wayland, we're drawing the content in the right position on a fullscreen widget + self.draw_osd_content(painter, self.content_x, self.content_y) + else: + # For X11, we're drawing directly at (0,0) since the widget is already positioned + self.draw_osd_content(painter, 0, 0) + + def draw_osd_content(self, painter, x_offset, y_offset): + """Draw the OSD content.""" + try: + # Create a path for rounded rectangle background + path = QPainterPath() + path.addRoundedRect( + x_offset, y_offset, + self.orig_width, self.orig_height, + self.corner_radius, self.corner_radius + ) + + # Fill the rounded rectangle with semi-transparent background + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(0, 50, 100, 200)) # RGBA + painter.drawPath(path) + + # Setup text drawing + painter.setPen(QColor(255, 255, 255)) + + # Draw volume icon or symbol + font = QFont("Fira Sans", 14) + font.setBold(True) + painter.setFont(font) + + # Draw volume label + painter.drawText(x_offset + 20, y_offset + 30, "Volume") + + # Draw volume value + font.setPointSize(12) + painter.setFont(font) + volume_text = f"{self.volume_level}%" + painter.drawText(x_offset + self.orig_width - 60, y_offset + 30, volume_text) + + # Draw volume bar background + bar_x = x_offset + 20 + bar_y = y_offset + 40 + bar_width = self.orig_width - 40 + bar_height = 16 + + bg_path = QPainterPath() + bg_path.addRoundedRect(bar_x, bar_y, bar_width, bar_height, 8, 8) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(255, 255, 255, 70)) + painter.drawPath(bg_path) + + # Draw volume level fill + if self.volume_level > 0: + fill_width = int((bar_width * self.volume_level) / 100) + fill_path = QPainterPath() + fill_path.addRoundedRect(bar_x, bar_y, fill_width, bar_height, 8, 8) + + # Determine color based on volume level + if self.volume_level <= 30: + fill_color = QColor(0, 200, 83) # Green + elif self.volume_level <= 70: + fill_color = QColor(255, 193, 7) # Yellow/Amber + else: + fill_color = QColor(255, 87, 34) # Red/Orange + + painter.setBrush(fill_color) + painter.drawPath(fill_path) + + except Exception as e: + print(f"Error in painting volume OSD: {e}") + traceback.print_exc() + + def update_volume(self, volume_level): + """Update the volume level displayed in the OSD.""" + self.volume_level = volume_level + self.update() # Trigger repaint + + def close_widget(self): + """Close the widget.""" + # Stop any active timers + if hasattr(self, 'stay_on_top_timer') and self.stay_on_top_timer.isActive(): + self.stay_on_top_timer.stop() + # Close the widget + self.hide() + + def start_close_timer(self, seconds=None): + """Start a timer to close the widget.""" + # Use the provided seconds or default to self.close_time + seconds = seconds if seconds is not None else self.close_time + + # Cancel any existing close timer + if hasattr(self, 'close_timer') and self.close_timer.isActive(): + self.close_timer.stop() + + # Create and start a new timer + self.close_timer = QTimer(self) + self.close_timer.setSingleShot(True) + self.close_timer.timeout.connect(self.close_widget) + self.close_timer.start(seconds * 1000) # Convert seconds to milliseconds