commit 0a7a5b7733d9cbfa6e17194eab95f57336052c8e Author: RRRosmontis Date: Tue Aug 18 08:03:51 2026 +0000 上传文件至「/」 diff --git a/TKYM-Singl.png b/TKYM-Singl.png new file mode 100644 index 0000000..9870285 Binary files /dev/null and b/TKYM-Singl.png differ diff --git a/danmaku.py b/danmaku.py new file mode 100644 index 0000000..5cd087b --- /dev/null +++ b/danmaku.py @@ -0,0 +1,1145 @@ +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'
{user}: {text}
' + self.setText(html) + self.setTextFormat(Qt.RichText) + self.setWordWrap(True) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + font = QFont("Microsoft YaHei", font_size) + self.setFont(font) + self.setContentsMargins(4, 2, 4, 2) + self.setStyleSheet(""" + QLabel { + background-color: rgba(30, 30, 40, 100); + border-radius: 6px; + padding: 4px 6px; + } + """) + self.opacity_effect = None + self.animation = None + + def start_animation(self): + from PyQt5.QtWidgets import QGraphicsOpacityEffect + self.opacity_effect = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self.opacity_effect) + self.opacity_effect.setOpacity(0) + self.animation = QPropertyAnimation(self.opacity_effect, b"opacity") + self.animation.setDuration(300) + self.animation.setStartValue(0) + self.animation.setEndValue(1) + self.animation.start() + +# ==================== 主悬浮窗(无边框、可拖拽、等比例缩放、半透明) ==================== +class DanmakuWindow(QWidget): + def __init__(self, room_id: int, mode: str, opacity: float, font_size: int, aspect_ratio: str): + super().__init__() + self.room_id = room_id + self.mode = mode + self.opacity_value = opacity + self.current_font_size = font_size + self.current_aspect_ratio = aspect_ratio # "16:9" 或 "9:16" + self.dragging = False + self.drag_pos = None + self.receiver = None + self._updating_scaling = False + self.settings_dialog = None # 记录设置对话框实例,用于同步字号 + + self.update_min_size() + if self.current_aspect_ratio == "16:9": + self.base_width = 350 # 减小默认宽度 + self.base_height = int(350 * 9 / 16) + else: + self.base_width = 250 # 9:16 默认宽度更小 + self.base_height = int(250 * 16 / 9) + if self.base_width < self.min_width: + self.base_width = self.min_width + self.base_height = int(self.base_width * (9/16 if self.current_aspect_ratio == "16:9" else 16/9)) + + self.init_ui() + + # 窗口标志:无边框、置顶、工具窗口(隐藏任务栏) + self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setWindowOpacity(self.opacity_value) + + self.resize(self.base_width, self.base_height) + self.center() + + self._resizing = False + self.update_ui_scaling() + + def update_min_size(self): + if self.current_aspect_ratio == "16:9": + self.min_width = 150 + self.min_height = int(150 * 9 / 16) + else: + self.min_width = 100 + self.min_height = int(100 * 16 / 9) + + def center(self): + screen_geo = QApplication.primaryScreen().geometry() + x = (screen_geo.width() - self.width()) // 2 + y = (screen_geo.height() - self.height()) // 2 + self.move(x, y) + + def init_ui(self): + self.container = QFrame(self) + self.container.setObjectName("Container") + self.container.setStyleSheet(""" + QFrame#Container { + background-color: rgba(20, 20, 30, 255); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 30); + } + QLabel { + color: #f0f0f0; + background: transparent; + } + """) + + main_layout = QVBoxLayout(self.container) + main_layout.setContentsMargins(8, 12, 8, 12) + main_layout.setSpacing(8) + + self.title_layout = QVBoxLayout() + self.title_layout.setSpacing(4) + + first_row = QHBoxLayout() + self.title_label1 = QLabel("弹幕列表") + self.title_label1.setStyleSheet("font-weight: bold; color: #d4d4d4;") + first_row.addWidget(self.title_label1) + first_row.addStretch() + + self.settings_btn = QPushButton("⚙") + self.settings_btn.setStyleSheet(""" + QPushButton { + background: rgba(80, 80, 90, 150); + border-radius: 12px; + font-weight: bold; + color: #c0c0c0; + } + QPushButton:hover { + background: rgba(120, 120, 130, 200); + color: white; + } + """) + self.settings_btn.clicked.connect(self.open_settings) + first_row.addWidget(self.settings_btn) + + self.close_btn = QPushButton("✕") + self.close_btn.setStyleSheet(""" + QPushButton { + background: rgba(80, 80, 90, 150); + border-radius: 12px; + font-weight: bold; + color: #c0c0c0; + } + QPushButton:hover { + background: rgba(120, 120, 130, 200); + color: white; + } + """) + self.close_btn.clicked.connect(self.close_app) + first_row.addWidget(self.close_btn) + + self.title_layout.addLayout(first_row) + + mode_text = "" + if self.mode == "main": + mode_text = "SITE 1" + elif self.mode == "backup": + mode_text = "SITE 2" + else: + mode_text = "SITE 1 & 2" + self.title_label2 = QLabel(f"{mode_text} · ROOM {self.room_id}") + self.title_label2.setStyleSheet("color: #a0a0c0;") + self.title_layout.addWidget(self.title_label2) + main_layout.addLayout(self.title_layout) + + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setStyleSheet("background-color: rgba(255, 255, 255, 40); max-height: 1px;") + main_layout.addWidget(line) + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.scroll_area.setStyleSheet(""" + QScrollArea { + background: transparent; + border: none; + } + QScrollArea > QWidget { + background: transparent; + } + """) + self.scroll_area.viewport().setStyleSheet("background: transparent;") + + self.danmaku_container = QWidget() + self.danmaku_container.setStyleSheet("background: transparent;") + self.danmaku_layout = QVBoxLayout(self.danmaku_container) + self.danmaku_layout.setAlignment(Qt.AlignTop) + self.danmaku_layout.setSpacing(6) + self.danmaku_layout.setContentsMargins(2, 4, 2, 4) + self.scroll_area.setWidget(self.danmaku_container) + + main_layout.addWidget(self.scroll_area) + + self.container.setGeometry(0, 0, self.width(), self.height()) + self.container.setLayout(main_layout) + + def update_ui_scaling(self): + if self._updating_scaling: + return + self._updating_scaling = True + base_width = 500 + scale = self.width() / base_width + scale = max(0.5, min(1.5, scale)) + + title_font_size = int(16 * scale) + title_font = QFont("Microsoft YaHei", title_font_size) + title_font.setBold(True) + self.title_label1.setFont(title_font) + + subtitle_font_size = int(11 * scale) + subtitle_font = QFont("Microsoft YaHei", subtitle_font_size) + self.title_label2.setFont(subtitle_font) + + btn_size = int(24 * scale) + btn_font_size = int(14 * scale) + self.settings_btn.setFixedSize(btn_size, btn_size) + self.close_btn.setFixedSize(btn_size, btn_size) + + btn_font = QFont("Segoe UI", btn_font_size) + self.settings_btn.setFont(btn_font) + self.close_btn.setFont(btn_font) + + self.title_layout.setSpacing(int(4 * scale)) + self._updating_scaling = False + + def open_settings(self): + if self.settings_dialog is None: + dlg = SettingsDialog(self) + self.settings_dialog = dlg + dlg.finished.connect(lambda: setattr(self, 'settings_dialog', None)) + dlg.show() + else: + self.settings_dialog.raise_() + self.settings_dialog.activateWindow() + + def set_opacity(self, value): + self.opacity_value = value + self.setWindowOpacity(value) + + def set_font_size(self, size): + self.current_font_size = size + font = QFont("Microsoft YaHei", size) + for i in range(self.danmaku_layout.count()): + item = self.danmaku_layout.itemAt(i) + if item and item.widget(): + widget = item.widget() + widget.setFont(font) + widget.updateGeometry() + self.danmaku_layout.invalidate() + self.danmaku_container.updateGeometry() + self.scroll_area.updateGeometry() + self.scroll_to_bottom() + + # 如果设置对话框已打开,同步其显示 + if self.settings_dialog is not None: + self.settings_dialog.refresh_font_display() + + def set_aspect_ratio(self, ratio): + if ratio == self.current_aspect_ratio: + return + self.current_aspect_ratio = ratio + self.update_min_size() + self.set_window_width(self.width()) + + def set_window_width(self, width): + if width < self.min_width: + width = self.min_width + if self.current_aspect_ratio == "16:9": + height = int(width * 9 / 16) + else: + height = int(width * 16 / 9) + if height < self.min_height: + height = self.min_height + width = int(height * (16/9 if self.current_aspect_ratio == "16:9" else 9/16)) + self.resize(width, height) + + def add_danmaku(self, user: str, text: str): + """添加一条弹幕,并强制滚动到底部(修复留白和显示不全问题)""" + if not self.isVisible(): + return + item = DanmakuItem(user, text, self.current_font_size) + self.danmaku_layout.addWidget(item) + item.start_animation() + + # 延迟滚动,确保布局完全更新(避免留白/显示不全) + QTimer.singleShot(30, self.scroll_to_bottom) + + # 限制最大条目数 + if self.danmaku_layout.count() > 200: + oldest_item = self.danmaku_layout.takeAt(0) + if oldest_item.widget(): + oldest_item.widget().deleteLater() + + def scroll_to_bottom(self): + """滚动到底部,强制更新滚动条最大值""" + scrollbar = self.scroll_area.verticalScrollBar() + # 先强制刷新布局,确保最大值正确 + self.danmaku_container.updateGeometry() + self.scroll_area.updateGeometry() + scrollbar.setValue(scrollbar.maximum()) + + def close_app(self): + if self.receiver: + try: + self.receiver.new_danmaku.disconnect(self.add_danmaku) + except (TypeError, AttributeError): + pass + exit_event.set() + if self.receiver and self.receiver.isRunning(): + self.receiver.stop() + self.receiver.wait(3000) + QApplication.quit() + + # ========== 无边框窗口拖拽和等比例缩放 ========== + def mousePressEvent(self, event: QMouseEvent): + if event.button() == Qt.LeftButton: + if event.pos().y() < 60: + self.dragging = True + self.drag_pos = event.globalPos() + event.accept() + + def mouseMoveEvent(self, event: QMouseEvent): + if self.dragging and self.drag_pos is not None: + delta = event.globalPos() - self.drag_pos + self.move(self.x() + delta.x(), self.y() + delta.y()) + self.drag_pos = event.globalPos() + event.accept() + + def mouseReleaseEvent(self, event: QMouseEvent): + self.dragging = False + self.drag_pos = None + + def mouseDoubleClickEvent(self, event: QMouseEvent): + if event.pos().y() < 60: + if self.isMaximized(): + self.showNormal() + else: + self.showMaximized() + + def update_danmaku_max_width(self): + """根据当前视口宽度,限制所有弹幕条目的最大宽度""" + # 获取视口宽度(滚动区域内部可用宽度) + viewport_width = self.scroll_area.viewport().width() + # 减去 danmaku_layout 的左右边距(2+2=4)以及容器内边距 + max_width = viewport_width - 8 # 左右各2px边距,再留一点余量 + if max_width < 50: + max_width = 50 + for i in range(self.danmaku_layout.count()): + item = self.danmaku_layout.itemAt(i) + if item and item.widget(): + widget = item.widget() + widget.setMaximumWidth(max_width) + # 强制重新布局,确保换行生效 + widget.updateGeometry() + + def auto_adjust_font_size(self): + """根据当前窗口宽度自动计算并调整弹幕字号""" + width = self.width() + new_size = int(width * 0.04) # 线性关系:宽度250 -> 字号10 + new_size = max(4, min(24, new_size)) # 限制字号范围 4~24 + if new_size != self.current_font_size: + self.set_font_size(new_size) + + def resizeEvent(self, event: QResizeEvent): + if not self._resizing and not self.isMaximized(): + self._resizing = True + if self.current_aspect_ratio == "16:9": + target_ratio = 16 / 9 + else: + target_ratio = 9 / 16 + + new_width = event.size().width() + new_height = event.size().height() + current_ratio = new_width / new_height + + if abs(current_ratio - target_ratio) > 0.01: + if current_ratio > target_ratio: + new_width = int(new_height * target_ratio) + else: + new_height = int(new_width / target_ratio) + new_width = max(self.min_width, new_width) + new_height = max(self.min_height, new_height) + self.resize(new_width, new_height) + event.accept() + + self.container.setGeometry(0, 0, self.width(), self.height()) + self._resizing = False + elif self.isMaximized(): + self.container.setGeometry(0, 0, self.width(), self.height()) + + self.update_ui_scaling() + self.update_danmaku_max_width() + self.auto_adjust_font_size() + super().resizeEvent(event) + +# ==================== 系统通知发送函数(静音) ==================== +def send_notification(title: str, main_content: str, sub_content: str = ""): + """在独立线程中发送 Windows 通知(避免 Qt 事件循环冲突)""" + def _show(): + pythoncom.CoInitialize() # 初始化 COM 为 STA + notification = Notification( + app_id="TSUKUYOMI DANMAKU", + title=title, + msg=main_content, + duration="short" + ) + if sub_content: + notification.msg = f"{main_content}\n{sub_content}" + # 暂时注释掉 set_audio,避免未知干扰 + # notification.set_audio(audio.Silent, loop=False) + notification.show() + pythoncom.CoUninitialize() + + threading.Thread(target=_show, daemon=True).start() + +# ==================== 托盘图标相关 ==================== +def setup_tray(quit_callback, icon_image): + menu = pystray.Menu(pystray.MenuItem("退出", quit_callback)) + icon = pystray.Icon("danmaku_client", icon_image, "TSUKUYOMI DANMAKU", menu) + tray_thread = threading.Thread(target=icon.run, daemon=True) + tray_thread.start() + return icon, tray_thread + +# ==================== 主函数 ==================== +def main(): + if sys.platform != "win32": + logger.error("此客户端仅在 Windows 上支持系统通知,请使用其他方式") + sys.exit(1) + + # 启用高 DPI 支持 + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + + app = QApplication(sys.argv) + + # 设置应用图标(任务栏) + app_icon = get_qicon() + app.setWindowIcon(app_icon) + + # 显示配置对话框(模态) + config_dialog = ConfigDialog() + if config_dialog.exec_() != QDialog.Accepted: + logger.info("用户取消配置,退出程序") + sys.exit(0) + + room_id = config_dialog.room_id + mode = config_dialog.mode + opacity = config_dialog.opacity + font_size = config_dialog.font_size + aspect_ratio = config_dialog.aspect_ratio + display_mode = config_dialog.display_mode # "window" 或 "notification" + + # 创建弹幕接收线程(两种模式都需要) + receiver = DanmakuReceiver(room_id, mode) + + # 托盘图标图片(提前加载) + tray_icon_img = get_icon_image() + + # 根据显示模式创建不同的 UI 组件 + if display_mode == "window": + # 悬浮窗模式 + main_window = DanmakuWindow(room_id, mode, opacity, font_size, aspect_ratio) + main_window.show() + receiver.new_danmaku.connect(main_window.add_danmaku) + main_window.receiver = receiver + # 退出回调 + def on_quit(icon=None, item=None): + logger.info("正在安全退出...") + exit_event.set() + try: + receiver.new_danmaku.disconnect(main_window.add_danmaku) + except TypeError: + pass + receiver.stop() + receiver.wait(3000) + if icon: + icon.stop() + QApplication.quit() + # 替换 close_app 方法 + main_window.close_app = lambda: on_quit() + # 设置 app 在最后一个窗口关闭时退出(悬浮窗关闭即退出) + app.setQuitOnLastWindowClosed(True) + else: + # 通知模式:不创建悬浮窗,只使用系统通知 + # 禁止窗口关闭时退出程序(因为没有主窗口) + app.setQuitOnLastWindowClosed(False) + + # 发送启动通知 + mode_text = "" + if mode == "main": + mode_text = "SITE 1" + elif mode == "backup": + mode_text = "SITE 2" + else: + mode_text = "SITE 1 & 2" + send_notification( + title="TSUKUYOMI DANMAKU", + main_content="弹幕接收已开启。右键托盘图标可关闭", + sub_content=f"{mode_text} · ROOM {room_id}" + ) + + # 连接弹幕信号到通知函数 + def on_new_danmaku(user, text): + send_notification( + title=text, # 弹幕内容作为标题 + main_content=f"来自 {user}", # 发送者信息作为正文 + sub_content="" # 无需副内容 + ) + receiver.new_danmaku.connect(on_new_danmaku) + + # 退出回调 + def on_quit(icon=None, item=None): + logger.info("正在安全退出...") + exit_event.set() + receiver.stop() + receiver.wait(3000) + if icon: + icon.stop() + QApplication.quit() + + # 启动接收线程 + receiver.start() + + # 创建托盘图标(两种模式都需要) + tray_icon, tray_thread = setup_tray(on_quit, tray_icon_img) + + # 运行 Qt 主循环 + try: + sys.exit(app.exec_()) + except SystemExit: + pass + finally: + if receiver.isRunning(): + receiver.stop() + receiver.wait(2000) + if tray_thread.is_alive(): + tray_thread.join(timeout=1) + logger.info("程序已完全退出") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/danmaku.spec b/danmaku.spec new file mode 100644 index 0000000..dc14ab3 --- /dev/null +++ b/danmaku.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['danmaku.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='danmaku', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['ico.ico'], +) diff --git a/ico.ico b/ico.ico new file mode 100644 index 0000000..6cc3b38 Binary files /dev/null and b/ico.ico differ diff --git a/test.py b/test.py new file mode 100644 index 0000000..0aca7ca --- /dev/null +++ b/test.py @@ -0,0 +1,10 @@ +from winotify import Notification, audio + +toast = Notification(app_id="Winotify Test", + title="🔔 通知测试", + msg="如果你能看到这条消息,说明winotify库本身工作正常。", + duration="short") + +# 暂时去掉set_audio,排除音频设置干扰 +# toast.set_audio(audio.Silent, loop=False) +toast.show() \ No newline at end of file