0 作业内容
请编写一个C#程序,实现音乐文件的播放功能。
要求1:
- 程序应能够读取MP3文件,并播放其中的音频。
- 程序应能够处理可能出现的异常,如文件不存在、文件读取错误等。
- 程序应具有良好的用户界面,方便用户进行操作。
- 程序应具有良好的兼容性,能在不同版本的C#中正常运行。 提示:此功能可以使用WindowsMediaPlayer控件
要求2:
- 程序应能够播放ogg文件。
- 程序应能够处理可能出现的异常,如文件不存在、文件读取错误等。
- 程序应具有良好的用户界面,方便用户进行操作。
- 程序应具有良好的兼容性,能在不同版本的C#中正常运行。 提示:此功能可以使用Nuget程序包中的Naudi.Vorbis控件
1 效果预览
1.1 Home界面
1.1.1 未播放音乐时
1.1.2 播放音乐时
1.2 Playlist界面
1.2.1 导入音乐前
1.2.2 导入音乐后
2 核心功能实现
2.0 说明
由于本项目较为完整地完成了页面UI的设计以及用户交互时的一些逻辑,项目整体代码比较复杂,我这里仅说明该项目的一些核心功能,如暂停、播放等等,其他方面的功能,如界面样式、交互响应等,请参考我github仓库中的完整代码:music-player
2.1 项目结构
项目大致可以分为三个主要部分:Form1、UCHome、UCPlaylist
2.1.1 Form1设计
Form1主要负责子界面Home、Playlist的切换,程序的关闭和最小化等功能
2.1.2 UCHome设计
UCHome主要负责Home界面的功能实现,如暂停、播放、下一首、上一首、停止等
2.1.3 UCPlaylist设计
UCPlaylist主要负责Playlist界面的功能实现,如导入歌曲、选择歌曲等
2.2 Form1核心功能
2.2.1 关闭
// Close ---------------------------------------------------------------
private async void btn_close_Click(object sender, EventArgs e)
{
UCHome.playbackState = -1;
axWindowsMediaPlayer1.Ctlcontrols.stop();
await Task.Delay(100);
Environment.Exit(0);
}
0 大致逻辑
- 停止播放——等待——退出
1 为什么不直接退出?
- 直接退出其实也没什么问题,只是我在这样做时发现一个问题:直接退出后,在下一次重新打开再播放时会有短暂的上次播放时的残留(我是强迫症)。所以我做了一点小处理,就是停止后再退出。
2 为什么要等待?
- 因为多线程的问题,必须留足时间等待线程完成结束播放的功能再退出,否则依然会有残留。
3 停止播放的逻辑
UCHome.playbackState = -1
把UCHome中的全局变量playbackState置为-1,代表结束播放的信号,等到负责播放的线程察觉这个变化后就会停止axWindowsMediaPlayer1.Ctlcontrols.stop()
控制wmp停止播放4 为什么有停止信号还要控制wmp停止,是否冗余了?
- 由于wmp不能播放.ogg格式,所以.ogg需要自行实现控制逻辑,故而全局变量
playbackState
主要服务于.ogg格式
2.2.2 子界面切换
// Home ---------------------------------------------------------------
private void clickHomeBtn() {
// 下面三行为样式变化
btn_home.BackColor = Color.FromArgb(213, 215, 218);
btn_home.ForeColor = Color.Black;
pnl_nav_home.Visible = true;
// 此处为核心逻辑(其他函数照此类推)
ucHome.Visible = true;
}
private void leaveHomeBtn()
{
btn_home.BackColor = Color.FromArgb(237, 239, 243);
btn_home.ForeColor = Color.Gray;
pnl_nav_home.Visible = false;
ucHome.Visible = false;
}
private void btn_home_Click(object sender, EventArgs e)
{
leavePlaylistBtn();
clickHomeBtn();
}
// Playlist ---------------------------------------------------------------
private void clickPlaylistBtn()
{
btn_playlist.BackColor = Color.FromArgb(213, 215, 218);
btn_playlist.ForeColor = Color.Black;
pnl_nav_palylist.Visible = true;
ucPlaylist.Visible = true;
}
private void leavePlaylistBtn()
{
btn_playlist.BackColor = Color.FromArgb(237, 239, 243);
btn_playlist.ForeColor = Color.Gray;
pnl_nav_palylist.Visible = false;
ucPlaylist.Visible = false;
}
private void btn_playlist_Click(object sender, EventArgs e)
{
leaveHomeBtn();
clickPlaylistBtn();
}
切换子界面逻辑比较简单,最直观的想法就是点击button时,只让对应的界面可见,其他界面不可见,以点击Home按钮举例:不去关注按钮样式的变化,代入函数后,最关键的代码就是
ucPlaylist.Visible = false
和ucHome.Visible = true
,即点击后Playlist界面不可见,Home界面可见。
2.3 UCHome核心功能
2.3.1 播放
private void picPlay_Click(object sender, EventArgs e)
{
// 下面两个函数为样式变化,这里不作说明
picPlayDisappear();
picPauseEmerge();
// 核心逻辑
switch (UCPlaylist.option)
{
case 0:
Form1.wmp.Ctlcontrols.play();
break;
case 1:
playbackState = 1;
break;
}
}
主要关注核心逻辑,UCPlaylist下的全局变量
option
用来分别此刻是wmp在播放一般格式的音乐(如mp3、flac等),还是NAudio在播放ogg格式的音乐,如果是前者,则让wmp开始播放;如果是后者则让playbackState
置1,表示播放状态,等待播放线程响应后就会开始播放。
2.3.2 暂停
private void picPause_Click(object sender, EventArgs e)
{
// 判断是否有音乐播放
switch (UCPlaylist.option) {
case 0:
if (Form1.wmp.playState == WMPPlayState.wmppsStopped) return;
break;
case 1:
if (playbackState == -1) return;
break;
}
// 样式变化
picPauseDisappear();
picPlayEmerge();
// 根据option来判断暂停的对象
switch (UCPlaylist.option) {
case 0:
Form1.wmp.Ctlcontrols.pause();
break;
case 1:
playbackState = 0;
break;
}
}
大致逻辑和播放一致,只是比播放多了一个判断此刻有没有音乐在播放的逻辑,如果没有音乐播放则直接返回。
2.3.3 停止
public static bool isStoppedManually = false;
public void clickPicStop() {
// 样式变化
picPlayDisappear();
picPauseEmerge();
labelSongName.Text = string.Empty;
changeStateText(false);
// 根据option来判断暂停的对象
switch (UCPlaylist.option)
{
case 0:
Form1.wmp.Ctlcontrols.stop();
break;
case 1:
playbackState = -1;
break;
}
}
private void picStop_Click(object sender, EventArgs e)
{
// 核心逻辑
isStoppedManually = true;
UCPlaylist.ucPlayList.smusicList.SelectedIndices.Clear();
clickPicStop();
}
clickPicStop()
函数的逻辑就不多说了,也是和上面一样。这里主要说明一下isStoppedManually
这个全局变量的作用,它是用来分别歌曲是被手动停止的还是播放结束自动停止的。我这里作这个区分主要是为了完善体验,如果是手动停止,则音乐结束,而如果是播放结束自动停止,我做的处理是让其自动播放下一首,这样也比较符合一般音乐软件的直觉。
所以这里的意思就是,点击停止按钮以后,isStoppedManually
置true,代表是手动停止(默认为自动停止),然后清空列表中选中的歌曲,最后执行停止播放的逻辑。
2.3.4 下一首
public static bool isNextManually = false;
public void clickNext() {
// 停止
clickPicStop();
// 清空选中歌曲
UCPlaylist.ucPlayList.smusicList.SelectedIndices.Clear();
// 当前歌曲索引加1
++UCPlaylist.curIndex;
// 如果是最后一个,则回到第一首
if (UCPlaylist.curIndex >= UCPlaylist.ucPlayList.smusicList.Items.Count) {
UCPlaylist.curIndex = 0;
}
UCPlaylist.ucPlayList.smusicList.SelectedIndices.Add(UCPlaylist.curIndex);
}
private void picNext_Click(object sender, EventArgs e)
{
// 如果没有歌曲在播放,直接返回,不进行响应
switch (UCPlaylist.option)
{
case 0:
if (Form1.wmp.playState == WMPPlayState.wmppsStopped) return;
break;
case 1:
if (playbackState == -1) return;
break;
}
isStoppedManually = false;
isNextManually = true;
clickNext();
}
这里逻辑略微有点复杂,我先说明
clickNext()
函数的逻辑,这个函数的实际作用就是播放下一首歌。要播放下一首歌,就得先将当前播放的歌曲停止,我这里直接调用了上面提到的停止功能,这时候抽离核心函数的作用就体现出来了,代码复用性明显提高。歌曲停止以后,很自然就会想到让播放列表选中下一首歌,所以得先清空播放列表的选中项,然后让负责跟踪歌曲序号的全局变量curIndex
加1,最后把curIndex
对应的歌曲添加到播放列表的选中项中。
接着说一下点击按钮后的响应,即picNext_Click()
的逻辑。首先和暂停功能一样,没有歌曲播放就直接返回。然后因为clickNext()
触发了停止功能,而点击下一首并不是手动停止,如果是手动停止的话就也不会播放下一首了,所以让isStoppedManually = false
。然后类似于全局变量isStoppedManually
的作用,我这里加入了isNextManually
全局变量,代表是否是手动播放下一首,因为这里是点击下一首按钮,很明显是手动的,所以让isNextManually = true
。最后调用clickNext()
就完成了整个下一首的逻辑。
2.3.5 上一首
private void clickPrevious() {
clickPicStop();
UCPlaylist.ucPlayList.smusicList.SelectedIndices.Clear();
--UCPlaylist.curIndex;
// 如果是第一首,则变成最后一首
if (UCPlaylist.curIndex < 0)
{
UCPlaylist.curIndex = UCPlaylist.ucPlayList.smusicList.Items.Count-1;
}
UCPlaylist.ucPlayList.smusicList.SelectedIndices.Add(UCPlaylist.curIndex);
}
private void picPrevious_Click(object sender, EventArgs e)
{
switch (UCPlaylist.option)
{
case 0:
if (Form1.wmp.playState == WMPPlayState.wmppsStopped) return;
break;
case 1:
if (playbackState == -1) return;
break;
}
isStoppedManually = false;
isNextManually = true;
clickPrevious();
}
大致上逻辑没有变动,只是让
curIndex
从加一变成减一,如果是之前第一首,则点击上一首就会变成最后一首。此外isNextManually
不仅仅只是用来区分是否是手动播放下一首歌,上一首也是等效的,毕竟这个变量的重点在于是否是手动的,很明显,点击上一首按钮是手动的。
2.4 UCPlaylist核心功能
2.4.1 导入歌曲
// 存储歌曲文件的url
List<string> urlList = new List<string>();
// 用来去重
HashSet<string> musicSet = new HashSet<string>();
private void buttonAddMusic_Click_1(object sender, EventArgs e)
{
// 打开资源管理器
OpenFileDialog ofd = new OpenFileDialog();
// 设置可多选
ofd.Multiselect = true;
// 窗口名称
ofd.Title = "Select Music File";
// 后缀过滤
ofd.Filter = "File Ext | *.mp3;*.avi;*.wav;*.flac;*.ogg";
if (ofd.ShowDialog() == DialogResult.OK)
{
// 获取选择的音乐文件名称
string[] nameList = ofd.FileNames;
// 依次遍历
for (int i = 0; i < nameList.Length; i++)
{
// 获取当前文件的url
string url = nameList[i];
// 如果已存在,则跳过
if (musicSet.Contains(url)) continue;
// 开始listview的更新
musicList.BeginUpdate();
ListViewItem lvi = new ListViewItem();
// 格式:序号——歌名——时长——后缀名
// 添加序号
lvi.Text = " " + (cnt++).ToString();
// 添加歌名
lvi.SubItems.Add(Path.GetFileNameWithoutExtension(url));
// 获取后缀名
string ext = Path.GetExtension(url);
string duration;
// 根据后缀名计算歌曲的时长
switch (ext)
{
case ".ogg":
duration = getOggMusicDuration(url);
break;
default:
duration = getCommonMusicDuration(url);
break;
}
// 添加时长
lvi.SubItems.Add(duration);
// 添加后缀
lvi.SubItems.Add(ext);
// 将这一整行添加到listview中
musicList.Items.Add(lvi);
// 结束listview的更新
musicList.EndUpdate();
// 将当前url存入urlList以便后续切换使用
urlList.Add(url);
// 存入musicSet方便去重
musicSet.Add(url);
}
}
}
// 获取ogg文件的时长
private string getOggMusicDuration(string filePath)
{
using (VorbisWaveReader reader = new VorbisWaveReader(filePath))
{
// 获取时长
TimeSpan duration = reader.TotalTime;
// 得到分钟数
int minutes = duration.Minutes;
// 得到秒数
int seconds = duration.Seconds;
// 秒数保留两位数(格式化)
string formattedSeconds = seconds.ToString("D2");
// 返回格式化的时长
return $"{minutes}:{formattedSeconds}";
}
}
// 获取一般格式(如mp3、flac等)的时长
private string getCommonMusicDuration(string filePath)
{
using (AudioFileReader reader = new AudioFileReader(filePath))
{
TimeSpan duration = reader.TotalTime;
int minutes = duration.Minutes;
int seconds = duration.Seconds;
string formattedSeconds = seconds.ToString("D2");
return $"{minutes}:{formattedSeconds}";
}
}
导入歌曲的逻辑比较清晰,打开资源管理器——选择文件——去重——添加路径——更新列表。简单说明一下要注意的点:
- 选择文件时,我并没有将ogg格式和其他格式分开考虑,而是进行了统一的处理,如果播放时选择的时一般格式,就会用wmp播放,如果是ogg格式,就会使用NAudio来播放
- 去重这里直接选择了
HashSet
,每次遍历一个url就会到这个set里查询是否存在,如果已经存在则直接跳过,如果不存在,则在处理完后将其添加到set中- 添加路径到
urlList
后,实际上它的顺序是和listview中的歌曲顺序对应的,因为添加时就是一一对应来添加的,这样做的好处就是,在切换歌曲时直接通过urllist中的序号来对照listview中的序号,就可以直接通过url找到对应的歌曲了
2.4.2 选择播放歌曲(重点)
// 根据后缀名播放对应格式歌曲
private async Task playMusic(string filePath,int selectIndex) {
// 获取后缀名
string ext = Path.GetExtension(filePath);
// 设置播放状态
UCHome.playbackState = 1;
switch (ext) {
// 播放ogg
case ".ogg":
option = 1;
await playOgg(filePath);
break;
// wmp播放一般格式
default:
option = 0;
playCommon(selectIndex);
break;
}
}
// wmp播放一般格式音乐
private void playCommon(int selectIndex) {
// 先确保停止当前播放的音乐
Form1.wmp.Ctlcontrols.stop();
// 等待100ms,消除残留(之前提过)
Thread.Sleep(100);
// 通过选择项序号获得url播放歌曲
Form1.wmp.URL = urlList[selectIndex];
}
// 音乐停止事件
private void OnPlaybackStopped(object sender, StoppedEventArgs e) {
// 如果是手动停止,则直接停止音乐
if (UCHome.isStoppedManually)
{
// 将变量恢复位默认状态
UCHome.isStoppedManually = false;
// 清空选中项
musicList.SelectedIndices.Clear();
}
// 如果是自动停止,则需要播放下一首
else
{
// 播放下一首时也触发了停止事件,需要判断是不是手动播放的下一首
// 如果是手动点击的下一首
if (UCHome.isNextManually)
{
// 恢复默认状态
UCHome.isNextManually = false;
// 播放下一首的动作在手动点击下一首按钮时已经触发,这里不需要再写下一首的逻辑
}
else
{
// 如果是自动播放下一首,这里需要调用下一首的逻辑
UCHome.ucHome.clickNext();
}
}
}
// 播放ogg音乐
private async Task playOgg(string filePath) {
// 通过NAudio和NAudio.Vorbis播放ogg
using (VorbisWaveReader reader = new VorbisWaveReader(filePath))
{
using (WaveOutEvent outputDevice = new WaveOutEvent())
{
// 添加歌曲停止事件,每次停止时触发
outputDevice.PlaybackStopped += OnPlaybackStopped;
// 初始化播放设备
outputDevice.Init(reader);
// 设置无限循环来监听播放状态的变化
while (true) {
// 判断播放状态
switch (UCHome.playbackState) {
// 如果是停止状态,则停止音乐,清空播放设备
case -1:
outputDevice.Stop();
outputDevice.Dispose();
reader.Dispose();
return;
// 如果是暂停状态,则暂停音乐并维持这个状态,直到再次发生变化
case 0:
outputDevice.Pause();
while (UCHome.playbackState == 0) {
await Task.Delay(10);
}
break;
// 如果是播放状态,则播放音乐并维持这个状态,直到再次发生变化
case 1:
outputDevice.Play();
while (UCHome.playbackState == 1)
{
await Task.Delay(10);
}
break;
}
}
}
}
}
// listview选择项变化事件
private async void musicList_SelectedIndexChanged(object sender, EventArgs e)
{
// 如果选择项个数大于0则触发事件
if (musicList.SelectedItems.Count>0) {
// 切换歌曲等效于手动下一首,所以设置isNextManually为true
UCHome.isNextManually = true;
// 停止当前播放的歌曲
UCHome.ucHome.clickPicStop();
// 等待足够时间至停止事件响应,确保消除残留
await Task.Delay(100);
// 停止事件按预想正常触发后恢复isNextManually的默认状态
UCHome.isNextManually = false;
// 可能多线程等待停止时间不匹配,会出现没有选择项但播放歌曲的情况,这时会出现异常
// 此时选择项数量为0,可以再多等待一段时间,增加容错
if (musicList.SelectedItems.Count == 0) {
await Task.Delay(100);
// 如果时间依然不匹配则放弃播放响应(概率较小)
if(musicList.SelectedItems.Count == 0) return;
}
// 因为设置了listview只能单选,所以播放的歌曲就是第一个选中项
// 获取第一个选中项的索引
int selectIndex = musicList.SelectedItems[0].Index;
// 跟踪当前播放歌曲的索引,以便上一首和下一首功能实现
curIndex = selectIndex;
// 获取对应歌曲的url
string filePath = urlList[selectIndex].ToString();
// 下面两行为样式变化,即在Home页面显示歌名,这里不作过多描述
UCHome.ucHome.changeCurName(Path.GetFileNameWithoutExtension(filePath));
UCHome.ucHome.changeStateText(true);
// 等待播放线程
await playMusic(filePath, selectIndex);
}
}
播放歌曲第一个要明确的点是,整个播放流程都是在单独的一个线程中完成的。用wmp播放是自动就会在后台播放歌曲,但是手动播放ogg音乐需要挂起当前线程,等待播放结束,否则ogg音乐来不及播放,当前函数就结束了,也就导致音乐不能播放。之所以不使用Sleep函数,是因为Sleep会阻塞当前线程,导致主程序在音乐播放时无法操作。
第二点,也是本项目的一个亮点吧,就是我将一般格式和ogg格式整合起来了,通过识别后缀名来播放对应格式的歌曲,一般格式用wmp播放,ogg用NAudio播放,在使用时用户并不会注意到两者的差异,即完成了播放状态的统一。
第三点,算是一个缺点,ogg音乐的状态是通过全局变量playBackState
来控制的,所以程序需要监听这个变量的状态来作出响应,我使用的监听策略是每隔一小段时间,就检查一下变量是否改变(即代码中的无限循环),没有变动就保持挂起状态,有变动就做出相应反应。这样做逻辑上可行,但并不完美,因为要使用户无感知就得保证检查变量的间隔时间足够短,这样就导致cpu需要频繁检查变量的状态,造成cpu的性能浪费。其实有更好的监听策略,也许可以启动一个事件来监听这个变量的变化,在这个变量变动的时候触发事件,这样就避免了反复检查变量,提高了cpu的性能。
其他方面基本在注释中都说明了,要需要可以按需参考,主要还是以上三点。
3 小结
本项目完成了一个音乐播放器的demo,但这依然是一个雏形,只是把大致的UI轮廓和主要功能完成了。还有很多可以完善的地方,比如音乐进度条,播放结束后的策略(如顺序播放、随机播放)等等。作为一次课程作业来说,我认为目前的进度足够了,但作为一个真正的项目来说,还有一段不小的距离。此demo有亮点,如较好的UI设计、交互体验、以及整合wmp和ogg的播放,但也有不足,如ogg音乐状态的监听、项目整体的架构设计等。
总的来说,这次作业是一次不错的练手项目,对我来说是一段小的开发经验吧。
因为项目中绝大多数问题bug都是我一点一点修复完善的,特别是对于全局变量的处理,比如是否手动停止,是否手动下一首这两个变量,并不是我一开始就能想到,而是代码到了关键时刻无法进展,开始思考解决策略,自然而然就设置了这两个变量。
再比如两个子界面之间的全局变量要相互调用时该如何处理,我一开始想到的是用static关键字,对于变量来说没什么问题,但是调用函数时就不能这样处理了,因为可能会涉及静态函数和非静态函数之间相互调用的问题,我开始认识到不能一味地用static,于是我想到可以在类中初始化一个该类的实例成员,再用this关键字给实例本身赋值,这样一来,无论是变量还是函数都可以设置为public,再通过这个实例成员来调用了。
除了逻辑的实现之外,对于UI的实现我其实也费了不少心思,模仿了不少主流软件的交互响应方式,这也是一个不错的体验,但这并不是重点,所以我也就没在这方面赘述。
——————😋完结撒花😋——————