项目实训第九周工作汇报
一、工作内容
1、完成音乐播放器的前后端开发
2、完成用户自主导入解压音乐的前后端开发(AI生成音乐暂未完成)
3、完成用户音乐列表的前后端开发
二、界面展示
2.1我的音乐界面
2.2音乐列表界面
2.3睡眠主界面
三、细节讲解
3.1 用户导入音乐
实现逻辑:
1、用户从本地选择文件上传
2、后端收到文件后,将该文件拷贝复制到服务器的存储文件夹中
3、构建对应的Music对象,并将其插入数据库
4、上传且插入成功后,重新调用得到用户导入音乐列表并在前端完成更新
关键代码:
将后端收到的文件拷贝并存到服务器的存储文件夹:
if (file.isEmpty()) {
System.err.println("上传的音乐文件为空");
return Result.error("405","上传的文件为空");
}
String contentType = file.getContentType();
String originalFilename = file.getOriginalFilename();
if (!Objects.equals(contentType, "audio/mpeg") ||
!originalFilename.toLowerCase().endsWith(".mp3")) {
System.err.println("非法文件类型:" + contentType + " | " + originalFilename);
return Result.error("408", "仅支持MP3格式");
}
long MAX_SIZE = 30 * 1024 * 1024;
if (file.getSize() > MAX_SIZE) {
System.err.println("文件过大:" + file.getSize());
return Result.error("413", "文件大小超过30MB限制");
}
对前端传递给后端的数据进行检查,文件为空报错,限制音频格式为MP3,限制文件大小最大为30MB。
synchronized (FileController.class) {
flag = System.currentTimeMillis() + "";
ThreadUtil.sleep(1L);
}
通过 System.currentTimeMillis()
串行加锁获取毫秒时间戳,生成唯一标识,并 sleep(1ms)
以避免并发重复。
String userDir = "user_" + userId;
String storagePath = filePath + "/music/" + userDir + "/";
if (!FileUtil.isDirectory(storagePath)) {
FileUtil.mkdir(storagePath);
}
String encodedFileName = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.name());
String saveName = flag + "-" + encodedFileName;
FileUtil.writeBytes(file.getBytes(), storagePath + saveName);
以 user_{userId}
作为子目录进行分隔,先判断目录是否存在,不存在则创建。
对原始文件名做 URL 编码,前缀加上时间戳,确保文件名唯一且不含非法字符后写入磁盘。
String accessUrl = "http://" + ip + ":" + port + "/files/music/"
+ userDir + "/" + saveName;
Music music = new Music();
music.setMusicUrl(accessUrl);
music.setTitle(fileNameWithoutExtension);
musicService.addPersonMusic(fileNameWithoutExtension, accessUrl, userId, phyPath);
return Result.success(music);
拼接外部可访问的 HTTP URL,构建 Music
实体并通过 musicService
保存数据库,返回成功响应。
将music存入数据库:
public int addPersonMusic(String title, String musicUrl, Long userId, String storagePath)
传入的是音乐名称,音乐文件可访问url,用户的userId,音乐文件的实际存储路径。音乐的实际存储路径将用来计算音乐的时长。
long duration = 0;
String ffprobePath = "D:\\program files\\ffmpeg\\…\\bin\\ffprobe.exe";
由于引入ffprobe依赖失败,所以在本地下载了ffprobe,并配置了系统环境变量。
ProcessBuilder pb = new ProcessBuilder(
ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
storagePath
);
pb.redirectErrorStream(true);
-show_entries format=duration
:仅打印 “format” 部分的 duration
字段,计算时长。
-of default=noprint_wrappers=1:nokey=1
:去掉字段名和包装,只输出纯数值。
storagePath
:传入待解析文件的绝对路径。
redirectErrorStream(true)
:将 stderr 合并到 stdout,方便统一读取。
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line = reader.readLine();
if (line != null) {
double seconds = Double.parseDouble(line.trim());
duration = (long) seconds;
System.out.println("使用ffprobe获取时长成功:" + duration + " 秒");
}
}
process.waitFor();
启动:pb.start()
启动外部进程。
读取:通过 InputStreamReader
包装,使用 readLine()
读取首行(预期为浮点型秒数,如 123.456789
)。
解析:Double.parseDouble(...)
转为 double
,然后截断(向下取整)赋值给 long duration
。
等待结束:process.waitFor()
阻塞,直至子进程退出,确保资源释放。
由此解析出来了音频时长
Music music = new Music();
music.setUserId(userId);
music.setTitle(title);
music.setDuration(duration);
music.setMusicUrl(musicUrl);
music.setType(MusicTypeEnum.个人导入.name());
music.setPlayCount(0);
music.setIfFavorite(true);
music.setCreateTime(LocalDateTime.now());
return musicMapper.insert(music);
设置Music的其他属性,之后将其插入到music数据库。
3.2 将音乐导入播放列表
实现逻辑:
1、在我的音乐中选择一首音乐,点击“操作”中的“+”号,将音乐插入当前播放的列表当中。
2、重新获得当前的播放列表,在前端完成更新。
关键代码:
Long userId = BaseContext.getCurrentId();
PlayList nowPlayList = playListMapper.selectNowByUserId(userId);
Integer nowPlayListId = nowPlayList.getPlayListId();
List<PlayListMusic> playListMusics = playListMusicMapper.selectNowPlayListMusic(userId);
PlayListMusic playListMusic = playListMusicMapper.selectNowLocation(userId);
1、获取当前用户的userId。
2、从数据库获得当前的播放队列。
3、从数据库中获得当前队列中的所有歌曲,并获得当前歌曲中正在播放的歌的位置
具体的sql实现:
<!-- 查询当前PlayList -->
<select id="selectNowPlayListMusic" resultType="com.example.entity.PlayListMusic">
SELECT
plm.*
FROM
play_list_music AS plm
JOIN (
SELECT
play_list_id
FROM
play_list
WHERE
user_id = #{userId}
AND if_now = 1
) AS pl1
ON plm.play_list_id = pl1.play_list_id
ORDER BY
plm.location ASC
</select>
if(playListMusic == null){
playListMusic = new PlayListMusic();
playListMusic.setPlayListId(nowPlayListId);
playListMusic.setLocation(0);
playListMusic.setId(null);
playListMusic.setMusicUrl(music.getMusicUrl());
playListMusic.setMusicId(music.getMusicId());
playListMusic.setIfNow(false);
}else{
Integer nowLocation = playListMusic.getLocation();
for(PlayListMusic plm : playListMusics){
if(plm.getLocation() > nowLocation){
plm.setLocation(plm.getLocation() + 1);
playListMusicMapper.updatePlayListMusics(plm);
}
}
playListMusic.setLocation(nowLocation + 1);
playListMusic.setId(null);
playListMusic.setMusicUrl(music.getMusicUrl());
playListMusic.setMusicId(music.getMusicId());
playListMusic.setIfNow(false);
}
return playListMusicMapper.insertPlayListMusic(playListMusic);
默认将歌曲插入在当前播放歌曲的下一个位置,所以在当前播放歌曲之后的每一个歌曲的location+1并更新,最后插入新歌曲至播放列表。
3.3 实现音乐播放器
实现逻辑:
1、用户获得当前播放列表的歌曲,将歌曲按需播放。
2、实现暂停功能,点击暂停按钮,可以实现暂停。
3、实现进度条功能,可以记录歌曲播放到的位置,拖动进度条,可控制音乐的播放。
关键代码:
实现以上的功能更多是需要前端的代码
loadAudio(url)方法(根据url播放音频)
if (this.audioElement) {
const eventsToRemove = {
'ended': this.nextSong,
'loadedmetadata': this.handleMetadata,
'timeupdate': this.handleTimeUpdate,
'error': this.handleAudioError
};
// 移除旧的事件监听
Object.entries(eventsToRemove).forEach(([event, handler]) => {
this.audioElement.removeEventListener(event, handler);
});
// 停止播放、清空 src 并卸载
this.audioElement.pause();
this.audioElement.removeAttribute('src');
this.audioElement.load();
this.audioElement = null;
}
判断旧实例:如果 this.audioElement
已经存在,说明此前有一个音频正在使用。
批量卸载事件监听:把所有之前注册的回调统一管理并逐一移除,避免内存泄漏和旧逻辑干扰。
彻底释放资源:
pause()
:停止播放。removeAttribute('src')
:移除资源路径,切断与媒体文件的引用。load()
:触发浏览器重新初始化MediaElement
,彻底清空内部状态。- 最后将
this.audioElement
置为null
,便于垃圾回收。
try {
this.audioElement = new Audio(url);
this.audioElement.preload = 'auto';
this.audioElement.setAttribute('playsinline', '');
// …
} catch (e) {
console.error('音频初始化失败:', e);
this.$emit('error', e);
}
构造函数:使用 new Audio(url)
动态创建一个 HTML5 音频元素。
预加载策略:preload = 'auto'
表示浏览器可以提前加载媒体元数据和部分数据。
const eventHandlers = {
'loadedmetadata': () => {
this.duration = Math.floor(this.audioElement.duration);
this.$emit('duration-update', this.duration);
},
'timeupdate': this.handleTimeUpdate,
'ended': this.nextSong,
'error': (e) => {
console.error('音频错误:', e.target.error.code);
this.$emit('error', e.target.error);
},
'stalled': () => this.isLoading = true,
'canplaythrough': () => this.isLoading = false
};
Object.entries(eventHandlers).forEach(([event, handler]) => {
this.audioElement.addEventListener(event, handler);
});
timeupdate:播放进度更新时触发,委托给 this.handleTimeUpdate
处理,更新进度条。
ended:播放结束,调用 this.nextSong
切换到下一首。
error:加载或播放出错时触发,打印错误码并通过事件上报。
handleTimeUpdate()方法
handleTimeUpdate() {
if (!this.isSeeking) {
// ✅ 必须使用响应式更新
this.$set(this, 'currentTime', Math.floor(this.audioElement.currentTime));
// 调试用日志(确认事件触发)
console.log('[进度更新]', this.currentTime, '/', this.duration);
}
},
实时更新currentTime变量实现进度条更新。
handlePlayControl()方法
handlePlayControl() {
// 1. 计算目标播放状态(播放 ⇄ 暂停)
const targetState = !this.isPlaying;
// 2. 如果还没有选中任何歌曲,默认从第一首开始播放
if (typeof this.currentIndex === 'undefined') {
this.currentIndex = 0;
this.playThis(this.currentIndex);
return;
}
// 3. 核心播放/暂停调用
if (targetState) {
this.resumePlayback();
} else {
this.pausePlayback();
}
// 4. 最后再更新 UI 状态标志
this.isPlaying = targetState;
},
计算目标状态
targetState = !this.isPlaying
:取反当前的播放布尔值,切换播放暂停状态。
首次播放处理
- 如果
currentIndex
还未定义,说明从未播放过任何歌曲,直接播放第一首。
核心控制逻辑
- 根据
targetState
调用不同方法:resumePlayback()
—— 恢复/开始播放。pausePlayback()
—— 暂停播放。
pausePlayback()方法
pausePlayback() {
if (this.audioElement && !this.audioElement.paused) {
try {
this.audioElement.pause();
} catch (e) {
console.error('暂停失败:', e);
}
}
},
this.audioElement.pause()
方法实现播放暂停。
resumePlayback()方法
resumePlayback() {
if (this.audioElement) {
const playPromise = this.audioElement.play();
playPromise.catch(error => {
console.warn('恢复播放失败:', error);
this.showPlayButton = true;
});
} else {
// 音频实例不存在时,重新加载并播放当前索引
this.playThis(this.currentIndex);
}
},
实例存在时
- 调用
audioElement.play()
继续播放。
实例缺失时
- 如果
this.audioElement
为null
,说明播放器实例还没初始化,直接调用playThis(currentIndex)
重新创建并播放当前歌曲。
四、下周任务规划
1、完成新用户的判定,并在新用户登陆后进入个性化信息收集界面。
2、完成个性化信息收集页面的前后端开发。
3、饮食打卡和运动打卡添加勋章判定逻辑,完成用户获得勋章的功能,并把用户获得的勋章通过勋章墙展示。
4、将勋章功能与负责区块链的同学对接。