一个在线音乐软件的故事(五、让我们开始写代码吧)
让我们开始写代码吧
现在有了明确的功能需求,几乎克服了所有的技术障碍,那么就可以开始动手编写这个音乐播放软件了。
一、组织项目结构
这个故事所讲的在线音乐播放软件并没有很复杂的功能需求,界面数量很少,没有数据库操作。这样的项目几乎可以任意组织代码文件,甚至可以没有任何结构,把所有的代码都保存在同一个目录中,但我们依然希望能有一套便于组织和维护的项目结构。
我们把项目划分为:项目配置、协议分析、音乐播放、其他独立代码、用户界面与控制这几个代码包,他们各自负责各自的功能,相对独立。因为项目比较简单,就没有将View层与控制层再进行区分,所以在用户界面与控制包中可以看到很多应该在控制层的代码。
项目配置代码包和其他独立代码包中的代码非常简单,特别是前者,仅存放一些常量配置数据,比如:网络请求的超时时间、缓冲区大小、各种资源文件的保存路径、搜索音频资源所用到的各种请求路径等。其他独立代码包中是一些功能完全独立的函数,如:是否在py2exe编译环境中执行、项目的主目录位置和一些其他的函数。
在config和util两个代码包目录中可找到对应的源码文件。
二、获得音乐数据,分析协议
在音乐从哪里来那一节中已经说明了通过什么方式获得音乐数据,但并没有介绍如何实现他们。我在协议分析代码包中实现这些功能,这里有个名叫TencentProtocol的类,所有与获取音乐信息有关的操作都在这里。
腾讯QQ音乐的所有请求响应数据都是按JSON格式传输的,因此在这个类中有个独立的classmethod用于发出请求获取JSON文本。而信息获取的第一步是通过关键字搜索,这是一个网络操作,存在一定的等待时间,为了不影响GUI界面的操作,同样要用到多线程,所以TencentProtocol这个类从QThread继承,在run()接口方法中启动搜索发出JSON请求,获取JSON文本。接着我们就能从JSON对象中获取我们所需的信息。
def search_song(self):
"""
搜索歌曲
"""
song_list = []
self.exception_list = []
try:
_url = self.qq_searcher(self.keyword, self.page_index,
self.page_size)
json_string = self.json_request(_url)
json_string = json_string[9:len(json_string) - 1]
song_list_json = json.loads(json_string)
for song in song_list_json[u'data'][u'song'][u'list']:
song_info = SongInfo()
song_info.album_id = int(song[u'albumid'])
song_info.album_mid = song[u'albummid']
song_info.album_name = self.decode_korean(song[u'albumname'])
song_info.id = int(song[u'songid'])
song_info.mid = song[u'songmid']
song_info.name = self.decode_korean(song[u'songname'])
song_info.interval = int(song[u'interval'])
song_info.length = seconds2time(int(song[u'interval']))
song_info.pub_time = int(song[u'pubtime'])
song_info.url = song[u'songurl'] if u'songurl' in song else ''
song_info.nt = int(song[u'nt'])
song_info.singer = []
song_info.singer_names = ''
singer_names = []
for sg in song[u'singer']:
name = self.decode_korean(sg[u'name'])
singer = {
'id': int(sg[u'id']),
'mid': sg[u'mid'],
'name': name
}
singer_names.append(name)
song_info.singer += [singer]
song_info.singer_names = u','.join(singer_names)
song_list += [song_info]
except BaseException as e:
e.message += u"搜索音乐信息错误。"
self.exception_list.append(e)
self.song_list = song_list
self.emit(SIGNAL('search_complete()'))
在获取音乐信息的过程中要注意,搜索结果所返回的JSON文本中可能包含非中文的文字编码,其中需要特殊处理的是韩文编码。为了能在JSON中传输韩文文字,韩文会被编码为ꨤ这种格式的Unicode码,其中“&#”为前缀“;”为后缀,一个这样的编码结构为一个韩文文字,所以我们需要一个函数来解码并正确显示韩文。其实无论哪种文字,只要是这样的编码结构,这个函数都能解码。
@classmethod
def decode_korean(cls, string, prefix='&#', postfix=';'):
"""
韩文解码函数
:param str string: 需要解码的韩文 Unicode 数据
:param str prefix: 韩文 Unicode 编码的开始符号
:param str postfix: 韩文 Unicode 编码的结束符号
:return: 解码后的 UTF-8 文本内容
"""
exp = prefix + r"(\d{5}?)" + postfix
code_list = re.findall(exp, string)
for code in code_list:
u_code = u'{0}'.format('\u' + hex(int(code))[2:6])
word = u_code.decode('unicode-escape')
string = string.replace(prefix + code + postfix, word)
string = string.decode('utf-8')
return string
在搜索函数中还需要完成一项重要任务,就是通知别的线程搜索已经完成,但是否存在错误需要其他线程自己检查。这个通知任务也就是线程间通讯的主要目的,因为搜索是在一个独立的线程中执行的,当搜索完成之后,主线程或其他启动线程并不知道,我们需要发出一个信号通知其他正在运行的线程,搜索任务已经完成,可以继续执行后续动作。
在搜索函数的最后一行可以看到,通过调用QThread的emit()方法发出一个信号,这个信号的名称是search_complete(),并且这个信号不带任何参数。如果这个信号带有参数,那么需要在信号名称中指定参数的类型,比如search_complete(int),并且要在emit方法中提供这个参数值,写成类似这样的结构:
self.emit(SIGNAL('search_complete(int)'), 10)
实际范例可以在这个类的下载函数中找到。这样其他线程可以通过槽连接到这个信号,来捕捉这个信号,一旦捕捉到这个信号,槽中的函数就被调用执行,也就意味着后续动作开始了。在PyQt、PySide中GUI组件也是通过“信号-槽”的方式来传递事件信号的。
TencentProtocol类除了包含搜索功能,还包含获得音乐专辑封面图片地址、获取音乐源地址的功能,总的来说都是发送请求,分析响应结果的过程,就不再逐一介绍了。关于线程内部的异常,建议不要尝试抛出,推荐的做法是把异常保存下来,留给其他线程去捕捉处理。
搜索功能的最终产出是一个保存SongInfo对象的列表,在搜索函数的倒数第二行可以看到。有了这些SongInfo对象,就能在后续的操作中载入音频数据。但是这里我们首先要分析一下SongInfo类,对于这个类我们有一定的要求。
三、保存分析结果,在软件中传递
搜索结果中的音乐信息是保存在JSON对象中的,JSON对象实际上就是dict对象,通过key来获取有用的音乐信息。由于获取的音乐信息要在软件的不同位置多次传递,为了防止输入的key名称错误,最好避免直接使用dict对象保存数据。因此编写一个叫做SongInfo的类,用于保存音乐信息。
实际上这个类非常简单,只要一些基本属性就可以了,但这个类最好能保留dict的所有特点,能动态增加数据项,又能有固定名称的属性,而且这些属性能够很方便增加,因为在开始动手编码时并不能确定SongInfo究竟需要多少属性。
这就需要用到Python的元类技术。学过数据库管理的都知道,数据库中有元数据的概念,元数据就是用来描述数据的数据,你可以理解元类就是描述类的类,基于这么相似的两个特性,元类设计思路也常常用在Python数据库操作的ORM映射中。
元类能改变类的属性类型、属性数量、以及这些属性的初始值。当你通过 __metaclass__ 属性为类A设置了元类,那么在载入A类时(注意不是类实例化对象的时间,要比这个更早)会首先执行元类的初始化动作,以明确A类应当具有哪些属性,这些属性的初始值是什么。元类都是从type继承下来的,通常会重写type类的 __new__ 方法,来加工类A的属性或方法,可以参考下面的代码:
class SongMetaclass(type):
"""
用元类的方式初始化音乐信息类,自动增加对应的属性
"""
@classmethod
def __new__(mcs, *more):
class_name = more[1] # type: str
super_classes = more[2] # type: tuple
attributes = more[3] # type: dict
mappings = dict()
for k, v in attributes.items():
if isinstance(v, SongInfoTag):
mappings[k] = v
attributes.pop(k)
attributes['__mappings__'] = mappings
return type.__new__(mcs, class_name, super_classes, attributes)
注意 __new__ 方法的参数 *more,这是一个元组类型的参数,其次序和含义是固定的,分别是:类名称、父类元组、属性字典,我们要实现的功能是将SongInfoTag类型的属性放在mappings中,并把他们从属性字典中删除,因为我们要的并不是属性,而是和字典一样的数据项。
现在编写SongInfo类的思路就清晰了,首先从dict继承,设置__metaclass__属性为 SongMetaclass,然后在 __init__() 方法中遍历 mappings 列表,逐个创建数据项,并用None初始化这些数据项,最后重写 __getattr__() 和 __setattr()__ 两个方法,首先从字典中读写数据,如果没有找到再尝试直接操作对象的属性数据。
对于我们需要的音乐信息属性,只要增加SongInfoTag类型的属性即可,这些属性在对象初始化过程中会被替换为None数据。这样我们就不用像操作dict那样使用key名称读写数据,只要通过点操作符就能读写属性数据,能在一定程度上避免出错。
在protocol代码包中可以找到SongInfo.py和TencentProtocol.py两个源码文件,以上所讲内容在这两个代码文件中都有对应实现。
四、载入和播放音乐
前面已经讲了很多播放音乐时应该注意的问题,但是并没有详细实现,现在就要来完成这部分工作。
按照前面的分析,我们应该首先解决音乐载入的问题,因为音乐并不总是从网络载入,对于已经播放过的并且成功缓存的音乐应该从本地载入,只有没有播放过的或缓存失败的音乐才需要从网络载入。
因此在player代码包中有个称为音乐装载器的类AudioLoader专门负责载入音乐文件,无论是从网络载入还是从本地载入,都是由它负责。但是要使用这个类,必须先有一个SongInfo对象,然后才能通过音乐装载器载入对应的音乐文件。前文已经介绍了通过关键字搜索可以获得一批SongInfo对象,其实还有其他的方式获得SongInfo对象,后面会讲到。
既然AudioLoader类要负责从文件或网络载入音频数据,显然AudioLoader类也必须支持多线程操作,所以也是从QThread继承。这个类的source_type属性用于区分从网络加载数据,还是从文件加载数据。由AudioLoader的run()方法要负责判断,并调用不同的载入方法加载数据。
def run(self, *args, **kwargs):
if self.source_type == AUDIO_FROM_INTERNET:
self.cache_from_url()
elif self.source_type == AUDIO_FROM_LOCAL:
# 从本地缓存读取音频文件时要求 song_info 必须具有 file_path 键值
self.cache_from_local()
从本地文件加载音频数据的操作比较简单,直接通过AudioSegment.from_file()方法就能完成载入动作。这里以从网络装载为例,说明载入和缓存的过程:
def cache_from_url(self):
"""
从一个 URL 地址获取音乐数据,并缓存在临时目录中
实际装载的过程是首先检查缓存目录中是否存在有效的音乐副本和封面图片副本
如果有,就直接从缓存播放,否则从网络下载,并缓存
:return: 返回缓存的临时文件对象
"""
self.emit(SIGNAL('before_cache()'))
self.is_stop = False
self.exception_list = []
try:
if self.song_info.song_url is None:
tencent = TencentProtocol()
tencent.get_play_key(self.song_info)
tencent.get_song_address(self.song_info)
tencent.get_image_address(self.song_info)
if tencent.has_exception:
self.exception_list += tencent.exception_list
raise tencent.exception_list[0]
self.image_data = self.check_image_cache()
if not self.image_data:
"""
从网络读取专辑封面,并写入本地缓存文件
"""
self.image_data = \
requests.get(self.song_info.image_url).content
cache_image_path = QM_DEFAULT_CACHE_PATH + \
str(self.song_info.mid) + '.jpg'
if os.path.isfile(cache_image_path):
os.remove(cache_image_path)
with open(cache_image_path, 'wb') as cover_file:
cover_file.write(self.image_data)
cache_audio = self.check_audio_cache()
if isinstance(cache_audio, AudioSegment):
"""
从缓存载入音频
"""
self.audio_segment = cache_audio
self.emit(SIGNAL('caching()'))
else:
"""
从网络缓存音频,并写入本地缓存
"""
request = Request(self.song_info.song_url)
pipe = urlopen(url=request, timeout=QM_TIMEOUT)
cache_file = QM_DEFAULT_CACHE_PATH + \
str(self.song_info.filename)
if os.path.isfile(cache_file):
os.remove(cache_file)
with open(cache_file, 'wb') as audio_file:
while True:
data = pipe.read(QM_BUFFER_SIZE)
if self.is_stop or data is None or len(data) == 0:
audio_file.close()
break
audio_file.write(data)
sleep(0.01)
self.audio_segment = \
AudioSegment.from_file(audio_file.name)
self.emit(SIGNAL('caching()'))
audio_file.close()
except RuntimeError as e:
e.message += u"运行时错误。"
self.exception_list.append(e)
except BaseException as e:
e.message += u"获取音乐数据错误。"
self.exception_list.append(e)
self.is_stop = True
self.emit(SIGNAL('after_cache()'))
这个方法先检查SongInfo对象的信息是否完整,如果不完整,则补全播放键、音乐地址、专辑封面图片地址等信息。然后检查是否存在专辑封面图片缓存,有则从网络读取,并缓存。
音乐文件也是这样,先检查缓存然后从不同的地方载入数据。从本地缓存载入只要一次就能载入所有数据,所以只会发出一次caching()信号,也只会产生一个用于保存音频数据的AudioSegment对象。
从网络载入时要根据缓冲区的大小多次载入,再分别写入缓存文件,这里不再使用临时文件保存缓存数据,而是在参数配置中设置一个目录位置,专门用于保存缓存数据,每首音乐都会创建一个缓存文件,同时还会缓存与音乐对应的专辑封面图片。缓存过程中会多次读取网络流数据再追加写入缓存文件,所以会多次发出caching()信号。也将会产生多个AudioSegment对象,且每次这个对象都会从缓存音频文件中载入所有数据,以便送给播放器对象播放。载入结束之后装载器会发出after_cache()信号,通知其他线程载入工作已经完成。但是否存在异常要通过检查exception_list才能知道。
播放音乐并不需要等到发出after_cache()再开始播放,只要收到caching()信号,就能确认缓存数据已经存在,就可以调用Player类的方法开始播放音乐。
在上面介绍播放进度的时候,我们已经看到了Player类的play()方法,这里不再介绍播放函数,而是要介绍播放器类载入播放数据的过程。
Player类有两个属性重要,一个是audio_segment,另一个是start_position,这两个属性分别对应于播放数据对象和播放开始的时间,当你为播放器设置这两个参数时会分别调用不同的设置方法,用于设置待播放的chunks。这里我们要重点介绍的是setup_chunks_for_cache()函数。
def setup_chunks_for_cache(self):
"""
从文件缓冲中载入要播放的 chunks
由于缓存文件在不停的变化,因此要记录下累计从缓存中载入了多少数据
下次缓存消息发出的时候,从已经载入的数据位置开始继续载入
:return: 缓存载入的状态
"""
if not self.is_valid:
return False
start = self.loaded_length
length = self.duration - self.loaded_length
self.loaded_length += length
# 创建要播放的 chunk
play_chunk = self.audio_segment[start * 1000.0: \
(start+length) * 1000.0] - (60 - (60 * (self.volume / 100.0)))
self.chunks += make_chunks(play_chunk, self.chunk_duration * 1000)
return True
从这个函数可以看出,每次从缓存文件载入音频数据,都会记录下载入的总量,下次再通过缓存文件载入时,将从上次缓存的结尾处开始读取音频数据。然后将数据加到self.chunks的结尾,由播放函数负责写入声卡数据流。
可以看到通过AudioLoader和Player两个类的配合使用,就能完成对音频的加载、缓存、播放这几个操作。
五、构建简约时尚的GUI界面
还是根据那张图片来构建软件的GUI界面,从图片上可以看出来,这个界面主要分为顶部绿色搜索区、左边面板选择按钮区、中部音乐列表面板区和底部深色的音乐播放控制区这几个主要的区域。
每个面板都是从PySide的QFrame组件继承而来,QFrame和QWidget、QApplication一样都支持layout操作,但是与QWidget不一样的是,QFrame可以直接设置背景色。
在QFrame上添加UI组件的方式很简单,一般来说会先为QFrame设置layou组件。我们称为布局组件。PySide支持很多布局方式,水平方向布局组件(QHBoxLayout)、垂直方向布局(QVBoxLayout)、表单布局(QFormLayout)、网格布局(QGridLayout)这些布局组件能让你在设计GUI界面的时后非常方便快捷。这里我们不打算详细介绍如何使用这些布局组件,不过如果你是Java Swing的用户,这些布局对你来说就非常熟悉了。
回到我们的软件,前面已经说过,我们把面板分解为几个区域,现在我们就来说说每一个区域的布局,顶部和底部一样,都是使用的从左到有的水平方向的布局,所以我们为QFrame设置的是QHBoxLayout,然后向layout添加按钮、文本框组件即可。
但是仔细观察顶部和底部的面板会发现,面板上的按钮分为左边区域和右边区域。这里有个小技巧,当你希望在水平布局时一部分组件放在左边,另一部分放在右边,那么在两个区域的中间你可以想象为需要一个弹簧,把组件往面板的两边顶。这在PySide中很简单,只要调用一次layout.addStretch(1)方法就可以了。
左边的面板是按照垂直方向,从上到下进行排列布局,只要为QFrame设置QVBoxLayout,然后再为面板添加按钮就能实现这样的排列布局。
中部的列表区域与其他区域不一样,不是在QFrame上直接添加组件,而是首先添加QStackedWidget组件,然后在这个组件中添加多个QFrame面板。QStackedWidget的特点是它可以拥有很多Widget组件,但是每次只显示其中的一个,我们需要通过左边按钮面板上的按钮来控制QStackedWidget应该显示哪个面板。
通过PySide的layout布局方式构建像上面图片那样的软件界面并不是很麻烦,可以说很简单。但软件界面上还有很多Icon、图片等辅助资源,这些资源必须事先准备好,并显示在正确的位置上,否则一个只有文字的软件界面是很枯燥无味的。
这里需要说明PySide支持的图标格式比较丰富,但是我们用的比较多的是PNG和SVG两种,这两种图标都能很好地支持Alpha通道的透明部分,其中PNG是点阵图,放大缩小PNG最好不要幅度太大,否则变形会比较严重。而SVG是XML格式描述的图片,支持矢量变化,缩放比例可以很大且不失真。SVG的另外一个好处是小,因为是用文字描述的,在图像结构不复杂的情况下(通常Icon图标都不会太复杂),文件会非常小。可以查看项目资源目录中icons目录中的文件,几乎所有的Icon都是SVG格式的。
无论是使用PNG格式还是使用SVG格式,创建一个图标的方法都是一样的:
icon = QIcon(QM_ICON_PATH + 'qq_music_sm.png')
要在修改窗体图标可以使用:
QMainWindow.setWindowIcon(icon)
要在按钮上应用图标可以使用:
QPushButton.setIcon(icon)
当我们通过layout完成布局,并为窗口、按钮都添加好图标之后,我们得到的界面可能是这样的:
因为我们还没有应用样式,所以很多与尺寸、颜色、鼠标等有关的特效都无法表现出来。在“简约时尚的界面”那一节已经知道如何设置组件的CSS样式,现在就是它大显身手的时候了!
通过前面的章节知道可以为每个组件单独设置样式,不过作为一个完整独立的软件,分别设置每个组件的样式,还是太麻烦了,我希望能像HTML那样,直接导入一个CSS样式表文件,应用在整个项目上,各个组件可以像HTML元素那样设置class属性,来设置各自组件的样式文件。
幸好PySide具有这样的功能,而且实现比较简单。只要从文件读入所有的CSS文本内容,通过下面的代码应用在主窗口上就可以了:
qss = self.load_qss(QM_QSS_PATH)
self.setStyleSheet(qss)
上面一行是从文件读入所有的CSS内容,下面一行是将读取到的所有文本内容设置为主窗口的样式表。
接着,我们就针对有需要用到样式的组件单独设置属性,就像设置HTML元素的class一样,来看看这样的代码怎么写:
self.btn_playlist.setProperty('class', 'highlight_button')
只要这样设置就可以了,但是需要注意,这里的class并不是PySide指定的,你可以任意指定,只要和css文件中的样式名称一致就行。比如上面的属性设置,对应的样式表声明是这样的:
LeftPanel QPushButton[class="highlight_button"],
LeftPanel QToolButton[class="highlight_button"] {
height: 32px;
width: 140px;
border: none;
padding-left: 10px;
padding-right: 10px;
border-radius: 5px;
text-align: left;
color:#555555;
}
可以看到属性名称声明是class,属性值是highlight_button。这段声明的实际含义是:应用在所有LeftPanel实例对象的QPushButton和QToolButton对象上,并且这些按钮对象要具有名为class值为highlight_button的属性。在CSS文件中,还能看到很多其他的样式声明,但都会有属性名称和属性值的声明。
还有一种声明是状态声明,在对音乐表格样式声明的时候用到了,样式声明是这样的:
SongTable::item::selected {
background-color: #DDEEDD;
}
这段声明是表示对于所有SongTable的实例对象的元素,只要被选了,那么他们都会应用这个样式声明。
当为所有的组件都设置了样式属性,并在应用程序启动时读入所有样式文本,设置为应用程序的样式,就能看到像第一张图片那样的界面。
六、让各部分协同工作
上一小节介绍了如何把各个区域的面板构建出来,现在我们希望能让各个区域能协同工作。先来看一看个部分应该负责的工作:
顶部面板:负责接受用户输入,点击搜索按钮后执行搜索功能;显示等待动画。
左侧面板:负责切换中部区域的表格。
中部面板:负责展示不同的面板,表格或别的内容,暂时并无其他内容。
底部面板:负责控制装载器和播放器,实现音频播放和控制。
搜索面板:负责执行搜索功能,并展示搜索结果。
缓存面板:负责从本地读取历史缓存文件并显示,同时显示最新缓存记录。
下载面板:负责从本地下载目录读取历史下载文件并显示,同时显示最新下载记录。
这里并不对所有的面板功能都作相信说明,只对:搜索面板、底部面板、主程序界面进行说明。
1. 搜索面板(SearchPanel)
首先需要说明的是搜索面板,搜索面板负责执行搜索,显示搜索结果。在前面获得音乐数据、分析协议小节中已经详细说明了音乐信息的搜索过程,那些程序就是在这里调用的。所有在这个面板中最重要的就是创建TencentProtocol类的对象,并执行start()接口方法启动搜索过程。
def search(self):
"""
通过关键字搜索歌曲
注意会启动新的线程进行搜索,通过 tencent 对象的消息捕获搜索结果
"""
self.emit(SIGNAL('before_search()'))
self.tencent.keyword = self.keyword.encode('utf-8')
self.tencent.page_index = self.page_index
self.tencent.page_size = self.page_size
self.tencent.start()
就像上面这样,设置好关键字、页码、每页显示的数据量这些参数之后就可以启动搜索,在初始化self.tencent的过程中已经为self.tencent连接了search_complete()信号,每当搜索完成之后就会执行槽方法:
self.tencent.connect(SIGNAL('search_complete()'), \
self.search_complete)
从绑定的过程可以看出来,一旦搜索完成,self.search_complete方法就会被执行:
def search_complete(self):
"""
搜索线程执行完毕之后触发该消息
清空表中内容,重新填充
如果存在异常则清空数据,显示消息
"""
if not self.tencent.has_exception:
self.song_table.fill_data(self.tencent.song_list)
else:
self.song_table.fill_data([])
txt = u'搜索错误'
message = ''
for e in self.tencent.exception_list:
message += e.message
QMessageBox.warning(self, txt, txt+u'。错误消息:'+ \
message, QMessageBox.Ok)
self.emit(SIGNAL('after_fill()'))
这里要注意就像前面我们说过的一样,在处理子线程内部的异常时,并不是直接在子线程中抛出,而是将之存在一个列表中,当搜索线程执行完毕之后,我们检查是否存在异常,如果存在,我们需要通知用户发生的具体异常是什么。这样操作的缺点是不利于调试,如果没有好的调试工具,就无法展示一些异常数据,也可以考虑增加异常消息日至文件,将所有的异常消息都保存在日至文件中。
搜索完成之后要处理的工作很简单,就是将搜索结果在列表中显示出来。这里需要一张表格,用于显示搜索到的所有音乐信息。
回忆一下我们在大多数其他播放器中的操作,在音乐列表上双击一首歌,就能立即播放这首歌。当我们选择了一些歌曲就能添加到播放列表或下载这些选择的歌曲。我们也要实现这些功能。
考虑到软件中多次使用到显示音乐信息的表格,而且主要用来显示音乐信息,且要支持选择行、双击、行背景颜色变化等功能。引出需要创建一个表格组件,从QTableWidget继承,用于实现我们想要的功能
这个新的表格类是SongTable在view代码包中,每当双击一个单元格都会发出cell_double_clicked()消息,同时SongTable的active_song_info属性会随之改变为正在双击的音乐信息,这样便于我们取得音乐信息,也便于我们调用底部面板上的播放功能播放音乐。同时还为这个表格设计了列头信息声明类,用于声明列的标题(title)、对应SongInfo的属性(filed)、宽度(width)、对齐方式(alignment)、是否显示为选择框(is_checkbox)等等一些辅助信息。
在表格中展示SongInfo信息列表时,是通过属性名称读取信息的,这很容易解释我们的SongInfo类为什么要从dict继承并使用元类,因为我们既需要能够通过点方式读取属性,也需要能够通过key名称的方式读取属性。
这个表格能帮助我们把音乐信息显示的很好,可以通过选择框选择想要的音乐,能够发出双击信号,这样就满足了我们的需求。
2. 底部面板
底部面板的主要作用是根据SongInfo播放音乐,需要用到AudioLoader和Player这两个类。另外这个面板还有一个重要的属性,就是song_list这是SongInfo列表,保存的是所有需要播放的音乐信息。在播放之前这个属性必须先设置好,但并非设置好这个属性就立即开始播放,必须通过设置song_index属性,来指定要播放的音乐,一旦设置了这个属性,装载器loader(AudioLoader)就知道应该状态哪首歌曲,装载过程中会发出一系列的信号,这些信号将会一步一步通知面板该做什么,比如:设置播放面板的信息、调用播放器播放音乐等。播放器player(Player)开始播放音乐之后也会发出一系列信号,通知面板该做什么,比如:不断修改播放进度和播放的当前时间信息等。
底部面板上的上一首、下一首按钮只是修改song_index属性的值,通知loader和player应该播放哪首歌曲。这里需要同时关注右边的三个按钮,他们是播放时切换按钮,有三种状态,分别是:列表循环、单曲循环、随机播放,这三种状态只是用于区分该如何取得下一首歌曲的索引进而修改song_index属性。
3. 主程序界面
主程序界面负责将所有的面板按照布局要求放在QApplication界面上,并为各面板的主要组件连接信号-槽,以便让各个面板组件协同工作。
如:当收到顶部搜索按钮的点击信号时,需要设置搜索面板的关键字、页码、每页显示数据量等参数,并调用搜索面板的搜索功能完成搜索。
当搜索面板的表格收到双击信号时,需要修改底部播放面板的播放列表为搜索列表,并设置播放索引为当前的歌曲索引,以便装载器能装载正确的歌曲,并交给播放器播放。
同样的,在其他列表上双击某个歌曲也是执行类似的操作,只是底部播放面板的播放列表将会切换到其他列表,同时播放索引也会被改变,以播放指定的歌曲。
王子和公主幸福地生活在一起
故事到这里就要结束了,通过这个小故事可以看到一款音乐播放软件的产出过程,虽然这个软件本身只能实现音乐搜索、播放、下载这些简单的功能,但是为了让代码比较容易维护,让软件能尽量运行的比较可靠我把各部分的功能分解的比较清楚,相对比较独立,耦合各部分功能的工作放在视图上处理,它们之间通过信号-槽的方式通讯,因为是小软件就没有设计独立的控制层。
这个软件本身还有很多不足之处,等我由时间在来慢慢修改维护吧,源码公开,如果你有时间也可以下载一份,按照你的想法调整。同时希望这个故事对已经有一些编程基础,想继续深入学习Python的童鞋起到抛砖引玉的作用。
这则故事所涉及到的所有源代码可以在GitHub上下载到:
https://github.com/waynezwf/q2music/