前言
酷狗、网抑云和 QQ 音乐都有桌面歌词功能,这篇博客也将使用 pyqt 实现桌面歌词功能,效果如下图所示:
代码实现
桌面歌词部件 LyricWidget
在 paintEvent
中绘制歌词。我们可以直接使用 QPainter.drawText
来绘制文本,但是通过这种方式无法对歌词进行描边。所以这里更换为 QPainterPath
来实现,使用 QPainterPath.addText
将歌词添加到绘制路径中,接着使用 Qainter.strokePath
进行描边,Qainter.fillPath
绘制歌词,这里的绘制顺序不能调换。
对于歌词的高亮部分需要特殊处理,假设当前高亮部分的宽度为 w
,我们需要对先前绘制歌词的 QPainterPath
进行裁剪,只留下宽度为 w
的部分,此处通过 QPainterPath.intersected
计算与宽度为 w
的矩形路径的交集来实现裁剪。
对于高亮部分的动画,我们既可以使用传统的 QTimer
,也可以使用封装地更加彻底的 QPropertyAnimation
来实现(本文使用后者)。这里需要进行动画展示的是高亮部分,也就是说我们只需改变“高亮宽度”这个属性即可。PyQt 为我们提供了 pyqtProperty
,类似于 python 自带的 property
,使用 pyqtProperty
可以给部件注册一个属性,该属性可以搭配动画来食用。
除了高亮动画外,我们还在 LyricWidget
中注册了滚动动画,用于处理歌词长度大于视口宽度的情况。
# coding:utf-8 from PyQt5.QtCore import QPointF, QPropertyAnimation, Qt, pyqtProperty from PyQt5.QtGui import (QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen) from PyQt5.QtWidgets import QWidget config = { "lyric.font-color": [255, 255, 255], "lyric.highlight-color": [0, 153, 188], "lyric.font-size": 50, "lyric.stroke-size": 5, "lyric.stroke-color": [0, 0, 0], "lyric.font-family": "Microsoft YaHei", "lyric.alignment": "Center" } class LyricWidget(QWidget): """ Lyric widget """ def __init__(self, parent=None): super().__init__(parent=parent) self.setAttribute(Qt.WA_TranslucentBackground) self.lyric = [] self.duration = 0 self.__originMaskWidth = 0 self.__translationMaskWidth = 0 self.__originTextX = 0 self.__translationTextX = 0 self.originMaskWidthAni = QPropertyAnimation( self, b'originMaskWidth', self) self.translationMaskWidthAni = QPropertyAnimation( self, b'translationMaskWidth', self) self.originTextXAni = QPropertyAnimation( self, b'originTextX', self) self.translationTextXAni = QPropertyAnimation( self, b'translationTextX', self) def paintEvent(self, e): if not self.lyric: return painter = QPainter(self) painter.setRenderHints( QPainter.Antialiasing | QPainter.TextAntialiasing) # draw original lyric self.__drawLyric( painter, self.originTextX, config["lyric.font-size"], self.originMaskWidth, self.originFont, self.lyric[0] ) if not self.hasTranslation(): return # draw translation lyric self.__drawLyric( painter, self.translationTextX, 25 + config["lyric.font-size"]*5/3, self.translationMaskWidth, self.translationFont, self.lyric[1] ) def __drawLyric(self, painter: QPainter, x, y, width, font: QFont, text: str): """ draw lyric """ painter.setFont(font) # draw background text path = QPainterPath() path.addText(QPointF(x, y), font, text) painter.strokePath(path, QPen( QColor(*config["lyric.stroke-color"]), config["lyric.stroke-size"])) painter.fillPath(path, QColor(*config['lyric.font-color'])) # draw foreground text painter.fillPath( self.__getMaskedLyricPath(path, width), QColor(*config['lyric.highlight-color']) ) def __getMaskedLyricPath(self, path: QPainterPath, width: float): """ get the masked lyric path """ subPath = QPainterPath() rect = path.boundingRect() rect.setWidth(width) subPath.addRect(rect) return path.intersected(subPath) def setLyric(self, lyric: list, duration: int, update=False): """ set lyric Parameters ---------- lyric: list list contains original lyric and translation lyric duration: int lyric duration in milliseconds update: bool update immediately or not """ self.lyric = lyric or [""] self.duration = max(duration, 1) self.__originMaskWidth = 0 self.__translationMaskWidth = 0 # stop running animations for ani in self.findChildren(QPropertyAnimation): if ani.state() == ani.Running: ani.stop() # start scroll animation if text is too long fontMetrics = QFontMetrics(self.originFont) w = fontMetrics.width(lyric[0]) if w > self.width(): x = self.width() - w self.__setAnimation(self.originTextXAni, 0, x) else: self.__originTextX = self.__getLyricX(w) self.originTextXAni.setEndValue(None) # start foreground color animation self.__setAnimation(self.originMaskWidthAni, 0, w) if self.hasTranslation(): fontMetrics = QFontMetrics(self.translationFont) w = fontMetrics.width(lyric[1]) if w > self.width(): x = self.width() - w self.__setAnimation(self.translationTextXAni, 0, x) else: self.__translationTextX = self.__getLyricX(w) self.translationTextXAni.setEndValue(None) self.__setAnimation(self.translationMaskWidthAni, 0, w) if update: self.update() def __getLyricX(self, w: float): """ get the x coordinate of lyric """ alignment = config["lyric.alignment"] if alignment == "Right": return self.width() - w elif alignment == "Left": return 0 return self.width()/2 - w/2 def getOriginMaskWidth(self): return self.__originMaskWidth def getTranslationMaskWidth(self): return self.__translationMaskWidth def getOriginTextX(self): return self.__originTextX def getTranslationTextX(self): return self.__translationTextX def setOriginMaskWidth(self, pos: int): self.__originMaskWidth = pos self.update() def setTranslationMaskWidth(self, pos: int): self.__translationMaskWidth = pos self.update() def setOriginTextX(self, pos: int): self.__originTextX = pos self.update() def setTranslationTextX(self, pos): self.__translationTextX = pos self.update() def __setAnimation(self, ani: QPropertyAnimation, start, end): if ani.state() == ani.Running: ani.stop() ani.setStartValue(start) ani.setEndValue(end) ani.setDuration(self.duration) def setPlay(self, isPlay: bool): """ set the play status of lyric """ for ani in self.findChildren(QPropertyAnimation): if isPlay and ani.state() != ani.Running and ani.endValue() is not None: ani.start() elif not isPlay and ani.state() == ani.Running: ani.pause() def hasTranslation(self): return len(self.lyric) == 2 def minimumHeight(self) -> int: size = config["lyric.font-size"] h = size/1.5+60 if self.hasTranslation() else 40 return int(size+h) @property def originFont(self): font = QFont(config["lyric.font-family"]) font.setPixelSize(config["lyric.font-size"]) return font @property def translationFont(self): font = QFont(config["lyric.font-family"]) font.setPixelSize(config["lyric.font-size"]//1.5) return font originMaskWidth = pyqtProperty( float, getOriginMaskWidth, setOriginMaskWidth) translationMaskWidth = pyqtProperty( float, getTranslationMaskWidth, setTranslationMaskWidth) originTextX = pyqtProperty(float, getOriginTextX, setOriginTextX) translationTextX = pyqtProperty( float, getTranslationTextX, setTranslationTextX)
上述代码对外提供了两个接口 setLyric(lyric, duration, update)
和 setPlay(isPlay)
,用于更新歌词和控制歌词动画的开始与暂停。下面是一个最小使用示例,里面使用 Qt.SubWindow
标志使得桌面歌词可以在主界面最小化后仍然显示在桌面上,同时不会多出一个应用图标(Windows 是这样,Linux 不一定):
class Demo(QWidget): def __init__(self): super().__init__(parent=None) # 创建桌面歌词 self.desktopLyric = QWidget() self.lyricWidget = LyricWidget(self.desktopLyric) self.desktopLyric.setAttribute(Qt.WA_TranslucentBackground) self.desktopLyric.setWindowFlags( Qt.FramelessWindowHint | Qt.SubWindow | Qt.WindowStaysOnTopHint) self.desktopLyric.resize(800, 300) self.lyricWidget.resize(800, 300) # 必须有这一行才能显示桌面歌词界面 self.desktopLyric.show() # 设置歌词 self.lyricWidget.setLyric(["Test desktop lyric style", "测试桌面歌词样式"], 3000) self.lyricWidget.setPlay(True) if __name__ == '__main__': app = QApplication(sys.argv) w = Demo() w.show() app.exec_()
后记
至此关于桌面歌词的实现方案已经介绍完毕,完整的播放器界面代码可参见:https://github.com/zhiyiYo/Groove,以上~~