import asyncio import json import logging import sys import threading import io import urllib.request from PIL import Image, ImageDraw import pystray from winotify import Notification, audio import websockets import pythoncom # PyQt5 相关导入 from PyQt5.QtWidgets import (QApplication, QWidget, QLabel, QLineEdit, QRadioButton, QPushButton, QSlider, QVBoxLayout, QHBoxLayout, QGroupBox, QMessageBox, QFrame, QScrollArea, QSizePolicy, QDialog) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QSize, QPropertyAnimation, QPoint, QRect, QTimer from PyQt5.QtGui import QIntValidator, QFont, QResizeEvent, QMouseEvent, QColor, QPainter, QBrush, QPen, QFontMetrics, QIcon, QPixmap, QImage # ==================== 版本号 ==================== VERSION = "0.6.2" # ==================== 配置日志 ==================== logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger("danmaku_client") # ==================== 全局变量 ==================== exit_event = threading.Event() # 退出标志 active_websockets = [] # 所有活跃的 WebSocket 连接对象 ws_lock = threading.Lock() # 保护 active_websockets 的线程锁 # 图标URL(若无法访问,将自动降级为默认图标) ICON_URL = "https://cdn.rrrosmontis.icu/tsukuyomi/TKYM-Singl.png" # ==================== 获取应用图标(供任务栏和托盘共用) ==================== def create_default_icon(): size = 64 img = Image.new('RGB', (size, size), color='white') draw = ImageDraw.Draw(img) draw.ellipse([8, 8, size-8, size-8], fill='blue', outline='darkblue') return img def get_icon_image(): try: with urllib.request.urlopen(ICON_URL, timeout=5) as response: img_data = response.read() img = Image.open(io.BytesIO(img_data)) if img.mode != 'RGBA': img = img.convert('RGBA') logger.info("成功加载远程图标") return img except Exception as e: logger.warning(f"加载远程图标失败: {e},使用默认图标") return create_default_icon() def get_qicon(): """将 PIL Image 转换为 QIcon""" img = get_icon_image() img = img.convert("RGBA") data = img.tobytes("raw", "RGBA") qimage = QImage(data, img.width, img.height, QImage.Format_RGBA8888) pixmap = QPixmap.fromImage(qimage) return QIcon(pixmap) # ==================== PyQt5 配置对话框(改为 QDialog) ==================== class ConfigDialog(QDialog): """美观的配置对话框,用于输入房间号、选择节点、显示模式""" def __init__(self): super().__init__() self.room_id = None self.mode = None self.opacity = 0.85 # 默认透明度 self.font_size = 10 # 默认字号(减小) self.aspect_ratio = "9:16" # 默认宽高比改为 9:16 self.display_mode = "window" # 默认悬浮窗模式 ("window" 或 "notification") self.init_ui() def init_ui(self): """初始化界面组件""" self.setWindowTitle(f"TSUKUYOMI DANMAKU - CONFIG") self.resize(500, 520) # 稍微增加高度以容纳新模式选项 self.setMinimumSize(400, 480) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) # 深色背景样式表(与原来一致) self.setStyleSheet(""" QWidget { background-color: #0a0e17; color: #e0e0e0; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 13px; } QLabel { color: #c0c0c0; } QLineEdit { background-color: #1e2434; border: 1px solid #2c3348; border-radius: 5px; padding: 8px 10px; color: #ffffff; font-size: 13px; selection-background-color: #3a425c; } QLineEdit:focus { border: 1px solid #5a6e8a; } QRadioButton { color: #d0d0d0; font-size: 13px; spacing: 10px; padding: 4px 0; } QRadioButton::indicator { width: 16px; height: 16px; } QRadioButton::indicator:unchecked { border: 1px solid #5a6e8a; border-radius: 8px; background-color: #1e2434; } QRadioButton::indicator:checked { border: 1px solid #7c9cbf; border-radius: 8px; background-color: #4c7a9a; } QPushButton { background-color: #2c3348; border: none; border-radius: 6px; padding: 8px 20px; color: #ffffff; font-size: 13px; font-weight: bold; } QPushButton:hover { background-color: #3a425c; } QPushButton:pressed { background-color: #1f2538; } QGroupBox { border: 1px solid #2c3348; border-radius: 8px; margin-top: 12px; font-size: 13px; font-weight: normal; } QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 6px 0 6px; color: #b0b0b0; } """) # 主布局 main_layout = QVBoxLayout() main_layout.setSpacing(18) main_layout.setContentsMargins(25, 20, 25, 20) # 标题区域 title_label = QLabel("TSUKUYOMI DANMAKU") title_font = QFont() title_font.setPointSize(15) title_font.setBold(True) title_label.setFont(title_font) title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("color: #d4d4d4; margin-bottom: 8px;") main_layout.addWidget(title_label) # 房间号输入区域 room_layout = QVBoxLayout() room_label = QLabel("房间号 (1-100)") room_label.setStyleSheet("margin-bottom: 4px; font-weight: bold;") self.room_edit = QLineEdit() self.room_edit.setPlaceholderText("请输入数字房间号") self.room_edit.setValidator(QIntValidator(1, 100)) room_layout.addWidget(room_label) room_layout.addWidget(self.room_edit) main_layout.addLayout(room_layout) # 节点选择区域 node_group = QGroupBox("接入节点选择") node_group.setMinimumHeight(140) node_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) node_layout = QVBoxLayout() node_layout.setSpacing(10) node_layout.setContentsMargins(15, 12, 15, 12) self.radio_main = QRadioButton("[主站] api.rrrosmontis.icu") self.radio_backup = QRadioButton("[副站] tsk.aeolianchi.me") self.radio_both = QRadioButton("[主站+副站] 同时接收") self.radio_main.setChecked(True) node_layout.addWidget(self.radio_main) node_layout.addWidget(self.radio_backup) node_layout.addWidget(self.radio_both) node_layout.addStretch() node_group.setLayout(node_layout) main_layout.addWidget(node_group) # ========== 新增:显示模式选择(悬浮窗 / 通知) ========== mode_group = QGroupBox("显示模式") mode_layout = QVBoxLayout() mode_layout.setSpacing(10) mode_layout.setContentsMargins(15, 12, 15, 12) self.radio_window = QRadioButton("悬浮窗模式") self.radio_notification = QRadioButton("通知模式") self.radio_window.setChecked(True) # 默认悬浮窗 mode_layout.addWidget(self.radio_window) mode_layout.addWidget(self.radio_notification) mode_group.setLayout(mode_layout) main_layout.addWidget(mode_group) # 添加弹性空间 main_layout.addStretch(1) # 按钮区域 btn_layout = QHBoxLayout() btn_layout.setSpacing(25) self.start_btn = QPushButton("启动接收") self.start_btn.setMinimumHeight(36) self.cancel_btn = QPushButton("退出程序") self.cancel_btn.setMinimumHeight(36) btn_layout.addStretch() btn_layout.addWidget(self.start_btn) btn_layout.addWidget(self.cancel_btn) btn_layout.addStretch() main_layout.addLayout(btn_layout) # 版本提示 ver_label = QLabel(f"版本 {VERSION}") ver_label.setAlignment(Qt.AlignCenter) ver_label.setStyleSheet("color: #6a6e7a; font-size: 10px; margin-top: 10px;") main_layout.addWidget(ver_label) self.setLayout(main_layout) # 连接信号 self.start_btn.clicked.connect(self.on_start_clicked) self.cancel_btn.clicked.connect(self.on_cancel_clicked) def on_start_clicked(self): """启动按钮处理:验证房间号并保存配置""" room_text = self.room_edit.text().strip() if not room_text: QMessageBox.warning(self, "输入错误", "请输入房间号") return try: room_val = int(room_text) if not (1 <= room_val <= 100): QMessageBox.warning(self, "输入错误", "房间号必须在 1-100 之间") return except ValueError: QMessageBox.warning(self, "输入错误", "房间号必须为整数") return if self.radio_main.isChecked(): mode = "main" elif self.radio_backup.isChecked(): mode = "backup" else: mode = "both" # 获取显示模式 if self.radio_window.isChecked(): display_mode = "window" else: display_mode = "notification" self.room_id = room_val self.mode = mode self.display_mode = display_mode # 透明度、字号、宽高比使用默认值(注意字号默认10,比例9:16) self.opacity = 0.85 self.font_size = 10 # 默认字号减小 self.aspect_ratio = "9:16" self.accept() # 关闭对话框并返回 Accepted def on_cancel_clicked(self): """退出程序""" self.reject() # ==================== 设置对话框(用于主悬浮窗) ==================== class SettingsDialog(QDialog): """设置对话框:调节透明度、字号、宽高比、窗口大小(等比缩放)""" def __init__(self, parent_window): super().__init__(parent_window) self.parent_window = parent_window # DanmakuWindow 实例 self.setWindowTitle("悬浮窗设置") self.setModal(True) self.setFixedSize(400, 420) self.setStyleSheet(""" QDialog { background-color: #1e1e2e; color: #ffffff; } QLabel { color: #ffffff; } QRadioButton { color: #ffffff; } QSlider::groove:horizontal { height: 4px; background: #2c3348; border-radius: 2px; } QSlider::handle:horizontal { background: #7c9cbf; width: 12px; height: 12px; margin: -4px 0; border-radius: 6px; } QPushButton { background-color: #2c3348; border: none; border-radius: 6px; padding: 6px 12px; color: #ffffff; } QPushButton:hover { background-color: #3a425c; } QGroupBox { border: 1px solid #2c3348; border-radius: 6px; margin-top: 10px; color: #ffffff; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #ffffff; } """) layout = QVBoxLayout(self) layout.setSpacing(15) # 透明度 opacity_group = QGroupBox("窗口透明度") opacity_layout = QVBoxLayout() self.opacity_slider = QSlider(Qt.Horizontal) self.opacity_slider.setRange(20, 100) self.opacity_slider.setValue(int(self.parent_window.opacity_value * 100)) self.opacity_slider.valueChanged.connect(self.on_opacity_changed) opacity_layout.addWidget(self.opacity_slider) self.opacity_label = QLabel(f"当前: {self.opacity_slider.value()}%") opacity_layout.addWidget(self.opacity_label) opacity_group.setLayout(opacity_layout) layout.addWidget(opacity_group) # 字号 font_group = QGroupBox("弹幕字号") font_layout = QVBoxLayout() self.font_slider = QSlider(Qt.Horizontal) self.font_slider.setRange(4, 24) self.font_slider.setValue(self.parent_window.current_font_size) self.font_slider.valueChanged.connect(self.on_font_changed) font_layout.addWidget(self.font_slider) self.font_label = QLabel(f"当前: {self.font_slider.value()}px") font_layout.addWidget(self.font_label) font_group.setLayout(font_layout) layout.addWidget(font_group) # 宽高比 ratio_group = QGroupBox("悬浮窗宽高比") ratio_layout = QVBoxLayout() ratio_radio_layout = QHBoxLayout() self.radio_16_9 = QRadioButton("16:9") self.radio_9_16 = QRadioButton("9:16") if self.parent_window.current_aspect_ratio == "16:9": self.radio_16_9.setChecked(True) else: self.radio_9_16.setChecked(True) self.radio_16_9.toggled.connect(self.on_ratio_changed) self.radio_9_16.toggled.connect(self.on_ratio_changed) ratio_radio_layout.addWidget(self.radio_16_9) ratio_radio_layout.addWidget(self.radio_9_16) ratio_radio_layout.addStretch() ratio_layout.addLayout(ratio_radio_layout) ratio_group.setLayout(ratio_layout) layout.addWidget(ratio_group) # 窗口大小调节(等比缩放) size_group = QGroupBox("窗口大小(等比缩放)") size_layout = QVBoxLayout() # 宽度调节滑块 width_layout = QHBoxLayout() width_layout.addWidget(QLabel("宽度:")) self.width_slider = QSlider(Qt.Horizontal) # 宽度范围:最小宽度 ~ 屏幕宽度 min_w = self.parent_window.min_width max_w = QApplication.primaryScreen().geometry().width() - 100 self.width_slider.setRange(min_w, max_w) self.width_slider.setValue(self.parent_window.width()) self.width_slider.valueChanged.connect(self.on_width_changed) width_layout.addWidget(self.width_slider) self.width_value_label = QLabel(f"{self.parent_window.width()}px") width_layout.addWidget(self.width_value_label) size_layout.addLayout(width_layout) # 高度显示(只读) height_layout = QHBoxLayout() height_layout.addWidget(QLabel("高度:")) self.height_value_label = QLabel(f"{self.parent_window.height()}px") height_layout.addWidget(self.height_value_label) height_layout.addStretch() size_layout.addLayout(height_layout) size_group.setLayout(size_layout) layout.addWidget(size_group) # 按钮 btn_layout = QHBoxLayout() btn_layout.addStretch() close_btn = QPushButton("关闭") close_btn.clicked.connect(self.accept) btn_layout.addWidget(close_btn) layout.addLayout(btn_layout) def on_opacity_changed(self, value): opacity = value / 100.0 self.parent_window.set_opacity(opacity) self.opacity_label.setText(f"当前: {value}%") def on_font_changed(self, value): self.parent_window.set_font_size(value) self.font_label.setText(f"当前: {value}px") def on_ratio_changed(self): if self.radio_16_9.isChecked(): new_ratio = "16:9" else: new_ratio = "9:16" self.parent_window.set_aspect_ratio(new_ratio) min_w = self.parent_window.min_width max_w = self.width_slider.maximum() self.width_slider.setRange(min_w, max_w) current_width = self.parent_window.width() if current_width < min_w: current_width = min_w self.parent_window.set_window_width(current_width) self.width_slider.setValue(current_width) self.width_value_label.setText(f"{current_width}px") self.height_value_label.setText(f"{self.parent_window.height()}px") def on_width_changed(self, width): self.parent_window.set_window_width(width) self.width_value_label.setText(f"{width}px") self.height_value_label.setText(f"{self.parent_window.height()}px") def showEvent(self, event): self.opacity_slider.setValue(int(self.parent_window.opacity_value * 100)) self.font_slider.setValue(self.parent_window.current_font_size) if self.parent_window.current_aspect_ratio == "16:9": self.radio_16_9.setChecked(True) else: self.radio_9_16.setChecked(True) self.width_slider.setValue(self.parent_window.width()) self.width_slider.setRange(self.parent_window.min_width, self.width_slider.maximum()) self.width_value_label.setText(f"{self.parent_window.width()}px") self.height_value_label.setText(f"{self.parent_window.height()}px") super().showEvent(event) # 新增:从主窗口同步当前字号并更新UI def refresh_font_display(self): """从主窗口同步当前字号并更新滑块和标签""" current_size = self.parent_window.current_font_size self.font_slider.setValue(current_size) self.font_label.setText(f"当前: {current_size}px") # ==================== 弹幕接收线程(异步) ==================== class DanmakuReceiver(QThread): """在独立线程中运行 asyncio 事件循环,接收弹幕并通过信号发送到主窗口""" new_danmaku = pyqtSignal(str, str) # 用户名, 弹幕内容 def __init__(self, room_id: int, mode: str): super().__init__() self.room_id = room_id self.mode = mode self.exit_event = threading.Event() self.loop = None def stop(self): self.exit_event.set() global active_websockets with ws_lock: for ws in active_websockets: if self.loop and self.loop.is_running(): asyncio.run_coroutine_threadsafe(ws.close(), self.loop) active_websockets.clear() if self.loop and self.loop.is_running(): self.loop.call_soon_threadsafe(self.loop.stop) def run(self): self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) try: self.loop.run_until_complete(self.main_async()) except asyncio.CancelledError: logger.info("异步任务被取消") except Exception as e: logger.error(f"异步任务异常: {e}") finally: pending = asyncio.all_tasks(self.loop) for task in pending: task.cancel() if pending: self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) self.loop.close() logger.info("事件循环已关闭") async def handle_message(self, message: str): try: data = json.loads(message) msg_type = data.get("type") if msg_type == "danmaku": danmaku = data.get("data", {}) user = danmaku.get("user", "匿名") text = danmaku.get("text", "") self.new_danmaku.emit(user, text) elif msg_type == "init": logger.info("收到 init 消息(忽略)") else: logger.debug(f"未知消息类型: {msg_type}") except json.JSONDecodeError: logger.error(f"解析消息失败: {message}") except Exception as e: logger.error(f"处理消息异常: {e}") async def websocket_task(self, url: str, name: str): global active_websockets while not self.exit_event.is_set(): websocket = None try: async with websockets.connect(url) as websocket: with ws_lock: active_websockets.append(websocket) logger.info(f"[{name}] 已连接到 {url}") join_msg = { "type": "join", "room": self.room_id, "user": "客户端用户" } await websocket.send(json.dumps(join_msg)) init_msg = await websocket.recv() logger.info(f"[{name}] 收到 init: {init_msg[:100]}") async for message in websocket: if self.exit_event.is_set(): break await self.handle_message(message) except websockets.exceptions.ConnectionClosed: if not self.exit_event.is_set(): logger.warning(f"[{name}] 连接已关闭,5秒后重连...") except Exception as e: if not self.exit_event.is_set(): logger.error(f"[{name}] 连接错误: {e},5秒后重连...") finally: if websocket: with ws_lock: if websocket in active_websockets: active_websockets.remove(websocket) if not self.exit_event.is_set(): await asyncio.sleep(5) logger.info(f"[{name}] WebSocket 任务已退出") async def main_async(self): MAIN_URL = "wss://api.rrrosmontis.icu/v1/live/danmaku" BACKUP_URL = "wss://tsk.aeolianchi.me/danmaku" tasks = [] if self.mode == "main": tasks.append(self.websocket_task(MAIN_URL, "主站")) elif self.mode == "backup": tasks.append(self.websocket_task(BACKUP_URL, "副站")) elif self.mode == "both": tasks.append(self.websocket_task(MAIN_URL, "主站")) tasks.append(self.websocket_task(BACKUP_URL, "副站")) else: logger.error(f"未知模式: {self.mode}") return await asyncio.gather(*tasks, return_exceptions=True) # ==================== 弹幕条目控件(支持动画) ==================== class DanmakuItem(QLabel): """单个弹幕的显示控件,支持淡入动画""" def __init__(self, user: str, text: str, font_size: int, parent=None): super().__init__(parent) html = f'