ipmpv/osd.py

251 lines
9.1 KiB
Python
Executable File

#!/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