Compare commits

...

7 Commits

Author SHA1 Message Date
imsyy
e62c81bb33 🐞 fix: 修复电台未加载全部节目 2024-01-18 16:25:03 +08:00
imsyy
984fdb3459 📃 docs: 更新说明 2024-01-18 11:45:46 +08:00
imsyy
019b78bf38 🐞 fix: 修复歌单无法删除歌曲 2024-01-13 10:23:40 +08:00
imsyy
cf88c7669f 📃 docs: 更新说明 2024-01-12 11:14:09 +08:00
imsyy
f4383ba848 feat: 播放页面支持调节音量 #124 2024-01-12 11:08:35 +08:00
imsyy
adbda459ba 🐞 fix: 修复下载歌曲元信息不正确导致无法正常播放 #113 2024-01-11 16:09:09 +08:00
imsyy
984d747179 🐞 fix: 修复本地歌词翻译显示异常 #121 2024-01-11 11:39:29 +08:00
30 changed files with 873 additions and 493 deletions

View File

@@ -1,17 +0,0 @@
name: 添加功能
description: 请填写希望添加的功能的具体信息
title: 【添加功能】请填写标题
labels: [add]
body:
- type: input
id: name
validations:
required: true
attributes:
label: "希望添加什么功能?"
placeholder: "请填写功能名称"
- type: textarea
id: other
attributes:
label: "具体信息"
description: "请详细描述希望添加的功能的具体信息"

View File

@@ -1,6 +1,6 @@
name: 遇到问题
description: 关于使用过程中遇到的问题
title: 【遇到问题】请填写标题
title: 请填写标题
labels: [bug]
body:
- type: input
@@ -31,4 +31,5 @@ body:
id: other
attributes:
label: "具体信息"
description: "有需要补充的信息吗?比如控制台的报错什么的"
description: "请填写完整的复现步骤和遇到的问题,包括但不限于报错信息、控制台输出、网络请求等"
placeholder: "请填写具体的复现步骤和遇到的问题"

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 添加功能
url: https://github.com/imsyy/SPlayer/discussions/new?category=%E6%83%B3%E6%B3%95-ideas
about: 新的功能建议和提问答疑请到讨论区发起
- name: 转到讨论区
url: https://github.com/imsyy/SPlayer/discussions
about: Issues 用于反馈 Bug, 新的功能建议和提问答疑请到讨论区发起

View File

@@ -1,10 +1,16 @@
<div align="center">
<!-- <div align="center">
<img alt="logo" height="80" src="./public/images/icons/favicon.png" />
<h2>SPlayer</h2>
<p>一个简约的音乐播放器</p>
<img alt="main" src="./screenshots/main.png" />
</div>
<br />
<br /> -->
# SPlayer
> 一个简约的音乐播放器
![main](/screenshots/SPlayer.jpg)
## 说明
@@ -23,7 +29,7 @@
- 仅对移动端做了基础适配,**不保证功能全部可用**
> 请注意,本程序不打算开发移动端,也不会对移动端进行完美适配,仅保证基础可用性
- 欢迎各位大佬 `Star` 😍
## 👀 Demo
@@ -137,8 +143,11 @@ docker-compose up -d
### 在线部署
```bash
# 拉取
# 从 Docker Hub 拉取
docker pull imsyy/splayer:latest
# 从 GitHub ghcr 拉取
docker pull ghcr.io/imsyy/splayer:latest
# 运行
docker run -d --name SPlayer -p 7899:7899 imsyy/splayer:latest
```

View File

@@ -42,7 +42,7 @@ export default defineConfig(({ mode }) => {
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "electron/preload/index.js"),
index: resolve(__dirname, "electron/preload/index.mjs"),
},
},
},

View File

@@ -121,8 +121,8 @@ class MainProcess {
icon: nativeImage.createFromPath(join(__dirname, "../../public/images/icons/favicon.png")),
// 预加载
webPreferences: {
devTools: is.dev,
preload: join(__dirname, "../preload/index.js"),
// devTools: is.dev,
preload: join(__dirname, "../preload/index.mjs"),
sandbox: false,
webSecurity: false,
hardwareAcceleration: true,

View File

@@ -1,9 +1,9 @@
import { ipcMain, dialog, app, clipboard, shell } from "electron";
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
import { readDirAsync } from "@main/utils/readDirAsync";
import { parseFile } from "music-metadata";
import { download } from "electron-dl";
import getNeteaseMusicUrl from "@main/utils/getNeteaseMusicUrl";
import NodeID3 from "node-id3";
import axios from "axios";
import fs from "fs/promises";
@@ -193,37 +193,42 @@ const mainIpcMain = (win) => {
});
// 下载文件至指定目录
ipcMain.handle("downloadFile", async (_, data, song, songName, songType, path) => {
ipcMain.handle("downloadFile", async (_, songData, options) => {
try {
const { url, data, lyric, name, type } = JSON.parse(songData);
const { path, downloadMeta, downloadCover, downloadLyrics } = JSON.parse(options);
if (fs.access(path)) {
const songData = JSON.parse(song);
console.info("开始下载:", songData, data);
console.info("开始下载:", name, url);
// 下载歌曲
const songDownload = await download(win, data.url, {
const songDownload = await download(win, url, {
directory: path,
filename: `${songName}.${songType}`,
filename: `${name}.${type}`,
});
// 若不为 mp3,则不进行元信息写入
if (songType !== "mp3") return true;
// 若关闭,则不进行元信息写入
if (!downloadMeta) return true;
// 下载封面
const coverDownload = await download(win, songData.cover, {
const coverDownload = await download(win, data.cover, {
directory: path,
filename: `${songName}.jpg`,
filename: `${name}.jpg`,
});
// 生成歌曲文件的元数据
const songTag = {
title: songData.name,
artist: Array.isArray(songData.artists)
? songData.artists.map((ar) => ar.name).join(" / ")
: songData.artists || "未知歌手",
album: songData.album?.name || songData.album,
image: coverDownload.getSavePath(),
};
// 读取歌曲文件
const songFile = File.createFromPath(songDownload.getSavePath());
// 生成图片信息
const songCover = Picture.fromPath(coverDownload.getSavePath());
// 保存修改后的元数据
const isSuccess = NodeID3.write(songTag, songDownload.getSavePath());
Id3v2Settings.forceDefaultVersion = true;
Id3v2Settings.defaultVersion = 3;
songFile.tag.title = data.name || "未知曲目";
songFile.tag.album = data.album?.name || "未知专辑";
songFile.tag.performers = data?.artists?.map((ar) => ar.name) || ["未知艺术家"];
if (downloadLyrics) songFile.tag.lyrics = lyric;
if (downloadCover) songFile.tag.pictures = [songCover];
// 保存元信息
songFile.save();
songFile.dispose();
// 删除封面
await fs.unlink(coverDownload.getSavePath());
return isSuccess;
return true;
} else {
console.log(`目录不存在:${path}`);
return false;

View File

@@ -1,4 +1,4 @@
const netEaseApi = require("NeteaseCloudMusicApi");
import netEaseApi from "NeteaseCloudMusicApi";
/**
* 启动网易云音乐 API 服务器

View File

@@ -1,4 +1,4 @@
import { dialog, shell } from "electron";
import { dialog } from "electron";
import { is } from "@electron-toolkit/utils";
import pkg from "electron-updater";
@@ -10,23 +10,56 @@ const hasNewVersion = (info) => {
.showMessageBox({
title: "发现新版本 v" + info.version,
message: "发现新版本 v" + info.version,
detail: "是否前往 GitHub 下载新版本安装包",
buttons: ["前往", "取消"],
detail: "是否立即下载并安装新版本",
buttons: ["立即下载", "取消"],
type: "question",
noLink: true,
})
.then((result) => {
if (result.response === 0) {
shell.openExternal("https://github.com/imsyy/SPlayer/releases");
// 触发手动下载
autoUpdater.downloadUpdate();
}
});
};
export const configureAutoUpdater = () => {
if (is.dev) return false;
autoUpdater.checkForUpdatesAndNotify();
// 监听下载进度事件
autoUpdater.on("download-progress", (progressObj) => {
console.log(`更新下载进度: ${progressObj.percent}%`);
});
// 下载完成
autoUpdater.on("update-downloaded", () => {
// 显示安装弹窗
dialog
.showMessageBox({
title: "下载完成",
message: "新版本已下载完成,是否现在安装?",
buttons: ["是", "稍后"],
type: "question",
})
.then((result) => {
if (result.response === 0) {
// 安装更新
autoUpdater.quitAndInstall();
}
});
});
// 下载失败
autoUpdater.on("error", (err) => {
console.error("下载更新失败:", err);
dialog.showErrorBox("下载更新失败", "请检查网络连接并稍后重试!");
});
// 若有更新
autoUpdater.on("update-available", (info) => {
hasNewVersion(info);
});
// 检查更新
autoUpdater.checkForUpdatesAndNotify();
};

View File

@@ -1,6 +1,6 @@
{
"name": "splayer",
"version": "2.0.0",
"version": "2.0.3",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "imsyy",
@@ -14,6 +14,7 @@
"npm": ">=9.6.7",
"pnpm": ">=8.14.0"
},
"type": "module",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
@@ -29,7 +30,7 @@
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@material/material-color-utilities": "^0.2.7",
"NeteaseCloudMusicApi": "^4.14.0",
"NeteaseCloudMusicApi": "^4.14.1",
"axios": "^1.6.5",
"colorthief": "^2.4.0",
"electron-dl": "^3.5.1",
@@ -41,7 +42,7 @@
"js-cookie": "^3.0.5",
"localforage": "^1.10.0",
"music-metadata": "7.14.0",
"node-id3": "^0.2.6",
"node-taglib-sharp": "^5.2.3",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"plyr": "^3.7.8",
@@ -52,17 +53,17 @@
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
"@rushstack/eslint-patch": "^1.6.1",
"@vitejs/plugin-vue": "^5.0.2",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"ajv": "^8.12.0",
"electron": "^28.1.2",
"electron": "^28.1.3",
"electron-builder": "^24.9.1",
"electron-log": "^5.0.3",
"electron-vite": "^2.0.0",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"eslint-plugin-vue": "^9.20.1",
"naive-ui": "^2.37.3",
"prettier": "^3.1.1",
"prettier": "^3.2.2",
"sass": "^1.69.7",
"terser": "^5.26.0",
"unplugin-auto-import": "^0.17.3",
@@ -70,6 +71,6 @@
"vite": "^5.0.11",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-pwa": "^0.17.4",
"vue": "3.4.4"
"vue": "3.4.8"
}
}

589
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
screenshots/SPlayer.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 524 KiB

View File

@@ -32,7 +32,7 @@
alignItems: 'center',
justifyContent: 'space-between',
}"
:class="music.getPlaySongData?.id === item?.id ? 'songs play' : 'songs'"
:class="Number(music.getPlaySongData?.id) === Number(item?.id) ? 'songs play' : 'songs'"
hoverable
@click="checkCanClick(data, item, songsIndex + index)"
@dblclick.stop="playSong(data, item, songsIndex + index)"
@@ -190,7 +190,7 @@
</div>
<!-- 更新日期 -->
<n-text v-if="type === 'dj' && item.updateTime" class="update hidden" depth="3">
{{ getTimestampTime(item.updateTime, false) }}
{{ djFormatDate(item.updateTime) }}
</n-text>
<!-- 播放量 -->
<n-text v-if="type === 'dj' && item.playCount" class="count hidden" depth="3">
@@ -273,7 +273,7 @@ import { setCloudDel } from "@/api/cloud";
import { addSongToPlayList } from "@/api/playlist";
import { siteData, siteSettings, musicData, siteStatus } from "@/stores";
import { initPlayer, fadePlayOrPause, addSongToNext } from "@/utils/Player";
import { getTimestampTime } from "@/utils/timeTools";
import { djFormatDate } from "@/utils/timeTools";
const router = useRouter();
const music = musicData();
@@ -439,6 +439,7 @@ const delCloudSong = (data, song, index) => {
// 歌单歌曲删除
const deletePlaylistSong = (pid, song, data, index) => {
if (!pid || !song) return $message.error("无法正确定位到歌单,请重试");
$dialog.warning({
title: "确认删除",
content: `确认从歌单中移除 ${song.name}?该操作无法撤销!`,
@@ -698,7 +699,7 @@ onBeforeUnmount(() => {
border-color: var(--main-color);
a,
span,
.play {
.num {
color: var(--main-color) !important;
}
.artist {

View File

@@ -59,7 +59,7 @@
@click="
() => {
drawerShow = false;
playMode = 'song';
playMode = 'normal';
addSongToNext(songData);
}
"
@@ -130,7 +130,7 @@
@click="
() => {
drawerShow = false;
emit('deletePlaylistSong', playlistData, songData, songIndex);
emit('deletePlaylistSong', songSourceId, songData, playlistData, songIndex);
}
"
>

View File

@@ -147,7 +147,7 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
show: isSong && playMode.value !== "dj" && music.getPlaySongData?.id !== song.id && !isFm,
props: {
onClick: () => {
playMode.value = "song";
playMode.value = "normal";
addSongToNext(song);
},
},
@@ -240,7 +240,7 @@ const openDropdown = (e, data, song, index, sourceId, type) => {
show: !isCloud && isUserPlaylist,
props: {
onClick: () => {
emit("deletePlaylistSong", data, song, index);
emit("deletePlaylistSong", sourceId, song, data, index);
},
},
icon: renderIcon("delete"),

View File

@@ -43,8 +43,9 @@
<n-button
:disabled="!downloadChoose"
:loading="downloadStatus"
:focusable="false"
type="primary"
@click="toSongDownload(songData, downloadChoose)"
@click="toSongDownload(songData, lyricData, downloadChoose)"
>
下载
</n-button>
@@ -58,7 +59,7 @@ import { storeToRefs } from "pinia";
import { isLogin } from "@/utils/auth";
import { useRouter } from "vue-router";
import { siteData, siteSettings } from "@/stores";
import { getSongDetail, getSongDownload } from "@/api/song";
import { getSongDetail, getSongDownload, getSongLyric } from "@/api/song";
import { downloadFile, checkPlatform } from "@/utils/helper";
import formatData from "@/utils/formatData";
@@ -66,11 +67,12 @@ const router = useRouter();
const data = siteData();
const settings = siteSettings();
const { userData } = storeToRefs(data);
const { downloadPath } = storeToRefs(settings);
const { downloadPath, downloadMeta, downloadCover, downloadLyrics } = storeToRefs(settings);
// 歌曲下载数据
const songId = ref(null);
const songData = ref(null);
const lyricData = ref(null);
const downloadStatus = ref(false);
const downloadSongShow = ref(false);
const downloadChoose = ref(null);
@@ -79,11 +81,13 @@ const downloadLevel = ref(null);
// 获取歌曲详情
const getMusicDetailData = async (id) => {
try {
const result = await getSongDetail(id);
const songResult = await getSongDetail(id);
const lyricResult = await getSongLyric(id);
// 获取歌曲详情
songData.value = formatData(result?.songs?.[0], "song")[0];
songData.value = formatData(songResult?.songs?.[0], "song")[0];
lyricData.value = lyricResult?.lrc?.lyric || null;
// 生成音质列表
generateLists(result);
generateLists(songResult);
} catch (error) {
closeDownloadModal();
console.error("歌曲信息获取失败:", error);
@@ -91,26 +95,42 @@ const getMusicDetailData = async (id) => {
};
// 歌曲下载
const toSongDownload = async (song, br) => {
console.log(song, br);
downloadStatus.value = true;
// 获取下载数据
const result = await getSongDownload(song?.id, br);
// 开始下载
if (!downloadPath.value && checkPlatform.electron()) {
$notification["warning"]({
content: "缺少配置",
meta: "请前往设置页配置默认下载目录",
duration: 3000,
const toSongDownload = async (song, lyric, br) => {
try {
console.log(song, lyric, br);
downloadStatus.value = true;
// 获取下载数据
const result = await getSongDownload(song?.id, br);
// 开始下载
if (!downloadPath.value && checkPlatform.electron()) {
$notification["warning"]({
content: "缺少配置",
meta: "请前往设置页配置默认下载目录",
duration: 3000,
});
}
if (!result.data?.url) {
downloadStatus.value = false;
return $message.error("下载失败,请重试");
}
// 获取下载结果
const isDownloaded = await downloadFile(result.data, song, lyric, {
path: downloadPath.value,
downloadMeta: downloadMeta.value,
downloadCover: downloadCover.value,
downloadLyrics: downloadLyrics.value,
});
}
const isDownloaded = await downloadFile(result.data, song, downloadPath.value);
if (isDownloaded) {
$message.success("下载完成");
closeDownloadModal();
} else {
downloadStatus.value = false;
$message.error("下载失败,请重试");
console.log(lyric);
if (isDownloaded) {
$message.success("下载完成");
closeDownloadModal();
} else {
downloadStatus.value = false;
$message.error("下载失败,请重试");
}
} catch (error) {
console.error("歌曲下载出错:", error);
$message.error("歌曲下载失败,请重试");
}
};

View File

@@ -104,36 +104,39 @@ const getCaptcha = (phone) => {
// 手机号登录
const phoneLogin = (e) => {
e.preventDefault();
phoneFormRef.value?.validate(async (errors) => {
if (!errors) {
const verifyRes = await verifyCaptcha(
phoneFormData._value.phone,
phoneFormData._value.captcha,
);
console.log(verifyRes);
if (verifyRes.code == 200) {
const result = await toLogin(phoneFormData._value.phone, phoneFormData._value.captcha);
console.log(result);
if (result.code === 200) {
// 去除 HTTPOnly
result.cookie = result.cookie.replaceAll(" HTTPOnly", "");
// 是否含有 MUSIC_U
if (result.cookie && result.cookie.includes("MUSIC_U")) {
// 储存登录信息
emit("setLoginData", result);
} else {
$message.error("登录出错,请重试");
try {
e.preventDefault();
phoneFormRef.value?.validate(async (errors) => {
if (!errors) {
const verifyRes = await verifyCaptcha(
phoneFormData._value.phone,
phoneFormData._value.captcha,
);
console.log(verifyRes);
if (verifyRes.code == 200) {
const result = await toLogin(phoneFormData._value.phone, phoneFormData._value.captcha);
console.log(result);
if (result.code === 200) {
// 去除 HTTPOnly
result.cookie = result.cookie.replaceAll(" HTTPOnly", "");
// 是否含有 MUSIC_U
if (result.cookie && result.cookie.includes("MUSIC_U")) {
// 储存登录信息
emit("setLoginData", result);
} else {
$message.error("登录出错,请重试");
}
}
} else {
phoneFormData.value.captcha = null;
$message.error("登录出错,请重试");
}
} else {
$message.error("请检查你的输入");
}
} else {
$message.error("请检查你的输入");
}
});
});
} catch (error) {
phoneFormData.value.captcha = null;
console.error("登录出错:", error);
$message.error("登录出错,请重试");
}
};
</script>

View File

@@ -131,6 +131,46 @@
>
<SvgIcon icon="comment-text" />
</n-icon>
<!-- 音量 -->
<n-popover trigger="hover" :show-arrow="false" raw>
<template #trigger>
<n-icon
class="volume hidden"
size="22"
@click.stop="setVolumeMute"
@wheel="changeVolume"
>
<SvgIcon v-if="playVolume === 0" icon="no-sound-rounded" />
<SvgIcon v-else-if="playVolume > 0 && playVolume < 0.4" icon="volume-mute-rounded" />
<SvgIcon
v-else-if="playVolume >= 0.4 && playVolume < 0.7"
icon="volume-down-rounded"
/>
<SvgIcon v-else icon="volume-up-rounded" />
</n-icon>
</template>
<!-- 音量调整 -->
<div
:style="{
'--cover-main-color': `rgb(${coverTheme?.light?.shadeTwo})` || '#efefef',
'--cover-second-color': `rgba(${coverTheme?.light?.shadeTwo}, 0.14)` || '#efefef14',
}"
class="slider-content hidden"
@wheel="changeVolume"
>
<n-slider
v-model:value="playVolume"
:tooltip="false"
:min="0"
:max="1"
:step="0.01"
vertical
style="height: 120px"
@update:value="setVolume"
/>
<n-text class="slider-num" depth="3">{{ (playVolume * 100).toFixed(0) }}%</n-text>
</div>
</n-popover>
<!-- 播放模式 -->
<n-icon v-if="playMode === 'normal'" class="hidden" size="22" @click.stop="togglePlayMode">
<SvgIcon
@@ -163,7 +203,14 @@
import { storeToRefs } from "pinia";
import { musicData, siteStatus, siteData } from "@/stores";
import { useRouter } from "vue-router";
import { playOrPause, fadePlayOrPause, setSeek, changePlayIndex } from "@/utils/Player";
import {
playOrPause,
fadePlayOrPause,
setSeek,
changePlayIndex,
setVolume,
setVolumeMute,
} from "@/utils/Player";
import debounce from "@/utils/debounce";
import VueSlider from "vue-slider-component";
import "vue-slider-component/theme/default.css";
@@ -185,6 +232,8 @@ const {
playMode,
playSongMode,
playHeartbeatMode,
playVolume,
coverTheme,
} = storeToRefs(status);
// 子组件
@@ -234,6 +283,24 @@ const togglePlayMode = () => {
playSongMode.value = modeMap[playSongMode.value] || "normal";
};
// 音量条鼠标滚动
const changeVolume = (e) => {
const deltaY = e.deltaY;
if (deltaY > 0) {
// 向下滚动
if (playVolume.value >= 0) {
playVolume.value = Math.max(playVolume.value - 0.05, 0);
}
} else if (deltaY < 0) {
// 向上滚动
if (playVolume.value < 1) {
playVolume.value = Math.min(playVolume.value + 0.05, 1);
}
}
// 更新音量
setVolume(playVolume.value);
};
// 控制面板移入
const controlEnter = () => {
if (controlTimeOut.value) clearTimeout(controlTimeOut.value);
@@ -398,4 +465,23 @@ const controlMove = (e) => {
}
}
}
// 音量控制
.slider-content {
padding: 10px 0px;
width: 50px;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--cover-second-color);
.n-slider {
--n-fill-color: var(--cover-main-color);
--n-fill-color-hover: var(--cover-main-color);
--n-handle-color: var(--cover-main-color);
}
.slider-num {
margin-top: 4px;
font-size: 12px;
}
}
</style>

View File

@@ -19,11 +19,20 @@ const useMusicDataStore = defineStore("musicData", {
playSongSource: 0,
// 当前歌曲歌词数据
playSongLyric: {
lrc: [],
yrc: [],
hasTran: false,
hasRoma: false,
// 是否具有普通翻译
hasLrcTran: false,
// 是否具有普通音译
hasLrcRoma: false,
// 是否具有逐字歌词
hasYrc: false,
// 是否具有逐字翻译
hasYrcTran: false,
// 是否具有逐字音译
hasYrcRoma: false,
// 普通歌词数组
lrc: [],
// 逐字歌词数据
yrc: [],
},
// 本地歌曲目录
localSongPath: [],

View File

@@ -48,6 +48,9 @@ const useSiteSettingsStore = defineStore("siteSettings", {
showRoma: true, // 是否显示歌词音译
// 下载部分
downloadPath: null, // 默认下载路径
downloadMeta: true, // 同时下载元信息
downloadCover: true, // 同时下载封面
downloadLyrics: true, // 同时下载歌词
};
},
getters: {},

View File

@@ -6,7 +6,7 @@ import { decode as base642Buffer } from "@/utils/base64";
import { getSongPlayTime } from "@/utils/timeTools";
import { getCoverGradient } from "@/utils/cover-color";
import { isLogin } from "@/utils/auth";
import parseLyric from "@/utils/parseLyric";
import { parseLyric, parseLocalLrc } from "@/utils/parseLyric";
// 全局播放器
let player;
@@ -42,10 +42,9 @@ export const initPlayer = async (playNow = false) => {
const { playList } = music;
// 当前播放歌曲数据
const playSongData = music.getPlaySongData;
// 若为电台则更改 id
playSongData.id = playMode === "dj" ? playSongData.mainTrackId : playSongData.id;
// 是否为本地歌曲
const isLocalSong = playSongData?.path ? true : false;
console.log("当前为本地歌曲");
// 获取封面
if (isLocalSong) {
music.playSongData.localCover = await getLocalCoverData(playSongData?.path);
@@ -597,17 +596,20 @@ const getSongLyricData = async (islocal, data) => {
const music = musicData();
const setDefaults = () => {
music.playSongLyric = {
hasLrcTran: false,
hasLrcRoma: false,
hasYrc: false,
hasYrcTran: false,
hasYrcRoma: false,
lrc: [],
yrc: [],
hasTran: false,
hasRoma: false,
hasYrc: false,
};
};
if (islocal) {
const lyricData = await electron.ipcRenderer.invoke("getMusicLyric", data?.path);
if (lyricData) {
music.playSongLyric = parseLyric({ lrc: { lyric: lyricData } });
const result = parseLocalLrc(lyricData);
music.playSongLyric = result ? (music.playSongLyric = result) : setDefaults();
} else {
console.log("该歌曲暂无歌词");
setDefaults();
@@ -616,7 +618,8 @@ const getSongLyricData = async (islocal, data) => {
const lyricResponse = await getSongLyric(data?.id);
const lyricData = lyricResponse?.lrc;
if (lyricData) {
music.playSongLyric = parseLyric(lyricResponse);
const result = parseLyric(lyricResponse);
result ? (music.playSongLyric = result) : setDefaults();
} else {
console.log("该歌曲暂无歌词");
setDefaults();
@@ -812,6 +815,10 @@ export const playAllSongs = async (playlist, mode = "normal") => {
// 播放
fadePlayOrPause();
}
// 获取封面
if (music.getPlaySongData?.path) {
music.playSongData.localCover = await getLocalCoverData(music.getPlaySongData?.path);
}
$message.info("已开始播放", { showIcon: false });
} catch (error) {
console.error("播放全部歌曲出错:", error);

View File

@@ -112,8 +112,7 @@ const formatData = (data, type = "playlist", noTracks = false) => {
// dj
case "dj":
return {
id: v.id || v.vid,
mainTrackId: v.mainTrackId,
id: v.mainTrackId || v.id || v.vid,
name: v.name,
creator: v.dj,
count: v.programCount,

View File

@@ -1,3 +1,8 @@
// BlobUrl
let lastSongBlobUrl = null;
let lastCoverBlobUrl = null;
let lastDownloadBlobUrl = null;
/**
* 判断当前运行环境
*/
@@ -59,27 +64,15 @@ export const getCacheData = async (key, time, request, params) => {
*/
export const getLocalCoverData = async (path, isAlbum = false) => {
try {
let blobUrl = null;
// 清理过期的 Blob 链接
if (lastCoverBlobUrl) URL.revokeObjectURL(lastCoverBlobUrl);
const coverData = await electron.ipcRenderer.invoke("getMusicCover", path);
if (coverData) {
// 将 Uint8Array 数据转换为 Blob
const blob = new Blob([coverData.coverData], { type: `image/${coverData.coverFormat}` });
// 生成Blob URL
blobUrl = URL.createObjectURL(blob);
// 检查当前path是否与上次不一致
const previousPath = sessionStorage.getItem("localCoverPath");
if (previousPath && previousPath !== path) {
// 清除上次的内容
const previousBlobUrl = sessionStorage.getItem("localCoverBlobUrl");
if (previousBlobUrl) {
URL.revokeObjectURL(previousBlobUrl);
sessionStorage.removeItem("localCoverBlobUrl");
}
}
// 存储当前path和Blob URL
sessionStorage.setItem("localCoverPath", path);
sessionStorage.setItem("localCoverBlobUrl", blobUrl);
return blobUrl;
lastCoverBlobUrl = URL.createObjectURL(blob);
return lastCoverBlobUrl;
} else {
// 如果没有封面数据
return `/images/pic/${isAlbum ? "album" : "song"}.jpg?assest`;
@@ -299,35 +292,40 @@ export const generateId = (fileName) => {
* @param {String} songName - 歌曲名称
* @returns {number} - 生成的数字ID
*/
export const downloadFile = async (data, song, path = null) => {
export const downloadFile = async (data, song, lyric, options) => {
try {
const isElectron = checkPlatform.electron();
const songType = data.type.toLowerCase();
// 歌曲名称
const songName =
song.name +
" - " +
"-" +
(Array.isArray(song.artists)
? song.artists.map((ar) => ar.name).join(" / ")
? song.artists.map((ar) => ar.name).join("&")
: song.artists || "未知歌手");
if (isElectron && path) {
console.log("开始下载:", data, song, songName, songType, path);
if (isElectron && options.path) {
console.log("开始下载:", data, song, songName, songType, options.path);
return await electron.ipcRenderer.invoke(
"downloadFile",
data,
JSON.stringify(song),
songName,
songType,
path,
JSON.stringify({
url: data?.url,
data: song,
lyric: lyric,
name: songName,
type: songType,
}),
JSON.stringify(options),
);
} else {
const songRes = await fetch(data.url.replace(/^http:/, "https:"));
// 清理过期的 Blob 链接
if (lastDownloadBlobUrl) URL.revokeObjectURL(lastDownloadBlobUrl);
const songRes = await fetch(data?.url.replace(/^http:/, "https:"));
if (!songRes.ok) throw new Error("下载出错,请重试");
const blob = await songRes.blob();
const url = window.URL.createObjectURL(blob);
lastDownloadBlobUrl = URL.createObjectURL(blob);
// 下载数据
const a = document.createElement("a");
a.href = url;
a.href = lastDownloadBlobUrl;
a.download = `${songName}.${songType}`;
document.body.appendChild(a);
a.click();
@@ -359,12 +357,10 @@ export const formatBytes = (bytes, decimals = 2) => {
* 获取音频文件的 Blob 链接
* @param {string} url - 音频文件的网络链接
*/
// 上次生成的 BlobUrl
let lastBlobUrl = null;
export const getBlobUrlFromUrl = async (url) => {
try {
// 清理过期的 Blob 链接
if (lastBlobUrl) URL.revokeObjectURL(lastBlobUrl);
if (lastSongBlobUrl) URL.revokeObjectURL(lastSongBlobUrl);
// 是否为网络链接
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("blob:")) {
return url;
@@ -377,8 +373,8 @@ export const getBlobUrlFromUrl = async (url) => {
}
const blob = await response.blob();
// 转换为本地 Blob 链接
lastBlobUrl = URL.createObjectURL(blob);
return lastBlobUrl;
lastSongBlobUrl = URL.createObjectURL(blob);
return lastSongBlobUrl;
} catch (error) {
console.error("获取 Blob 链接遇到错误:" + error);
throw error;

View File

@@ -4,60 +4,113 @@
* @param {string} data 接口数据
* @returns {Array} 对应数据
*/
const parseLyric = (data) => {
// 判断是否具有内容
const checkLyric = (lyric) => (lyric ? (lyric.lyric ? true : false) : false);
// 初始化数据
const { lrc, tlyric, romalrc, yrc, ytlrc, yromalrc } = data;
const lrcData = {
lrc: lrc?.lyric || null,
tlyric: tlyric?.lyric || null,
romalrc: romalrc?.lyric || null,
yrc: yrc?.lyric || null,
ytlrc: ytlrc?.lyric || null,
yromalrc: yromalrc?.lyric || null,
};
// 初始化输出结果
const result = {
// 是否具有普通翻译
hasLrcTran: checkLyric(tlyric),
// 是否具有普通音译
hasLrcRoma: checkLyric(romalrc),
// 是否具有逐字歌词
hasYrc: checkLyric(yrc),
// 是否具有逐字翻译
hasYrcTran: checkLyric(ytlrc),
// 是否具有逐字音译
hasYrcRoma: checkLyric(yromalrc),
// 普通歌词数组
lrc: [],
// 逐字歌词数据
yrc: [],
};
// 普通歌词
if (lrcData.lrc) {
result.lrc = parseLrc(lrcData.lrc);
//判断是否有其他翻译
result.lrc = lrcData.tlyric
? parseOtherLrc(result.lrc, parseLrc(lrcData.tlyric), "tran")
: result.lrc;
result.lrc = lrcData.romalrc
? parseOtherLrc(result.lrc, parseLrc(lrcData.romalrc), "roma")
: result.lrc;
export const parseLyric = (data) => {
try {
// 判断是否具有内容
const checkLyric = (lyric) => (lyric ? (lyric.lyric ? true : false) : false);
// 初始化数据
const { lrc, tlyric, romalrc, yrc, ytlrc, yromalrc } = data;
const lrcData = {
lrc: lrc?.lyric || null,
tlyric: tlyric?.lyric || null,
romalrc: romalrc?.lyric || null,
yrc: yrc?.lyric || null,
ytlrc: ytlrc?.lyric || null,
yromalrc: yromalrc?.lyric || null,
};
// 初始化输出结果
const result = {
// 是否具有普通翻译
hasLrcTran: checkLyric(tlyric),
// 是否具有普通音译
hasLrcRoma: checkLyric(romalrc),
// 是否具有逐字歌词
hasYrc: checkLyric(yrc),
// 是否具有逐字翻译
hasYrcTran: checkLyric(ytlrc),
// 是否具有逐字音译
hasYrcRoma: checkLyric(yromalrc),
// 普通歌词数组
lrc: [],
// 逐字歌词数据
yrc: [],
};
// 普通歌词
if (lrcData.lrc) {
result.lrc = parseLrc(lrcData.lrc);
//判断是否有其他翻译
result.lrc = lrcData.tlyric
? parseOtherLrc(result.lrc, parseLrc(lrcData.tlyric), "tran")
: result.lrc;
result.lrc = lrcData.romalrc
? parseOtherLrc(result.lrc, parseLrc(lrcData.romalrc), "roma")
: result.lrc;
}
// 逐字歌词
if (lrcData.yrc) {
result.yrc = parseYrc(lrcData.yrc);
//判断是否有其他翻译
result.yrc = lrcData.ytlrc
? parseOtherLrc(result.yrc, parseLrc(lrcData.ytlrc), "tran")
: result.yrc;
result.yrc = lrcData.yromalrc
? parseOtherLrc(result.yrc, parseLrc(lrcData.yromalrc, false), "roma")
: result.yrc;
}
console.log(result);
return result;
} catch (error) {
console.error("解析歌词时出现错误:", error);
return false;
}
// 逐字歌词
if (lrcData.yrc) {
result.yrc = parseYrc(lrcData.yrc);
//判断是否有其他翻译
result.yrc = lrcData.ytlrc
? parseOtherLrc(result.yrc, parseLrc(lrcData.ytlrc), "tran")
: result.yrc;
result.yrc = lrcData.yromalrc
? parseOtherLrc(result.yrc, parseLrc(lrcData.yromalrc, false), "roma")
: result.yrc;
};
/**
* 解析本地歌词数据
* @param {string} data - 歌词字符串
* @returns {Object} - 包含解析后的歌词信息的对象
*/
export const parseLocalLrc = (data) => {
try {
const lyric = parseLrc(data);
const parsedLyrics = [];
// 初始化输出结果
const result = {
hasLrcTran: false,
hasLrcRoma: false,
hasYrc: false,
hasYrcTran: false,
hasYrcRoma: false,
lrc: [],
yrc: [],
};
// 遍历本地歌词数据
for (let i = 0; i < lyric.length; i++) {
// 当前歌词
let currentObj = lyric[i];
// 是否有相同时间
let existingObj = parsedLyrics.find((v) => Number(v.time) === Number(currentObj.time));
// 如果存在翻译
if (existingObj) {
result.hasLrcTran = true;
existingObj.tran = currentObj.content;
}
// 若不存在翻译
else {
parsedLyrics.push({
time: currentObj.time,
content: currentObj.content,
});
}
}
// 改变输出结果
result.lrc = parsedLyrics;
console.log(result);
return result;
} catch (error) {
console.error("解析本地歌词时出现错误:", error);
return false;
}
console.log(result);
return result;
};
/**
@@ -199,5 +252,3 @@ const parseYrc = (lyrics) => {
return [];
}
};
export default parseLyric;

View File

@@ -106,3 +106,31 @@ export const getCommentTime = (t) => {
}${userDate.getDate()}${UH}:${Um}`;
}
};
/**
* 电台时间戳格式化
* @param {number} timestamp - 要格式化的时间戳(毫秒)
* @returns {string} - 格式化后的日期描述
*/
export const djFormatDate = (timestamp) => {
const now = new Date();
const targetDate = new Date(timestamp);
const timeDiff = now - targetDate;
const oneDay = 24 * 60 * 60 * 1000; // 一天的毫秒数
const daysDiff = Math.floor(timeDiff / oneDay);
// 数字补零
const formatNumber = (num) => {
return num < 10 ? `0${num}` : num;
};
if (daysDiff === 0) {
return "今日";
} else if (daysDiff === 1) {
return "昨日";
} else if (daysDiff <= 7) {
return `${daysDiff}天前`;
} else if (targetDate.getFullYear() === now.getFullYear() - 1) {
return `${targetDate.getFullYear()}-${formatNumber(targetDate.getMonth() + 1)}`;
} else {
return `${formatNumber(targetDate.getMonth() + 1)}-${formatNumber(targetDate.getDate())}`;
}
};

View File

@@ -171,13 +171,6 @@
<Transition name="fade" mode="out-in">
<div v-if="!searchValue" class="song-list">
<SongList :data="djData" type="dj" />
<!-- 分页 -->
<Pagination
v-if="djData?.length"
:totalCount="totalCount"
:pageNumber="pageNumber"
@pageNumberChange="pageNumberChange"
/>
</div>
<SongList v-else-if="searchData?.length" :data="searchData" type="dj" />
<n-empty
@@ -209,7 +202,7 @@
import { NIcon } from "naive-ui";
import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { siteData, siteSettings } from "@/stores";
import { siteData } from "@/stores";
import { getDjDetail, getDjProgram, likeDj } from "@/api/dj";
import { fuzzySearch } from "@/utils/helper";
import { isLogin } from "@/utils/auth";
@@ -221,20 +214,17 @@ import SvgIcon from "@/components/Global/SvgIcon";
const router = useRouter();
const data = siteData();
const settings = siteSettings();
const { userLikeData } = storeToRefs(data);
const { loadSize } = storeToRefs(settings);
// 电台数据
const djId = ref(router.currentRoute.value.query.id);
const pageNumber = ref(Number(router.currentRoute.value.query?.page) || 1);
const djDetail = ref(null);
const djData = ref(null);
// 模糊搜索数据
const loadingMsg = ref(null);
const searchValue = ref(null);
const searchData = ref([]);
const totalCount = ref(0);
// 图标渲染
const renderIcon = (icon) => {
@@ -267,6 +257,8 @@ const getDjDetailData = async (id) => {
const detail = await getDjDetail(id);
// 基础信息
djDetail.value = formatData(detail.data, "dj")[0];
// 获取节目
await getDjProgramData(djId.value, djDetail.value?.count);
} catch (error) {
console.error("获取电台信息出错:", error);
$message.error("获取电台信息出现错误");
@@ -274,16 +266,27 @@ const getDjDetailData = async (id) => {
};
// 获取电台全部节目
const getDjProgramData = async (id, limit = loadSize.value, offset = 0) => {
const getDjProgramData = async (id, count) => {
try {
if (count === 0) return (djData.value = "empty");
// 是否为超大歌单
if (count >= 500) {
loadingMsg.value = $message.loading("该电台节目数量过多,请稍等", {
duration: 0,
});
}
// 循环获取
let offset = 0;
djData.value = [];
const result = await getDjProgram(id, limit, offset);
console.log(result);
// 数据总数
totalCount.value = result.count;
if (totalCount.value === 0) return (djData.value = "empty");
// 处理数据
djData.value = formatData(result.programs, "dj");
while (count === null || offset < count) {
const result = await getDjProgram(id, 500, offset);
const djDetail = formatData(result.programs, "dj");
djData.value = djData.value.concat(djDetail);
offset += 500;
}
// 关闭加载提示
loadingMsg.value?.destroy();
loadingMsg.value = null;
} catch (error) {
console.error("获取电台节目错误:", error);
$message.error("获取电台节目出现错误");
@@ -330,36 +333,8 @@ const likeOrDislike = debounce(async (id) => {
}
}, 300);
// 页数变化
const pageNumberChange = (page) => {
router.push({
path: "/dj",
query: { id: djId.value, page },
});
};
// 监听路由变化
watch(
() => router.currentRoute.value,
async (val) => {
if (val.name === "dj") {
// 更改参数
pageNumber.value = Number(val.query?.page) || 1;
djId.value = val.query?.id;
// 调用接口
await getDjDetailData(djId.value);
await getDjProgramData(
djId.value,
loadSize.value,
(pageNumber.value - 1) * settings.loadSize,
);
}
},
);
onMounted(async () => {
await getDjDetailData(djId.value);
await getDjProgramData(djId.value, loadSize.value, (pageNumber.value - 1) * settings.loadSize);
});
</script>

View File

@@ -71,7 +71,7 @@
v-model:value="searchValue"
:input-props="{ autoComplete: false }"
class="search"
placeholder="搜索"
placeholder="模糊搜索"
clearable
@input="localSearch"
>

View File

@@ -516,6 +516,21 @@
</n-button>
</n-flex>
</n-card>
<n-card class="set-item">
<div class="name">
同时下载歌曲元信息
<n-text class="tip">为当前下载歌曲附加封面及歌词等元信息</n-text>
</div>
<n-switch v-model:value="downloadMeta" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">下载歌曲时同时下载封面</div>
<n-switch v-model:value="downloadCover" :disabled="!downloadMeta" :round="false" />
</n-card>
<n-card class="set-item">
<div class="name">下载歌曲时同时下载歌词</div>
<n-switch v-model:value="downloadLyrics" :disabled="!downloadMeta" :round="false" />
</n-card>
</div>
<!-- 其他 -->
<div class="set-type">
@@ -611,6 +626,9 @@ const {
showSpectrums,
siderShowCover,
useMusicCache,
downloadMeta,
downloadCover,
downloadLyrics,
} = storeToRefs(settings);
// 标签页数据