本片文章结束时第一个我们要实现歌曲的播放功能,第二个就是实现动态歌词界面,显示效果如下:
只有中文歌词或没有翻译歌词显示情况如下
上一篇文章我们介绍了播放器的ui界面的设计以及界面需要自定义才能够满足需求的控件.并完整的实现了他们,那么接下来就应该进入音乐播放的部分了.
因为咱们这篇文章要实现动态歌词的显示,所以博主下去参考了下网易云音乐的动态歌词显示的界面,发现他是从进度条位置向上展开的,所以我们这里调整下我们上一篇文章设置的主界面布局,不需要改上篇写的所有代码(当然如果你代码中涉及了bodyLeft与bodyRight可能就需要调整下),只需要在主界面的ui文件中拖动界面控件如下(增加一个布局器和Widget--容纳stackWidget的):
修改后的控件布局与从属关系如下:
也就是说我们的body部分将之前的bodyRight改为了bodyDown,然后将原来的stackWidget套了一个Widget.让这个Widget变为了bodyRight.因为head部分没有改变,上面就不贴了.OK,接下来我们正式进入音乐的解析与播放部分:
一.音乐管理
我们现在需要实现的是本地音乐的播放与加载,那么就必须要用户去点击界面中的+号去选择本地音乐.所以第一步我们就需要为+按钮添加槽函数,该槽函数就需要响应用户的操作打卡文件选择界面,同时过滤文件以及加载音乐:
void SekaiMusic::on_addLocal_clicked()
{
//点击后打开文件夹,我们默认设置为工作路径下的musics文件夹
QDir dir(QDir::currentPath());
dir.cdUp();
QString defaultPath = dir.path() + "/SekaiMusic/musics";
QFileDialog fileDialog(this,"选择歌曲文件",defaultPath);
//文件过滤器-因为我们不熟知所有文件类型,这里我们选择不过滤
QStringList mimeTypeFilters;
mimeTypeFilters << "application/octet-stream";
fileDialog.setMimeTypeFilters(mimeTypeFilters);
//设置默认打开窗口为打开文件窗口
fileDialog.setAcceptMode(QFileDialog::AcceptOpen);
//设置选择的文件数量和类型-允许用户选择多个文件
fileDialog.setFileMode(QFileDialog::ExistingFiles);
//弹出选择窗口
int success = fileDialog.exec();
if(success == 1)
{
//极大可能用户选择了文件,我们对歌曲文件进行处理
QList<QUrl> selectFiles = fileDialog.selectedUrls();
musicList.addMusicsByUrls(selectFiles);
//解析完毕之后需要让CommonPage去加载这些音乐
ui->stackedWidget->setCurrentIndex(4);//设置显示页面为本地下载页面
ui->localPage->reFresh(musicList);
}
}
第一个关键要点,此处我们需要设置fileDialog能够选择多个文件,无论是谁作为用户都不可能想去自己一个一个的去添加.
第二个关键要点,为什么我们这里不使用fileDialog自带的文件过滤器呢?这是因为考虑到用户可能通过改文件后缀传入错误的文件类型.所以我们需要使用MIME来检测文件的真实类型,简要的介绍下MIME:
在计算机领域,MIME(Multipurpose Internet Mail Extensions,多用途互联网邮件扩展)是一种标准化的数据格式标识系统,最初用于电子邮件扩展,后来被广泛应用于文件类型识别。与音乐播放器相关的MIME常见类型示例:
文件格式 | 常见 MIME 类型 |
---|---|
MP3 | audio/mpeg |
WAV | audio/wav |
OGG | audio/ogg |
FLAC | audio/flac |
AAC | audio/aac |
MIME类型(Multipurpose Internet Mail Extensions)是⼀种互联⽹标准,⽤于表⽰⽂档、⽂件或
字节流的
性质和格式。
语法:type/subType
⽐如:text/plain 表⽰⽂本⽂件 application/octet-stream表⽰通⽤的⼆进制数据流的MIME
类型
void setMimeTypeFilters(const QStringList &filters)
⽰例:
QStringList mimeTypeFilters;
mimeTypeFilters << "image/jpeg" // will show "JPEG image (*.jpeg *.jpg *.jpe)
<< "image/png" // will show "PNG image (*.png)"
<< "application/octet-stream"; // will show "All files (*)"
QFileDialog dialog(this);
dialog.setMimeTypeFilters(mimeTypeFilters);
可以看到,我们上面设置的MIME类型并没有对所有文件进行过滤-"application/octet-stream"; // will show "All files (*)";因为我们的一个完整的歌曲文件是分为音乐文件,图片以及Lrc歌词的.所以这里我们直接对用户选择的所有文件进行接收.然后在对应类中进行过滤.
1.1自定义MusicList类
那么此时问题来了,这些导入的音乐我们应该怎么管理起来呢?当然是先描述,后组织.这么多的音乐文件当然首先需要有一个音乐列表对用户选择的文件进行过滤并将文件Url传给音乐对象,让音乐对象去对这个文件的Url进行解析,所以我们需要先自定义一个MusicList的类:
typedef QVector<Music>::iterator iterator;
class MusicList
{
public:
MusicList();
void addMusicsByUrls(const QList<QUrl>& urlLists);//用来处理上层传入的Urls
iterator begin();//直接把QVector的迭代器拿过来便于我们后面使用范围for
iterator end();
private:
//存储的音乐列表
QVector<Music> musicList;
};
显然我们此时第一步就是把刚刚获取的Urls进行过滤并传给下一层,怎么使用MIME对文件进行过滤呢,我们需要获取文件真实类型可以使用下面的方法:
QMimeDatabase mimeDB;
QMimeType mimeType = mimeDB.mimeTypeForFile("song.mp3");
qDebug() << mimeType.name(); // 输出: "audio/mpeg"
所以就不难写出addMusicsByUrls:
void MusicList::addMusicsByUrls(const QList<QUrl> &urlLists)
{
QMimeDatabase mimeDatebase;
for(auto& url : urlLists)
{
QMimeType mimeType = mimeDatebase.mimeTypeForUrl(url);
const QString& fileType = mimeType.name();
//过滤实际类型为歌曲相关的类型文件
if(fileType == "audio/mpeg" || fileType == "audio/flac" || fileType == "audio/wav")
{
musicList.push_back(Music(url));
}
}
}
1.2自定义Music类
好的,过滤完文件传入下一层,那么下一层应该怎么解析这个Url呢?当然还是先描述,再组织.首先我们肯定需要有一个自定义的Music类,那么Music类中应该记录歌曲的哪些信息呢?如下:
class Music
{
public:
Music();
Music(const QUrl& musicUrl);
//set方法
void setIsLike(bool isLike);
void setIsHistory(bool isHistory);
void setMusicName(const QString& musicName);
void setSingerName(const QString& singerName);
void setAlbumName(const QString& albumName);
void setDuration(const qint64 duration);
void setMusicUrl(const QUrl& url);
void setMusicId(const QString& musicId);
//get方法
bool getIsLike() const;
bool getIsHistory() const;
const QString& getMusicName() const;
const QString& getSingerName() const;
const QString& getAlbumName() const;
qint64 getDuration() const;
const QUrl& getMusicUrl() const;
const QString& getMusicId() const;
//初始化解析
void parseMediaMetaData();
private:
bool isLike; // 标记⾳乐是否为我喜欢
bool isHistory; // 标记⾳乐是否播放过
// ⾳乐的基本信息有:歌曲名称、歌⼿名称、专辑名称、总时⻓
QString musicName;
QString singerName;
QString albumName;
qint64 duration; // ⾳乐的持续时⻓,即播放总的时⻓
// 为了标记歌曲的唯⼀性,给歌曲设置id
// 磁盘上的歌曲⽂件经常删除或者修改位置,导致播放时找不到⽂件,或者重复添加
// 此处⽤musicId来维护播放列表中⾳乐的唯⼀性
QString musicId;
QUrl musicUrl; // ⾳乐在磁盘中的位置
};
注意到这些属性中,有一个属性比较让人疑惑,那就是musicId.有人说,我们直接像数据库那样使用一个自增的静态成员变量,每创建一个Music对象就对其进行++.这样不就可以确保文件的唯一性了吗.
乍一听好像真的可行,但是如果用户对同一首歌曲文件进行重复选择了呢.这样就不行了,所以此时我们就需要使用Uuid.简单介绍下:
UUID 是一个 128 位(16 字节)的数字,通常表示为 32 个十六进制数字,以连字符分隔的五组显示,格式为 8-4-4-4-12,共 36 个字符。比如:
123e4567-e89b-12d3-a456-426655440000
同时Uuid具有的特性如下:
唯一性:理论上在不同时间、不同机器上生成的 UUID 不会重复
标准化:遵循 RFC 4122 标准
无需中央注册:可以本地生成而不需要协调
而Qt中也为我们提供了生成Uuid的方法:
QUuid::createUuid().toString()
所以我们此处就需要使用该方法生成音乐类的唯一Uuid:
Music::Music(const QUrl& MusicUrl)
:isLike(false),
isHistory(false),
musicUrl(MusicUrl)
{
musicId = QUuid::createUuid().toString();
}
对于set与get一系列接口的写法我们不再给出.接下来就是对上层传入的Url进行解析了,解析当然就需要使用MediaPlay类了,具体实现方法如下:
void Music::parseMediaMetaData()
{
QMediaPlayer mediaPlayer;
//解析歌手等一系列信息
mediaPlayer.setMedia(musicUrl);
//因为前者会立即返回,所以这里我们必须要死循环之后才能进行解析工作
while(!mediaPlayer.isMetaDataAvailable())
{
//确保主界面正常运行
QCoreApplication::processEvents();
}
//再次验证确保解析工作结束
if(mediaPlayer.isMetaDataAvailable())
{
//进行音乐标题,作者,专辑,总时长的解析工作
this->musicName = mediaPlayer.metaData("Title").toString();
this->singerName = mediaPlayer.metaData("Author").toString();
this->albumName = mediaPlayer.metaData("AlbumTitle").toString();
this->duration = mediaPlayer.metaData("Duration").toLongLong();
//处理meta解析结果为空的情况
QString fileName = musicUrl.fileName();
int index = fileName.indexOf("-");
if(musicName.isEmpty())
{
//从文件名中获取
//第一种情况文件名->Empurple - はるまきごはん_初音ミク.flac
if(index != -1)
{
musicName = fileName.mid(0,index - 1);//-1是为了把空格拿掉
}
else
{
//第二种情况文件名->Empurple.flac
musicName = fileName.mid(0,fileName.indexOf("."));//此时没有空格就不需要-1了
}
}
if(singerName.isEmpty())
{
//也是与上面一样分两种情况
if(index != -1)
{
QString name = fileName.mid(index + 1,fileName.indexOf(".") - index - 1);
//此时name会多取出一个前导空格,我们需要拿掉
singerName = name.trimmed();
}
else
{
singerName = "未知歌手";
}
}
if(albumName.isEmpty())
{
albumName = musicName;
}
}
}
第一个问题,mediaplay的setMedia方法可以对传入的文件的Url路径进行解析,但是传入之后他会立即返回.没有解析完我们总不能从中提取文件的元数据吧.那么此时我们就需要死循环等待,但是死循环等待难度让整个程序都卡死在这里吗,所以我们就需要在死循环的过程中去调用主窗口的事件循环,几种常见的事件循环方式区别如下:
方法 | 适用场景 | 备注 |
---|---|---|
QEventLoop |
阻塞等待某个条件 | 适用于网络请求、模态对话框 |
processEvents() |
临时处理事件 | 适用于防止界面卡死 |
线程事件循环 | 非 GUI 线程处理事件 | 需继承 QThread |
嵌套事件循环 | 复杂交互逻辑 | 慎用,易导致代码混乱 |
显然此处processEvents最合适,我们就使用processEvents.
那么第二个问题,如果歌曲文件本身就是盗版的,所以它的元数据就不完整甚至没有,所以我们就需要对解析的元数据为空的情况进行处理,具体处理方式见上方代码.(记得在构造函数中调用这个函数)
1.3更新歌曲信息到CommonPage页面
因为此时我们仅仅是处理本地音乐,所以我们解析完毕之后仅需要对本地下载界面进行更新,也就是调用其reFresh方法.但后续我们肯定是要对最近播放界面以及我喜欢界面进行更新的,所以我们需要在Common类中添加一个枚举常量用于区分这三个CommonPage的类型:
enum PageType{
LIKE_PAGE,//喜欢的音乐
LOCAL_PAGE,//本地音乐
RECENT_PAGE//最近播放的音乐
};
在主界面的initUi函数中通过CommonPage提供的方法设置其PageType类型:
//设置页面类型
ui->likePage->setPageType(PageType::LIKE_PAGE);
ui->localPage->setPageType(PageType::LOCAL_PAGE);
ui->recentPage->setPageType(PageType::RECENT_PAGE);
reFresh函数的实现:
void CommonPage::addItemBox(const Music &music)
{
//添加歌曲条目
ListItemBox* listItemBox = new ListItemBox(this);
QListWidgetItem* listWidgetItem = new QListWidgetItem(ui->pageMusicList);
//设置信息
listItemBox->setMusicName(music.getMusicName());
listItemBox->setSingerName(music.getSingerName());
listItemBox->setAlbumName(music.getAlbumName());
listItemBox->setLikeBtnIcon(music.getIsLike());//记得带上
//需要指定推荐显示尺寸,否则会遵从默认设置,不一定是我们想要的效果
listWidgetItem->setSizeHint(QSize(ui->pageMusicList->width(), 45));
ui->pageMusicList->setItemWidget(listWidgetItem, listItemBox);
//添加歌曲的Uuid到记录中
musicsOfUuids.push_back(music.getMusicId());
}
void CommonPage::reFresh(MusicList &musicList)
{
for(auto& music : musicList)
{
switch (pageType) {
case LIKE_PAGE:
if(music.getIsLike())
{
addItemBox(music);
}
break;
case LOCAL_PAGE:
addItemBox(music);
break;
case RECENT_PAGE:
if(music.getIsHistory())
{
addItemBox(music);
}
break;
default:
qDebug() << "暂不支持";
}
}
}
考虑到一般listWidgetItem长度不会超过界面原有的宽度,所以我们拿掉 CommonPage中ListWidget中的水平滑动条:
CommonPage::CommonPage(QWidget *parent) :
QWidget(parent),
ui(new Ui::CommonPage)
{
ui->setupUi(this);
// 不要⽔平滚动条
ui->pageMusicList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
}
此时我们便可以去尝试加载音乐文件到本地下载的CommonPage中了.(refresh开头的清除和repaint后面我们再说为什么,这里留个伏笔).
1.4处理音乐喜欢状态改变事件
如果用户点击了我喜欢按钮,第一事件知道用户按下按钮的控件一定是listWidgetItem,那么它能够更新页面状态吗,当然不可以,音乐的信息什么都没记录,怎么处理,所以它需要向上一层传递一个自定义信号让上层去处理:
void ListItemBox::on_likeBtn_clicked()
{
isLike = !isLike;
setLikeBtnIcon(isLike);
//此时需要发送信号让CommonPage知道该音乐收藏状况发生改变
emit musicLikeChange(isLike);
}
//commonPage类中的addItem添加:
//绑定listItemBox中的信号,并向主页面继续传递
connect(listItemBox,&ListItemBox::musicLikeChange,this,[=](bool isLike){
//commonPage也无法修改音乐的喜好状态,所以继续向上传递
emit this->itemLikeChange(isLike,music.getMusicId());
});
传到CommonPage中能够处理吗,我只记录了我们当前页面保存的音乐的MusicId,一个一个去对比去查,哪里来的musicList.你说上层传过来,好像可以,但是为什么我不包装个信号继续向上一层传递呢,这样不就省去了字符串对比所消耗的性能了.所以我们这里选择继续向上传递,也就明白了为什么上面的代码中我们又发射了一个信号.
//主界面的connectSignalsAndSlots()添加
//为CommonPage界面的喜欢信号绑定处理函数
connect(ui->likePage,&CommonPage::itemLikeChange,this,&SekaiMusic::musicLikeChange);
connect(ui->localPage,&CommonPage::itemLikeChange,this,&SekaiMusic::musicLikeChange);
connect(ui->recentPage,&CommonPage::itemLikeChange,this,&SekaiMusic::musicLikeChange);
//musicLikeChange:
void SekaiMusic::musicLikeChange(bool isLike, QString musicId)
{
//改变音乐喜欢状态,更新所有Page页面
auto it = musicList.findMusicByMusicId(musicId);
if(it != musicList.end())
{
//说明查找到目标音乐了更新音乐状态
it->setIsLike(isLike);
//同时更新列表状态
ui->localPage->reFresh(musicList);
ui->likePage->reFresh(musicList);
ui->recentPage->reFresh(musicList);
}
}
为什么我们这里需要对三个页面都进行更新呢,想一下,这个我们喜欢按钮可能在这三个界面中任意一个界面被按下.当然我们可以通过增加逻辑以处理这种情况,但实际上,即便是连接网络的音乐播放器,用户的我喜欢数量和本地下载数量一般都超不过1000首.最近播放也只会保存有限制的数据.所以没必要因为这里的性能损耗去增加代码的复杂度.这里我们直接对三个界面都进行更新即可.
1.5处理listWidget中歌曲重复的问题
当我们按下喜欢按钮时,会发现此时出现了歌曲重复的情况,为什么,因为上一次加载到页面中的歌曲没有被清除,所以这里我们就需要在reFreash中去清理一下:
//清空原有的歌曲清单与记录的音乐Uuid,目前是为了处理收藏事件
musicsOfUuids.clear();
ui->pageMusicList->clear();
而此时页面当然要及时刷新,我们对比下Qt中的两种刷新方式:
函数 | 触发方式 | 执行时机 | 是否阻塞 |
---|---|---|---|
repaint() |
强制立即重绘 | 直接调用 paintEvent() |
同步阻塞 |
update() |
请求重绘(加入事件队列) | 下次事件循环处理 | 异步非阻塞 |
显然我们要选择repaint立即进行重绘,不然无论是谁看页面缓冲一会才刷新过来,都会很难受的.
二.音乐播放控制
2.1初始化播放列表
因为我们每个页面的歌曲一般都不只有一首歌曲,所以我们使用QMediaPlayerList在每个页面进行reFresh之后进行歌曲的加载:
//注意player与playList均为主界面类的成员变量
void SekaiMusic::initPlayer()
{
//添加媒体播放类
player = new QMediaPlayer(this);
//初始化媒体播放列表
playList = new QMediaPlaylist(this);
//设置播放列表
player->setPlaylist(playList);
//设置默认音量
player->setVolume(20);
//默认设置为列表循环播放
playList->setPlaybackMode(QMediaPlaylist::Loop);
因为刚开始的时候歌曲是从本地下载导入的,所以第一次reFresh发生在本地下载页面中,所以我们需要增加以下逻辑:
//主界面类中的on_addLocal_clicked增加
ui->localPage->reFresh(musicList);
ui->localPage->addMusicOfPlayList(musicList,playList);//添加到播放列表
//Commpage类中新增方法-直接把Url加载进去就行
void CommonPage::addMusicOfPlayList(MusicList &musiclist, QMediaPlaylist *playList)
{
for(auto& music : musiclist)
{
switch (pageType) {
case LIKE_PAGE:
if(music.getIsLike())
{
playList->addMedia(music.getMusicUrl());
}
break;
case LOCAL_PAGE:
playList->addMedia(music.getMusicUrl());
break;
case RECENT_PAGE:
if(music.getIsHistory())
{
playList->addMedia(music.getMusicUrl());
}
break;
default:
qDebug() << "暂不支持";
}
}
}
2.2单击play按钮播放歌曲
因为此时我们的歌单中没有音乐就不说了,当用户进行本地音乐的导入之后,那么必定播放列表中是有歌曲的.这里我们简单些,直接通过单击play按钮切换player的开始与暂停状态,也就是说用户未指定的情况下默认播放列表中的第一首歌曲(如果存在):
void SekaiMusic::on_Play_clicked()
{
if(player->state() == QMediaPlayer::StoppedState || player->state() == QMediaPlayer::PausedState)
{
player->play();
}
else
{
player->pause();
}
}
当然.图标也是需要切换的,但是因为player在播放状态改变时会发射一个信号:QMediaPlaylist::playbackModeChanged,它的参数PlayBackMode有三种枚举类型:
状态枚举名称 | 枚举值 | 说明 |
QMediaPlayer::StoppedState | 0 | 播放停⽌状态 |
QMediaPlayer::PlayingState | 1 | 播放状态 |
QMediaPlayer::PausedState | 2 | 播放暂停状态 |
所以我们可以有如下的逻辑去切换播放按钮的图片:
void SekaiMusic::playStateChanged(QMediaPlayer::State state)
{
if(state == QMediaPlayer::StoppedState || state == QMediaPlayer::PausedState)
{
ui->Play->setIcon(QIcon(":/images/play3.png"));
}
else
{
ui->Play->setIcon(QIcon(":/images/play_on.png"));
}
}
//当然记得在你的主界面类的连接管理函数中连接信号槽
2.3上一首歌曲与下一首歌曲的切换
player类中直接就提供了prev与next的方法,所以我们实现起来非常简单,这里就不多赘述了:
void SekaiMusic::on_playPrev_clicked()
{
//上一曲
playList->previous();
}
void SekaiMusic::on_playNext_clicked()
{
//下一曲
playList->next();
}
2.4播放模式的改变
playList的播放模式有五种情况,我们只用三种就可以满足:
枚举状态名称 | 枚举值 | 说明 |
QMediaPlaylist::CurrentItemOnce | 0 | 单词播放 |
QMediaPlaylist::CurrentItemInLoop | 1 | 单曲循环 |
QMediaPlaylist::Sequential | 2 | 从当前选中位置开始,顺序播放 |
QMediaPlaylist::Loop | 3 | 列表中⽂件循环播放 |
QMediaPlaylist::Random | 4 | 列表中⽂件随机播放 |
而且当playList的播放模式改变时会发射信号:playbackModeChanged(QMediaPlaylist::PlaybackMode mode);,那么就简单了,我们以顺序播放->随机播放->单曲循环按照这个循环去写逻辑,然后按钮图标的设置通过信号绑定我们自定义的槽函数去改变即可:
void SekaiMusic::on_playMode_clicked()
{
//切换播放模式
if(playList->playbackMode() == QMediaPlaylist::PlaybackMode::Loop)
{
playList->setPlaybackMode(QMediaPlaylist::PlaybackMode::Random);
}
else if(playList->playbackMode() == QMediaPlaylist::PlaybackMode::Random)
{
playList->setPlaybackMode(QMediaPlaylist::PlaybackMode::CurrentItemInLoop);
}
else if(playList->playbackMode() == QMediaPlaylist::PlaybackMode::CurrentItemInLoop)
{
playList->setPlaybackMode(QMediaPlaylist::PlaybackMode::Loop);
}
}
//下面函数记得在主界面连接管理函数中进行信号槽绑定
void SekaiMusic::playModeChanged(QMediaPlaylist::PlaybackMode playMode)
{
if(playMode == QMediaPlaylist::PlaybackMode::Loop)
{
ui->playMode->setIcon(QIcon(":/images/list_play.png"));
ui->playMode->setToolTip("列表循环");
}
else if(playMode == QMediaPlaylist::PlaybackMode::Random)
{
ui->playMode->setIcon(QIcon(":/images/shuffle_2.png"));
ui->playMode->setToolTip("随机播放");
}
else if(playMode == QMediaPlaylist::PlaybackMode::CurrentItemInLoop)
{
ui->playMode->setIcon(QIcon(":/images/single_play.png"));
ui->playMode->setToolTip("单曲循环");
}
else
{
qDebug() << "暂不支持";
}
}
2.5播放所有
因为我们的播放所有的按钮是在CommonPage中的,所以我们当然无法在CommonPage页面改变当前的歌曲播放,那么我们就需要发射自定义信号来提醒主界面去播放全部,但是主界面怎么知道你是哪一个界面的播放全部呢,所以我们发射信号时要将自己页面的指针一并携带出去:
void CommonPage::on_playAllBtn_clicked()
{
//发射一个播放全部的信号给主界面
emit playAll(this);
}
主界面则需要保存这个指针,记为CurrentPage.为什么,后面有大用处,连接上面信号的槽函数实现如下:
//连接信号的槽函数:
void SekaiMusic::playAllMusics(CommonPage *page)
{
playList->setPlaybackMode(QMediaPlaylist::PlaybackMode::Loop);//设置为列表循环
//从0下标位置播放歌曲
playMusicOfIndex(page,0);
}
//上面函数通过我来播放歌曲
void SekaiMusic::playMusicOfIndex(CommonPage *page, int index)
{
playList->clear();//清空原有播放列表
currentPage = page;//设置当前播放页面
currentPage->addMusicOfPlayList(musicList,playList);//加载当前页面的歌曲到playlist中
playList->setCurrentIndex(index);//从给定的下标位置开始播放
player->play();
}
2.6双击CommonPage中的列表项播放歌曲
因为用户切换页面时我们并没有重新加载歌曲,也没必要.我们只在用户播放歌曲时按照当前页面去加入即可,所以播放能够改变歌曲列表的动作只有两个,一个就是2.5中的情况,一个就是当前的双击情况.
显然此时我们需要借助2.5中的playMusicOfIndex来作为接收下层双击信号的槽函数,但是这个双击是发生在我们自定义控件ListWidgetItem中的,这个控件隶属于CommonPage,所以我们实现逻辑和2.5差不多如下:
void CommonPage::on_pageMusicList_doubleClicked(const QModelIndex &index)
{
//当界面中的歌曲被双击时,发送信号给上层,让上层相应并播放对应歌曲
emit doubleCliked(this,index.row());
}
//上面信号在主界面中绑定的槽函数为2.5中按照index位置播放音乐的成员函数
//本身向playList列表中插入音乐的顺序与插入commonPage中的ListWidget的顺序是一致的,所以可以这样干
2.7最近播放同步
在playList中播放的歌曲发生改变时,会发射QMediaPlaylist::currentIndexChanged信号,该信号会带index参数,表⽰现在是媒体播放列表中的index歌曲被播放.因为我们上面说了,插入页面中的音乐顺序与插入播放列表中的音乐顺序是一致的,所以我们可以直接使用这个index通过commonPage去找对应歌曲的Uuid.
那么我们怎么知道当前播放的是那个页面中的歌曲呢,这就是为什么上面我们要记录currentPage的原因,有了这个currentPage,那我们也就很容易就能实现最近播放同步的功能了:
void SekaiMusic::addRecentMusic(int index)
{
currentIndex = index;
currentPage->setCurrentIndex(index);
//从currentPage中找到其播放列表对应的下标的歌曲
const QString& tarMusicId = currentPage->findMusicByIndex(index);
auto it = musicList.findMusicByMusicId(tarMusicId);
if(it != musicList.end())
{
//说明找到目标歌曲了
it->setIsHistory(true);
//此时顺带把界面中能更新的信息进行更新-更新歌手和歌曲名称
ui->musicName->setText(it->getMusicName());
ui->musicSinger->setText(it->getSingerName());
//找到当前路径下的同名图片-在music类中添加获取图片方法,并直接设置图片
ui->musicCover->setPixmap(QPixmap(it->getImageFilePath()));
ui->musicCover->setScaledContents(true);//设置缩放适应窗口大小
}
//刷新最近播放页面-如果是最近播放界面便不再刷新
if(currentPage != ui->recentPage)
ui->recentPage->reFresh(musicList);
}
//Tips:我们设置歌曲图片可以通过元数据解析来获取:
// QVariant coverImage = player->metaData("ThumbnailImage");
// if(coverImage.isValid())
// {
// qDebug() << "有缩略封面图";
// QImage image = coverImage.value<QImage>();
// ui->musicCover->setPixmap(QPixmap::fromImage(image));
// ui->musicCover->setScaledContents(true);//适应窗口大小
// }
//但是一般使用本地播放器的音乐都是想听高质量的,不想去花钱的,所以从网上下载下来的歌曲很难自带有//元数据缩略图,当然读者也可以加上,我这里就只使用本地配好的歌曲图片了
//CommonPage中添加:
const QString &CommonPage::findMusicByIndex(int index)
{
//因为插入列表中的音乐顺序与加入媒体源中的音乐顺序一致
if(index >= musicsOfUuids.size())
{
qDebug() << "未找到目标音乐";
return "";
}
return musicsOfUuids[index];
}
//music中添加(歌曲文件,lrc文件,图片文件均为同名,只是后缀不同)
QString Music::getImageFilePath() const
{
//要求图片必须是jpg格式的-建议图片长宽像素相等s
QString path = musicUrl.toLocalFile();
path.replace(".mp3", ".jpg");
path.replace(".flac", ".jpg");
path.replace(".mpga", ".jpg");
return path;
}
2.8音量设置
QMediaPlayer中⾳量相关操作如下:
int volume; // 标记⾳量⼤⼩,值在0~100之间
int volume()const; // 获取⾳量⼤⼩
void setVolume(int); // 槽函数:设置⾳量⼤⼩
bool muted; // 是否静⾳,true为静⾳,false为⾮静⾳
bool isMuted()const; // 获取静⾳状态
bool setMuted(bool muted); // 槽函数:设置静⾳或⾮静⾳
我们之前在主界面中给volumn按钮设置的功能是,鼠标进入volumn中显示调节窗口,单击该按钮会切换静音与非静音状态,所以我们先来设置静音与非静音状态:
2.8.1静音与非静音模式的切换
void SekaiMusic::on_volume_clicked()
{
//改变静音状态
if(!volumeMute)
{
//通过volumeTool提供的函数来恢复音量
//切换默认图片
ui->volume->setStyleSheet("#volume{background-image: url(:/images/volumn.png);}");
}
else
{
//通过volumeTool提供的函数设置音量为0
//切换静音图片
ui->volume->setStyleSheet("#volume{background-image: url(:/images/silent.png);}");
}
//切换模式
player->setMuted(this->volumeMute);
this->volumeMute = !(this->volumeMute);
}
但是这里会有一个问题,我们发现鼠标一旦拖入volumn中再怎么点击也无法切换静音与非静音状态.博主下去查了一下是因为我们设置volumnTool窗口为Qt::Popup
属性的原因:
还记得我们弹窗分为模态与非模态窗口吗,模态窗口显示时无法与主界面交互,但后者可以.虽然Popup窗口本身不是严格意义上的模态窗口,但它的行为在某些方面类似于模态窗口,比如Qt::Popup
窗口会自动获取键盘和鼠标焦点(类似模态窗口)以及阻止其他窗口接收输入事件(除非手动配置).
所以解决办法就是设置弹窗不会去强制的获取焦点,也就是Qt::Popup组合 Qt::WindowDoesNotBlockFocus
去使用.或者设置Qt::NonModal并手动置顶,但是还有一种更好的方案就是单单使用Qt::Tool,我们来看下他们之间的对比:
方法 | 适用场景 | 是否阻塞主窗口 | 是否支持滑块交互 |
---|---|---|---|
Qt::Tool |
可交互弹窗(如音量调节) | ❌ 否 | ✔️ 是 |
Qt::Popup |
短暂提示(默认会抢焦点) | ✔️ 是 | ❌ 否 |
Qt::NonModal |
通用非模态窗口(需手动置顶) | ❌ 否 | ✔️ 是 |
所以这里我们把Qt::Popup改为Qt::Tool即可正常的去切换静音与非静音状态了.
2.8.2通过滑块进行音量调节
滑块是在volumnTool中的,所以自然我们是要发送自定义信号然后在主界面中绑定player的setVolume来改变音量大小的.但是我们应该在什么时候去发射这个自定义信号呢?
一般情况下,我们都是左键点击音槽的某个位置去改变音量或拖动滑块改变音量.这两种情况中一共涉及左键的三种操作:鼠标按下,鼠标释放以及鼠标拖拽.当我们通过第一种方式改变音量时,一定是左键按下,然后释放这两个步骤,如果是第二种就是左键按下,拖动以及释放.
我们发现,最后都会是释放,所以释放时是需要发射自定义信号的.左键按下不需要,只需要改变滑块位置即可.但是拖拽时我们都知道,他音量是持续改变的,所以拖拽时也需要发射自定义信号去改变音量.所以逻辑理清了,我们就可以来着手实现了,实现的方式有两种,一种是事件过滤器,另一种是重写相关函数,我们这里用第一种,下面进度条我们再用第二种:
bool VolumeTool::eventFilter(QObject *obj, QEvent *event)
{
if(obj == ui->volumeWidget)
{
//移动和释放时都需要发送信号
if(event->type() == QEvent::MouseMove)
{
setVolumn();
emit volumeChanged(volumn);
}
else if(event->type() == QEvent::MouseButtonPress)
{
//仅修改按钮位置和滚动条位置即可
setVolumn();
}
else if(event->type() == QEvent::MouseButtonRelease)
{
//直接发送信号
emit volumeChanged(volumn);
}
}
return QWidget::eventFilter(obj,event);
}
void VolumeTool::setVolumn()
{
//转化鼠标的全局坐标为volumnWidget内部的局部坐标
int height = ui->volumeWidget->mapFromGlobal(QCursor::pos()).y();
//限定调节高度的大小
height = height < ui->inSlider->y() ? ui->inSlider->y() : height;
height = height > ui->inSlider->y() + ui->inSlider->height() ? ui->inSlider->y() + ui->inSlider->height() : height;
//调节滑条和按钮位置
QRect outGe = ui->outSlider->geometry();
ui->outSlider->setGeometry(outGe.x(),height,outGe.width(),ui->inSlider->height() + ui->inSlider->y() - height);//注意此处多减了,需要加上
ui->sliderBtn->move(ui->sliderBtn->x(),ui->outSlider->y() - ui->sliderBtn->height()/2);
//调节音量大小
volumn = (ui->outSlider->height() / (float)ui->inSlider->height()) * 100;
//设置音量显示文本
ui->volumeRatio->setText(QString::number(volumn) + "%");
}
2.9当前播放事件与总播放事件的更新
歌曲总时间在Music对象中可以获取,也可以让player调⽤⾃⼰的duration()⽅法获取。但是这两种获取歌曲总时间的调⽤时机不太好确定。我们期望的是当歌曲发⽣切换时,获取到正在播放歌曲的总时⻓。
当播放源的持续时⻓发⽣改变时,QMediaPlayer会触发durationChanged信号,该信号中提供了将要播放媒体的总时⻓。
// duration为将要播放媒体的总时⻓
void QMediaPlayer::durationChanged(qint64 duration);
所以我们就可以去给他关联一个槽函数就可以获取更新总时长了:
void SekaiMusic::onDurationChanged(qint64 duration)
{
//改变界面中显示的歌曲总时长-使用成员变量记录,方便后面进度条的同步调整
currentMusicDuration = duration;
QString totalTime = QString("%1:%2").arg(duration/1000/60,2,10,QChar('0')).arg(duration/1000%60,2,10,QChar('0'));
ui->totalTime->setText(totalTime);
}
那么当前播放时间的改变自然也会有一个信号:
//QMediaPlayer播放时间改变时发射下面信号-position: 媒体持续播放时间
void positionChanged(qint64 position);
我们再给它关联一个槽函数即可:
void SekaiMusic::onPositionChanged(qint64 duration)
{
//改变界面中显示的播放进度
QString positionTime = QString("%1:%2").arg(duration/1000/60,2,10,QChar('0')).arg(duration/1000%60,2,10,QChar('0'));
ui->currentTime->setText(positionTime);
}
2.10进度条功能的实现
2.10.1进度条同步当前播放时间
这个很简单,直接将当前播放的进度与总播放时长之比传给progressBar即可,让他自己去调节自己的outLine长度就行:
//onPositionChanged中添加
ui->progressBar->setProgress(duration / (float)currentMusicDuration);
//musicSilder中添加
void MusicSlider::setProgress(float percentage)
{
QRect outGe = ui->outLine->geometry();
int width = (int)(maxWidth * percentage);
ui->outLine->setGeometry(outGe.x(),outGe.y(),width,outGe.height());
}
2.10.2当前播放时间同步进度条位置
这里与我们上面实现音量调节的方式大致类似,所以逻辑大家可以自行思考下,我们这里换重写函数的方式实现同步功能:
void MusicSlider::mouseMoveEvent(QMouseEvent *event)
{
//仅响应左键按下与释放其他均不响应
if(event->buttons() == Qt::LeftButton)
{
//仅鼠标在滑块区域内响应拖拽事件
QPoint localCursorPos = mapFromGlobal(QCursor::pos());
if(this->rect().contains(localCursorPos))
{
//与音量调节一样
int width = mapFromGlobal(QCursor::pos()).x();
width = width < ui->outLine->x() ? ui->outLine->x() : width;
width = width > maxWidth ? maxWidth : width;
float progress = width / (float)maxWidth;
setProgress(progress);
emit progressChanged(progress);
}
}
}
void MusicSlider::mousePressEvent(QMouseEvent *event)
{
if(event->button() == Qt::LeftButton)
{
//与音量调节一样
int width = mapFromGlobal(QCursor::pos()).x();
width = width < ui->outLine->x() ? ui->outLine->x() : width;
width = width > maxWidth ? maxWidth : width;
setProgress(width / (float)maxWidth);
}
}
void MusicSlider::mouseReleaseEvent(QMouseEvent *event)
{
(void)event;
//此时直接发送信号
if(event->button() == Qt::LeftButton)
emit progressChanged(ui->outLine->width() / (float)maxWidth);
}
三.Lrc动态歌词显示
这部分我们只提供双语歌词的lrc解析方式,其实很简单.但是因为网上的lrc歌词又不一定都是按照标准格式来写的,所以又很难.为了不那么麻烦,我们本文只实现两种格式的简单的lrc歌词解析方式,歌词格式如下:(lRC歌词可以通过MusicLyricApp这个软件来获取,可以改变合并格式非常方便,纯中文歌词还好说,但是有翻译时部分Lrc歌词是歌词和翻译分开的,所以可以使用这个软件的合并功能,合并符设置为(翻译:就可以直接套用本文的解析方式了,适用大部分lrc歌词情况了-当然至少博主自己用时可以通过这个方式解决95%的lrc歌词不规范问题了,剩下的就只能自己调了悲)
或者是这样的:
推荐软件界面设置如下:
就可以直接设置Lrc歌词文件格式为上面的第二种情况了.
只有中文歌词的情况被合并到上面两种情况中了,好的接下来我们先把lrc歌词界面的布局给搭出来:
控件布局如下:
3.1为lrc歌词界面添加弹出与隐藏动画效果
因为之前我们实现过推荐界面的矩形动画,所以这里其实很简单了,我们的目标是让lrc窗口从进度条位置弹出,然后按下下拉按钮后向下滑动直到歌词界面顶部到进度条位置之后消失,所以我们可以得出实现代码如下:
//SekaiMusic::initPlayer中新增加
//初始化Lrc歌词界面-lrcPage为我们自定义设计师类中的LrcPage*类型,而lrcPageAnimation是QPropertyAnimation*类型的,二者均为主界面类的成员变量
lrcPage = new LrcPage(ui->background);
lrcPage->setGeometry(0,0,lrcPage->width() + 1,lrcPage->height() + 3);
lrcPage->hide();//默认隐藏
//设置Lrc歌词界面上滑动画
lrcPageAnimation = new QPropertyAnimation(lrcPage,"geometry");
lrcPageAnimation->setDuration(300);
lrcPageAnimation->setStartValue(QRect(0,527,lrcPage->width(),lrcPage->height() + 3));
lrcPageAnimation->setEndValue(QRect(0,0,lrcPage->width(),lrcPage->height() + 3));
lrcPageAnimation->setLoopCount(1);
点击显示歌词按钮时:
void SekaiMusic::on_lrcWord_clicked()
{
//只有当控件不可见时才响应
if(!lrcPage->isVisible())
{
//点击时弹出Lrc歌词界面
lrcPage->show();
lrcPageAnimation->start();
}
}
初始化下滑动画-在LrcPage类中:
//LrcPage的构造函数中
//设置窗口背景透明
this->setAttribute(Qt::WA_TranslucentBackground);
//设置Lrc歌词界面上滑动画
lrcAnimation = new QPropertyAnimation(this,"geometry");
lrcAnimation->setDuration(300);
lrcAnimation->setStartValue(QRect(0,0,width(),height() + 3));
lrcAnimation->setEndValue(QRect(0,527,width(),height() + 3));
lrcAnimation->setLoopCount(1);
//动画结束时隐藏界面
connect(lrcAnimation,&QPropertyAnimation::finished,this,[=](){
hide();
});
点击下拉按钮启动下拉动画:
void LrcPage::on_pullDownBtn_clicked()
{
lrcAnimation->start();
}
如果动画效果出现重影或程序异常退出,可以把我们之前为主窗口添加阴影部分的函数调整为一下设置:
//initUi中
//添加阴影效果
QGraphicsDropShadowEffect* shadowEffect = new QGraphicsDropShadowEffect(this);
shadowEffect->setOffset(0,0); // 阴影偏移值
shadowEffect->setColor("#000000"); // 阴影颜色:黑色
shadowEffect->setBlurRadius(10); // 阴影模糊半径-弹出歌词界面增加之后需要设置为10,不然程序无法正常退出
this->setGraphicsEffect(shadowEffect);
3.2Lrc歌词的显示以及同步
我们之前不是当歌曲播放时会刷新最近播放页面的函数吗,之前顺便加载了当前歌曲的封面图和歌手及专辑信息到play栏中,现在我们顺带把lrc歌词文件的url获取下并在LrcPage中进行解析:
void SekaiMusic::addRecentMusic(int index)
{
......
if(it != musicList.end())
{
......
//新增
lrcPage->setTitleSingerAndImage(it->getMusicName(),it->getSingerName(),it->getImageFilePath());
//让界面加载lrc歌词
lrcPage->parseLrcFile(it->getLrcFilePath());
}
......
lrcPage中新增存储单句歌词类和一个QVector来存储这些歌词:
struct LrcWord{
LrcWord(const QString& Lrc,const QString& tra,qint64 Time)
:lyrics(Lrc),
translation(tra),
time(Time)
{}
QString lyrics;
QString translation;
qint64 time;
};
//新增成员变量:
QVector<LrcWord> lrcWordLines;
歌词解析方法如下:
void LrcPage::parseLrcFile(const QString &filePath)
{
QFile file(filePath);
if(!file.open(QFile::ReadOnly))
{
return;
}
//清理之前的lrc歌词表
lrcWordLines.clear();
while(!file.atEnd())
{
QString Word = "",Translation = "";
QString LineWord = file.readLine();
LineWord = LineWord.trimmed();//去除开头和结尾的转义字符
if(LineWord.back() == ')')//判断结尾是否有),有)便去除
LineWord.chop(1);
//解析时间字符
qint64 time = 0;
int start = 1,end = LineWord.indexOf(":");
//解析分钟
time += LineWord.mid(start,end - start).toInt() * 60 * 1000;
//解析秒
start = end + 1;
end = LineWord.indexOf(".",start);
time += LineWord.mid(start,end-start).toInt() * 1000;
//解析毫秒
start = end + 1;
end = LineWord.indexOf("]",start);
time += LineWord.mid(start,end - start).toInt();
//解析歌词
start = end + 1;
if(start < LineWord.size())
{
end = LineWord.indexOf("(翻译:",start);
if(end != -1)//需要判断是否找到(翻译:
{
Word = LineWord.mid(start,end - start);
start = end + 1;
if(start < LineWord.size())
{
end = LineWord.indexOf(":",start);
Translation = LineWord.mid(end + 1);
if(Translation.size() > 1 && Translation[0] == "[")
{
Translation = "";//排除作曲家一列
}
}
}
else
{
//直接把后面字符串截取全给Word
Word= LineWord.mid(start);
}
}
lrcWordLines.push_back(LrcWord(Word,Translation,time));
}
//打印验证解析是否成功
// for(auto e : lrcWordLines)
// {
// qDebug() << e.time << " : " << e.lyrics << " : " << e.translation;
// }
}
同步功能也很好实现,因为我们有歌词的时间戳,之前player我们说歌曲播放进度发生改变时会发射信号告诉主界面当前歌曲的播放进度,所以我们可以这样实现:
void SekaiMusic::onPositionChanged(qint64 duration)
{
......
//新增
if(currentIndex >= 0)
{
//此时再让Lrc去切换歌词
lrcPage->setLrcWord(duration);
}
}
void LrcPage::setLrcWord(qint64 posintion)
{
int index = findWordByPosition(posintion);
if(index == -1)
{
return;
}
//为5个组合Label设置歌词
ui->Word->setText(setWordByIndex(index - 2).lyrics);
ui->Translation->setText(setWordByIndex(index - 2).translation);
ui->Word_2->setText(setWordByIndex(index - 1).lyrics);
ui->Translation_2->setText(setWordByIndex(index - 1).translation);
ui->WordCenter->setText(setWordByIndex(index).lyrics);
ui->TranslationCenter->setText(setWordByIndex(index).translation);
ui->Word_4->setText(setWordByIndex(index + 1).lyrics);
ui->Translation_4->setText(setWordByIndex(index + 1).translation);
ui->Word_5->setText(setWordByIndex(index + 2).lyrics);
ui->Translation_5->setText(setWordByIndex(index + 2).translation);
}
LrcWord LrcPage::setWordByIndex(int index)
{
if(index >= 0 && index < lrcWordLines.size())
{
return lrcWordLines[index];
}
return LrcWord("","",0);
}
int LrcPage::findWordByPosition(qint64 position)
{
if(lrcWordLines.size() <= 0)
{
//此时没有Lrc歌词
return -1;
}
int n = lrcWordLines.size();
for(int i = 1;i < n;i++)
{
if(position >= lrcWordLines[i - 1].time && position < lrcWordLines[i].time)
{
return i - 1;
}
}
return n - 1;//歌曲播完,还有剩余纯音乐部分
}
到这里我们的整个播放器的功能就实现完成了.下一篇文章我们再来处理本地持久化的问题和解决一些边角问题.