B站直播弹幕姬

0. 前言

本文于2022-01-16发布于CSDN,且为上一篇文章后续,现迁移至此。

这个软件是基于我半年多前写的一个小小小软件的一个大更新,即集合前端和后端写成的一个软件、程序应用。不过其实相当于重新写了一个就是了www

完整代码我已经开源到gitee和github上了,并且软件的使用方法已经发到b站,链接在文末,欢迎大家一起学习讨论。

1. 日志对象

软件有日志都很常见了,既可以了解到现在程序运行在哪,也可以很方便地定位到出错代码。

写在所有代码之前作为全局变量也是为了各个代码模块方便调用。

2. 获取弹幕

获取b站直播弹幕用到了websocket协议,使用到了aiowebsocket这个类。详情可以看我上一篇文章(AioWebSocket实现python异步接收B站直播弹幕),已经讲得挺详细的了(大概),将文章中的代码封装成一个类就可以拿来用了,这里就只列出函数定义头了。

1
2
3
4
5
6
7
8
9
10
class BiliSocket():
def __init__(self):
async def startup(self,roomid):
def getRealRoomid(self,url):
async def check2close(self):
def close(self):
async def sendHeartBeat(self, websocket):
async def receDM(self, websocket):
def printDM(self, data):
def DANMU_handle(self,data):

所不同的是,我新加入了几个函数。 - 有些直播间地址栏的房间号是3位数及以下的,这些不是真实房间号,是获取不了弹幕的。所以getRealRoomid()用来获取真实房间号。 - DANMU_handle()这个方法是对弹幕的处理,包括对用户输入的关键词进行筛选,及筛选后将弹幕传到展示窗口。 - 软件运行时,主线程要给窗口刷新,不能用来接收弹幕,所以接收弹幕只能放在子线程。而子线程怎么关闭呢?我上网找过很多方法:一开始在aiowebsocket这个类里找到一个close_connection的方法,但这个方法好像不能帮我结束线程;后来用loop.stop()结束了循环并且用join()对子线程进行阻塞,等待子线程退出,这样其实也是可行的,但有时候又不行,反而会因为阻塞时间太长导致主窗口主线程无响应。后来我找到可以通过抛出异常来使线程退出,于是我写了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async def check2close(self):
'''
循环判断关闭标志位closeFlag是否为真
若为真则抛出异常来结束该子线程
一定要设置休眠否则占据资源导致卡死
'''
while True:
await asyncio.sleep(0.2)
if self.__closeFlag == True:
raise KeyboardInterrupt

def close(self):
'''通过设置标志位来结束线程'''
self.__closeFlag = True

定义一个标志位__closeFlag,子线程通过扫描它来判断自己是否该退出,若是则抛出异常来退出该线程。完美解决! # 3. qt窗口 前端方面用到的是pyqt5。之前我也用过tkinter(python自带的一个gui库)来实现过类似的应用,那是我半年前写的。但我发现tkinter功能太少,界面太简陋,不好操作,所以换成了比较多人用的gui,也就是pyqt5。但qt资料大多是c++的,转换到python不是一件容易事,对于我这种新手来说真的很不容易(所以为什么要用python来写啊哈哈)。

首先安装

1
2
pip install PyQt5
pip install PyQt5-tools

导入pyqt5模块(这是懒人一键全部导入,也可以一个一个模块导入)

其他模块就不赘述了,用到再导入就好。

1
2
3
4
5
from PyQt5 import QtCore
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.uic import loadUi

在pyqt5安装目录下可以找到qt designer,这个图形化软件可以帮助我们设计出想要的窗口。

3.1 窗口间传递信号

有时候,多个窗口之间要互相传递信息,这时候可以利用qt的信号和槽来实现。

举个例子

1
2
3
# 定义一种信号,两个参数 类型分别是: 整数 和 字符串
# 调用 emit方法 发信号时,传入参数 必须是这里指定的 参数类型
text_print = QtCore.pyqtSignal(int, str)

发送信号

1
text_print.emit(1,'abc')

接收信号

1
2
3
4
def printFun(num,string):
print(num,string)

text_print.connect(printFun)

而我很多时候需要传递不同种参数,需要多个信号变量,所以直接定义一个信号类并实例化,作为全局变量。这样就可以让不同的类之间相互交流了,不是继承自QObject的类也可以用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
'''
自定义信号源对象类型,一定要继承自 QObject
'''
class MySignals(QObject):
# 定义一种信号,两个参数 类型分别是: 整数 和 字符串
# 调用 emit方法 发信号时,传入参数 必须是这里指定的 参数类型
text_print = QtCore.pyqtSignal(int, str)
# 还可以定义其他种类的信号
my_Signal = QtCore.pyqtSignal(str)
new_comment = QtCore.pyqtSignal(bool,str)
otherChange = QtCore.pyqtSignal(str,str)
sizeChange = QtCore.pyqtSignal(str,int)
fontChange = QtCore.pyqtSignal(QFont)

# 实例化
global_ms = MySignals()

3.2 主窗口

主窗口比较简单,简单写写就行。

3.3 设置窗口

设置窗口主要是对展示窗口进行一些参数的设置,比如文字大小、颜色、字体等。重点和难点应该是参数发生变化后如何与展示窗口进行交流并及时调整。

1
2
3
4
5
6
7
class ConfigWindow(QWidget):
def __init__(self, parent=None):
super(ConfigWindow, self).__init__(parent)
loadUi('ui/ConfigWindow.ui', self)
# 标题、图标
self.setWindowTitle('设置')
self.setWindowIcon(QIcon(iconPath))

这里我采用的是(QLineEdit+QPushButton )和QSpinBox 实现参数的改变。将QLineEdit设置为只读,当用户点击按钮修改参数时,程序会修改QLineEdit的内容;QSpinBox 则是直接改变内容的。当QLineEdit或QSpinBox的值发生变化时,发出信号并传递需要修改的参数,展示信号接收后作出相应操作。

下面的是窗口初始化部分代码,将QLineEdit或QSpinBox的值发生变化时与函数绑定,将参数发射出去。(接收部分详见展示窗口)

设置窗口还有一个功能,就是将参数导入、导出,不用每次都手动设置。因为我不熟悉配置文件的操作,不知道我这种存储方式用那种文件比较合适,所以用了我最熟悉的excel表。如果各位有什么好推荐的话欢迎向我提出。

一开始我是打算打开一个文件浏览框让用户选择路径和文件名,但发现这样不好操作,因为做不到将用户设置的路径保存下来,下次自动导入(不可能为了它新建一个文件来保存吧,这样也不保险)。最保险的方法就是自己在代码里设定好路径,每次到这个路径下去找就行了。

3.4 弹幕展示窗口

展示窗口是最复杂的,花了我很长时间找资料和解决办法。

1
2
3
4
5
6
7
class DisplayWindow(QDockWidget):
def __init__(self, parent=None):
super(DisplayWindow, self).__init__(parent)
loadUi('ui/DisplayWindow.ui', self)
self.setWindowFlags(Qt.WindowStaysOnTopHint|Qt.FramelessWindowHint|Qt.Tool) # 置顶、无边框、隐藏任务栏
self.setAttribute(Qt.WA_TranslucentBackground) # 窗体背景透明
self.setMouseTracking(True) # 设置widget鼠标跟踪

3.4.1 窗口中间层

首先是文字显示。

为了无边框后可以移动窗口(移动窗口的方法一会儿讲到),我一开始用的是QLabel来设置文字,因为我发现鼠标在QLabel上是不会变成其他样式,而其他输入控件上鼠标会变成输入样式,导致无法移动。但QLabel有个致命缺点就是无法换行,导致我要添加多个QLabel来实现多行文本。而且当设置文字样式(比如文字描边后)也会出现无法移动窗口的情况。不仅占用资源多还容易出错。

这时候我想到,既然鼠标在QLabel上不受影响,那如果在顶层铺一层QLabel,不设置文字(相当于透明),下面的控件会不会受影响呢?事实证明,覆盖下面控件后,鼠标就不受下面控件影响了,终于可以愉快的移动窗口了!

于是下面的文字显示控件换成了QPlainTextEdit。这个控件既可以方便的换行、设置最大行数,又可以设置不同部分的文字颜色。这样,弹幕发送者与弹幕内容的文字颜色分开,就可以更直观了!

将弹幕加入QPlainTextEdit时我发现只能在主线程中操作,如果在子线程中会有显示延迟的情况。不过这也没有什么办法

3.4.2 窗口顶层

然后是置顶、无边框后的移动处理和改变大小处理。

窗口移动和改变大小可以合在一起说,因为他们都是利用了鼠标操作函数mousePressEventmouseMoveEventmouseReleaseEvent。只要我们重写这三个函数,就可以实现这些功能了。

窗口移动很简单,只需要在发生鼠标点击事件mousePressEvent后跟踪坐标并计算移动后坐标值,将窗口移动至此坐标就行。 但改变大小有点麻烦,我需要划分一定的区域,在这部分区域内才允许改变窗口大小,并且改变鼠标样式。所以本来直接在顶层铺一个label,我改成了在顶层铺一个QWidget,并在QWidget里面添加了3个QLabel,分别在如下位置:

然后,设置当鼠标在这3个QLabel上面时改变鼠标样式,提醒用户这些位置可以改变窗口大小。效果如下图:

这部分代码如下:

3.4.3 窗口底层

然后是无边框后的背景处理。

因为我想要实现改变窗口透明度的功能,但控件透明度不变(不然文字也看不清了),所以找了很多代码,例如下面的。

1
2
MainWindow.setWindowOpacity(0.85)  # 设置窗口透明度
MainWindow.setAttribute(QtCore.Qt.WA_TranslucentBackground) # 设置窗口背景透明

但这些都只能改变整个窗口包括控件透明度。但我需要的是仅仅只是背景的改变。于是我想到,在文字控件的下面再铺一层QLabel,让这个QLabel显示图片,并且自己在ps里制作几张不同透明度的图片,分别让他显示出来:

搞定!窗口初始化代码如下:

1
2
3
4
5
6
7
# 底层label,设置背景图片
self.backLabel = QLabel()
self.backLabel.setObjectName("backLabel")
pix = QPixmap(initbackground)
self.backLabel.setPixmap(pix)
self.backLabel.setScaledContents(True)
self.gridLayout.addWidget(self.backLabel, 0, 26, 11, 11)

注意一定要铺在上层文字控件的同一位置(0, 26, 11, 11),否则这个Label会被挤到一边去。也正是这个原因我在qt designer里弄不到理想的效果,所以只能自己在代码里实现了。

后续要改变透明度的话,只需要将图片路径传进QPixmap再调用setPixmap就可以了。除了白底图片,其他任意图片也是可以的。

3.4.4 信号连接

初始化

1
2
3
4
5
def SignalConnect_init(self):
global_ms.new_comment.connect(self.addComment)
global_ms.sizeChange.connect(self.modifySize)
global_ms.fontChange.connect(self.modifyFont)
global_ms.otherChange.connect(self.modifyOther)

以下代码以modifySize()为例:

添加弹幕因为显示问题不得不在主线程中进行,但修改参数没有这些问题,所以在子线程中修改就行,可以减少主线程压力,防止窗口无响应。

3.5 托盘

托盘这里我采用的方法是继承QObject类。因为像MainWindow他有一个菜单栏QMenu,那我只要模仿他,创建一个QMenu,再将动作QAction加进去,就变成了一个托盘菜单选项了。

4.主函数

上面已经完成了各个功能模块的类的代码,接下来只需要把他们联系起来

点击(开始/更新)按钮执行的函数:先判断输入房间号是否合法;然后判断是否已经开始,若已经开始则为更新效果(先执行关闭操作再重新开始)。将获取弹幕的操作放到子线程。

点击(关闭)按钮执行的函数:调用BiliSocket类的自定义close方法,并且等待它抛出异常,从而达到结束线程的效果。

接收信号并处理

退出软件

写上循环指令

最后,只需要在其他地方实例化这个类,并且调用它的run方法,就可以运行整个程序啦!

1
2
danmuji = BiliDanmuji()
danmuji.run()

5. 最终成果及使用方法

b站:https://www.bilibili.com/video/BV1LP4y177sa

CSDN:https://blog.csdn.net/Sharp486/article/details/122516917

个人网站:https://huihui486.github.io/2023/01/18/B站直播弹幕姬

6.开源地址

gitee:https://gitee.com/huihui486/bilibili-danmuji github:https://github.com/huihui486/bilibili-danmuji


B站直播弹幕姬
https://huihui486.github.io/2023/01/17/Bilibili_danmuji/B站直播弹幕姬/
作者
灰灰
发布于
2023年1月17日
许可协议