Compare commits

...

77 Commits

Author SHA1 Message Date
alger
8dab799939 feat: 修改更新检查功能 2025-01-01 15:05:49 +08:00
alger
1ddbe6f24e feat: 修改 github action 2025-01-01 14:48:31 +08:00
alger
4d5bcba6c7 fix: update macOS build config 2025-01-01 14:42:19 +08:00
alger
f833306b60 feat: 修改 github action 2025-01-01 14:31:27 +08:00
alger
4d92ed9963 🐞 fix: 修复mac 安装包损坏问题 2025-01-01 14:19:44 +08:00
alger
a22285156a feat: 修改 github action 添加更新日志 2025-01-01 14:01:55 +08:00
alger
d1029f16d6 feat: 修改 github action 2025-01-01 13:42:08 +08:00
alger
4908555635 feat: 修改 github action 2025-01-01 13:34:36 +08:00
alger
750cf7a484 🐞 fix: 去除无用导入 2025-01-01 13:26:06 +08:00
alger
a334743f6f feat: 添加 github action 自动 打包 发布 2025-01-01 13:14:56 +08:00
alger
14747cac10 feat: 优化打包和版本更新功能 2025-01-01 13:12:46 +08:00
alger
cc239aeaba 📃 docs: 修改文档 2025-01-01 02:44:39 +08:00
alger
eeda296589 📃 docs: 修改文档 2025-01-01 02:43:00 +08:00
alger
edb7ea201c 🌈 style: 去除无用提交 2025-01-01 02:30:37 +08:00
alger
17d20fa299 🦄 refactor: 重构整个项目 优化打包 修改后台服务为本地运行 添加更新版本检测功能 2025-01-01 02:25:18 +08:00
alger
f8d421c9b1 🐞 fix: 修复web移动端 页面空白问题 (#24)
closed #24
2024-12-30 11:20:23 +08:00
alger
dfdf02a17f feat: 优化主题效果 添加展开 menu功能 优化图片清晰度 添加随机播放功能(#20) 2024-12-29 00:43:39 +08:00
alger
abdb2bcd50 feat: 新增主题色切换功能 默认为日间主题可切换夜间 (#19、#21)
fixes #19  #21
2024-12-28 16:43:52 +08:00
alger
f728191a8f feat: 顶栏修改 2024-12-27 18:27:01 +08:00
alger
dfa8b51a53 📃 docs: qq 2024-12-25 19:59:58 +08:00
alger
b2c13121fd feat: 添加mv分类 2024-12-25 19:55:24 +08:00
alger
d28adb61a4 feat: 优化 list 加载 2024-12-17 23:23:20 +08:00
alger
9a7d5a3834 feat: 记忆歌词窗口位置 主窗口可关闭歌词窗口 2024-12-16 22:25:38 +08:00
alger
2037798fbe feat: 修复桌面歌词滚动问题 2024-12-16 22:15:25 +08:00
alger
85bd0ad015 feat: 优化桌面歌词添加歌曲控制 上一首下一首 播放暂停 2024-12-16 22:12:28 +08:00
alger
e1557a51a3 feat: 优化下载应用功能 去除web 窗口样式 2024-12-16 20:40:57 +08:00
alger
1ecc6f136f feat: 添加网页端可拖动边缘调整窗口大小功能 2024-12-15 21:17:35 +08:00
alger
53b3061b03 feat: 优化歌单列表页面 添加分类 2024-12-15 18:19:58 +08:00
alger
3d2f6a2330 feat: 将收藏与历史合并 2024-12-15 15:12:45 +08:00
alger
3b1470f28f feat: 添加设置菜单 优化移动端菜单显示 2024-12-15 14:35:18 +08:00
alger
100268448a feat: 优化图片加载 2024-12-15 14:13:13 +08:00
alger
51f67bb2c2 feat: 优化应用下载 2024-12-15 13:00:20 +08:00
alger
7be126cf5f feat: 优化播放器样式 添加单曲循环 优化桌面歌词效果 2024-12-15 01:40:13 +08:00
alger
f2f5d3ac15 feat: 优化web端页面效果 展示为 pc应用样式 2024-12-14 13:49:32 +08:00
alger
34c45e0105 📃 docs: 修该注释 2024-12-14 13:15:59 +08:00
alger
f9333f5f78 🐞 fix: 修复搜索时 使用空格导致的空格快捷键冲突问题(#18)
fixes #18
2024-12-14 13:00:06 +08:00
alger
7365daf700 🐞 fix: 修复播放暂停控制问题 后续优化为参数监听 2024-12-12 22:36:07 +08:00
alger
cebf313075 feat: 优化播放 修改为howler 修复搜索导致播放无限卡顿问题(#15)
- 优化了整个项目的播放
- 去除audio
- 优化歌词页 歌词同步时间

fixes #15
2024-12-12 22:18:52 +08:00
alger
bb99049991 feat: 优化页面效果 2024-12-09 22:58:57 +08:00
alger
df74dafbc5 feat: 优化歌单列表页面 2024-12-09 22:39:33 +08:00
alger
721d2a9704 feat: 添加搜藏功能 与页面 2024-12-09 21:55:08 +08:00
alger
1e60fa9a95 feat: 添加展开收起歌词的提示 2024-12-09 20:51:40 +08:00
alger
f24e8232f8 feat: 修复布局问题 2024-12-09 20:39:32 +08:00
alger
a1b1d861ac feat: 修改下载地址 2024-12-09 18:43:05 +08:00
alger
f24263b416 🐞 fix: 修复滚动问题 2024-12-08 21:57:34 +08:00
alger
17795e5da2 feat: 添加动画速度调整功能 优化页面自适应效果 2024-12-08 21:50:58 +08:00
alger
f1030d3a78 feat: seo 优化 2024-12-08 21:35:15 +08:00
alger
b979ce250f feat: 添加 Coffee 2024-12-07 23:20:31 +08:00
alger
d0d8966875 feat: 优化移动端 歌词与歌单页面显示 2024-12-07 22:54:45 +08:00
alger
d39ba65263 📃 docs: 修改文档 2024-12-07 22:38:56 +08:00
alger
62d400827e 📃 docs: 修改文档 2024-12-07 22:33:36 +08:00
alger
75b99c46b5 📃 docs: 修改文档 2024-12-07 22:32:06 +08:00
alger
e7ae79144c feat: 修改登录背景 2024-12-07 21:50:18 +08:00
alger
04d6cbe7f3 🐞 fix: 修复二维码登录 重复触发请求问题 修改为手机号优先 2024-12-07 21:37:10 +08:00
alger
bea1e5751f feat: 优化cpu占用过高问题 2024-12-07 14:30:20 +08:00
alger
f2ebb04fab 📃 docs: 修改文档 2024-12-07 12:02:54 +08:00
alger
42048764d5 📃 docs: 修改文档 2024-12-07 11:38:56 +08:00
alger
e326253fd8 feat: 优化打包命令 2024-12-06 23:52:08 +08:00
alger
edf5c77ea0 feat: 优化桌面歌词功能 添加歌词进度 优化歌词页面样式 2024-12-06 23:50:44 +08:00
alger
8870390770 feat: 修复下载提示弹出问题 2024-12-06 20:57:33 +08:00
alger
c9514e6e19 feat: 在页面显示 github地址 2024-12-05 21:57:14 +08:00
alger
08fa160de4 🐞 fix: 修复搜索下 mv和歌曲同时播放问题 2024-12-05 21:35:20 +08:00
alger
5d4c4922fd feat: 修复搜索播放 bug 优化搜索 mv播放器 2024-12-05 21:29:13 +08:00
alger
c5e7c87658 feat: 优化列表渲染 2024-12-04 20:38:26 +08:00
alger
f6923b4c47 🐞 fix: 修复调整窗口大小 歌单列表重新加载问题 2024-12-01 16:41:23 +08:00
alger
4cf7598a7d 📃 docs: 更新 docs 2024-12-01 16:28:26 +08:00
alger
81b09bef0d feat: 修复无法播放的问题 2024-12-01 15:55:09 +08:00
alger
b21df3de25 feat: 修改下载地址 2024-11-29 08:49:32 +08:00
alger
c49d814182 feat: 优化滚动条 位置 2024-11-28 23:45:44 +08:00
alger
1cb3c72ab7 feat: 优化歌单列表数量 2024-11-28 23:39:56 +08:00
alger
f03372de6a feat: 优化歌单列表 添加加载更多 优化自动布局 优化歌单 mv 歌单类型的动画效果 2024-11-28 23:33:38 +08:00
alger
d925f40303 feat: 优化歌词进度 添加下载 优化播放 优化历史记录 2024-11-28 08:12:37 +08:00
alger
dc12d895d8 feat: 2.1.0 2024-11-23 22:43:01 +08:00
alger
0bb14902f2 feat: 优化播放样式 优化歌曲背景色 优化 mv播放样式 添加循环播放 等控制功能 2024-11-23 22:42:23 +08:00
alger
3027a5f6ff feat: 完善mac打包规则 修复 icon显示问题 2024-11-20 22:44:17 +08:00
alger
f320f4760b feat: 添加网页标题修改 2024-11-01 17:39:18 +08:00
alger
e939933d6f feat: 添加减轻动画效果选项 添加indexdb方法 2024-10-22 21:09:51 +08:00
175 changed files with 12349 additions and 4147 deletions

View File

@@ -1,3 +1,12 @@
VITE_API = /api
VITE_API_MUSIC = /music
VITE_API_PROXY = http://110.42.251.190:9856
# 你的接口地址 (必填)
VITE_API_LOCAL = ***
# 音乐破解接口地址
VITE_API_MUSIC = ***
# 代理地址
VITE_API_PROXY = ***
# 本地运行代理地址
VITE_API_PROXY = /api
VITE_API_MUSIC_PROXY = /music
VITE_API_PROXY_MUSIC = /music_proxy

View File

@@ -1,3 +0,0 @@
VITE_API = http://110.42.251.190:9898
VITE_API_MUSIC = http://110.42.251.190:4100
VITE_API_PROXY = http://110.42.251.190:9856

View File

@@ -1,13 +0,0 @@
snapshot*
dist
lib
es
esm
node_modules
static
cypress
script/test/cypress
_site
temp*
static/
!.prettierrc.js

136
.eslintrc
View File

@@ -1,136 +0,0 @@
{
"extends": [
"plugin:@typescript-eslint/recommended",
"eslint-config-airbnb-base",
"@vue/typescript/recommended",
"plugin:vue/vue3-recommended",
"plugin:vue-scoped-css/base",
"plugin:prettier/recommended"
],
"env": {
"browser": true,
"node": true,
"jest": true,
"es6": true
},
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly"
},
"plugins": [
"vue",
"@typescript-eslint",
"simple-import-sort"
],
"parserOptions": {
"parser": "@typescript-eslint/parser",
"sourceType": "module",
"allowImportExportEverywhere": true,
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"import/extensions": [
".js",
".jsx",
".ts",
".tsx"
]
},
"rules": {
"no-console": "off",
"no-continue": "off",
"no-restricted-syntax": "off",
"no-return-assign": "off",
"no-unused-expressions": "off",
"no-return-await": "off",
"no-plusplus": "off",
"no-param-reassign": "off",
"no-shadow": "off",
"guard-for-in": "off",
"import/extensions": "off",
"import/no-unresolved": "off",
"import/no-extraneous-dependencies": "off",
"import/prefer-default-export": "off",
"import/first": "off", // https://github.com/vuejs/vue-eslint-parser/issues/58
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"vue/first-attribute-linebreak": 0,
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-types": "off",
"class-methods-use-this": "off", // 因为AxiosCancel必须实例化而能静态化所以加的规则如果有办法解决可以取消
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
},
"overrides": [
{
"files": [
"*.vue"
],
"rules": {
"vue/component-name-in-template-casing": [
2,
"kebab-case"
],
"vue/require-default-prop": 0,
"vue/multi-word-component-names": 0,
"vue/no-reserved-props": 0,
"vue/no-v-html": 0,
"vue-scoped-css/enforce-style-type": [
"error",
{
"allows": [
"scoped"
]
}
]
}
},
{
"files": [
"*.ts",
"*.tsx"
], // https://github.com/typescript-eslint eslint-recommended
"rules": {
"constructor-super": "off", // ts(2335) & ts(2377)
"getter-return": "off", // ts(2378)
"no-const-assign": "off", // ts(2588)
"no-dupe-args": "off", // ts(2300)
"no-dupe-class-members": "off", // ts(2393) & ts(2300)
"no-dupe-keys": "off", // ts(1117)
"no-func-assign": "off", // ts(2539)
"no-import-assign": "off", // ts(2539) & ts(2540)
"no-new-symbol": "off", // ts(2588)
"no-obj-calls": "off", // ts(2349)
"no-redeclare": "off", // ts(2451)
"no-setter-return": "off", // ts(2408)
"no-this-before-super": "off", // ts(2376)
"no-undef": "off", // ts(2304)
"no-unreachable": "off", // ts(7027)
"no-unsafe-negation": "off", // ts(2365) & ts(2360) & ts(2358)
"no-var": "error", // ts transpiles let/const to var, so no need for vars any more
"prefer-const": "error", // ts provides better types with const
"prefer-rest-params": "error", // ts provides better types with rest args over arguments
"prefer-spread": "error", // ts transpiles spread to apply, so no need for manual apply
"valid-typeof": "off" // ts(2367)
}
}
]
}

87
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Build and Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- name: Install Dependencies
run: npm install
# MacOS Build
- name: Build MacOS
if: matrix.os == 'macos-latest'
run: |
export ELECTRON_BUILDER_EXTRA_ARGS="--universal"
npm run build:mac
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: false
DEBUG: electron-builder
# Windows Build
- name: Build Windows
if: matrix.os == 'windows-latest'
run: npm run build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Linux Build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
npm run build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Get version from tag
- name: Get version from tag
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
shell: bash
# Read release notes
- name: Read release notes
id: release_notes
run: |
NOTES=$(awk "/## \[v${{ env.VERSION }}\]/{p=1;print;next} /## \[v/{p=0}p" CHANGELOG.md)
echo "NOTES<<EOF" >> $GITHUB_ENV
echo "$NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
shell: bash
# Upload artifacts
- name: Upload artifacts
uses: softprops/action-gh-release@v1
with:
files: |
dist/*.dmg
dist/*.exe
dist/*.deb
dist/*.AppImage
dist/latest*.yml
dist/*.blockmap
body: ${{ env.NOTES }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.gitignore vendored
View File

@@ -14,3 +14,9 @@ package-lock.json
dist.zip
.vscode
bun.lockb
.env.*.local
out

View File

@@ -1,39 +0,0 @@
module.exports = {
// 一行最多 120 字符..
printWidth: 120,
// 使用 2 个空格缩进
tabWidth: 2,
// 不使用缩进符,而使用空格
useTabs: false,
// 行尾需要有分号
semi: true,
// 使用单引号
singleQuote: true,
// 对象的 key 仅在必要时用引号
quoteProps: 'as-needed',
// jsx 不使用单引号,而使用双引号
jsxSingleQuote: false,
// 末尾需要有逗号
trailingComma: 'all',
// 大括号内的首尾需要空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
jsxBracketSameLine: false,
// 箭头函数,只有一个参数的时候,也需要括号
arrowParens: 'always',
// 每个文件格式化的范围是文件的全部内容
rangeStart: 0,
rangeEnd: Infinity,
// 不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准
proseWrap: 'preserve',
// 根据显示样式决定 html 要不要折行
htmlWhitespaceSensitivity: 'css',
// vue 文件中的 script 和 style 内不用缩进
vueIndentScriptAndStyle: false,
// 换行符使用 lf
endOfLine: 'lf',
};

21
CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# 更新日志
## [v3.0.0] - 2024-03-21
### ✨ 新功能
- 新增自动更新检测功能
- 新增 GitHub Actions 自动构建和发布
- 新增主题色切换功能,支持日间/夜间模式 (#19, #21)
- 新增随机播放功能 (#20)
- 优化主题效果和图片清晰度
### 🏗️ 架构重构
- 重构整个项目架构
- 优化打包配置
- 修改后台服务为本地运行
- 优化项目结构
### 🐞 问题修复
- 修复 web 移动端页面空白问题 (#24)
- 修复无用导入问题
- 优化错误处理

201
LICENSE
View File

@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,12 +1,44 @@
# 一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
# Alger Music Player
主要功能如下
- 音乐推荐
- 音乐播放
- 网易云登录
- 播放历史
- 桌面歌词
- 歌单 mv 搜索 专辑等功能
- 识别无法播放歌曲 并解析播放
- 主题切换 更新检测
- 本地服务 不依赖线上服务
- 可听周杰伦(搜索专辑)
## 项目简介
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
## 预览地址
[http://mc.alger.fun/](http://mc.alger.fun/)
QQ群:789288579
## 软件截图
![首页白](./docs/image.png)
![首页黑](./docs/image3.png)
![歌词](./docs/image1.png)
![桌面歌词](./docs/image2.png)
## 技术栈
### 主要框架
- Vue 3 - 渐进式 JavaScript 框架
- TypeScript - JavaScript 的超集,添加了类型系统
- Electron - 跨平台桌面应用开发框架
- Vite - 下一代前端构建工具
- Naive UI - 基于 Vue 3 的组件库
## 咖啡☕️
| 微信 | 支付宝 |
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
| <img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width=200> | <img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Wechat QRcode" width=200> |
## 项目运行
```bash
@@ -26,16 +58,37 @@
npm run win ...
# 具体看 package.json
```
#### 注意
- 本地运行需要配置 .env.development 文件
- 打包需要配置 .env.production 文件
```bash
# .env.development
VITE_API_LOCAL = /api
VITE_API_MUSIC_PROXY = /music
VITE_API_PROXY_MUSIC = /music_proxy
# 你的接口地址 (必填)
VITE_API = ***
# 音乐po接口地址
VITE_API_MUSIC = ***
VITE_API_PROXY = ***
# .env.production
# 你的接口地址 (必填)
VITE_API = ***
# 音乐po接口地址
VITE_API_MUSIC = ***
# 代理地址
VITE_API_PROXY = ***
```
## Stargazers over time
[![Stargazers over time](https://starchart.cc/algerkong/AlgerMusicPlayer.svg?variant=adaptive)](https://starchart.cc/algerkong/AlgerMusicPlayer)
## 软件截图
![首页](./docs/img/image.png)
![歌单](./docs/img/image-1.png)
![搜索](./docs/img/image-2.png)
![mv](./docs/img/image-3.png)
![历史](./docs/img/image-4.png)
![我的](./docs/img/image-5.png)
## 欢迎提Issues

145
app.js
View File

@@ -1,145 +0,0 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, globalShortcut, nativeImage } = require('electron');
const path = require('path');
const Store = require('electron-store');
const setJson = require('./electron/set.json');
const { loadLyricWindow } = require('./electron/lyric');
let mainWin = null;
function createWindow() {
mainWin = new BrowserWindow({
width: 1200,
height: 780,
frame: false,
webPreferences: {
nodeIntegration: true,
// contextIsolation: false,
preload: path.join(__dirname, '/electron/preload.js'),
},
});
const win = mainWin;
win.setMinimumSize(1200, 780);
if (process.env.NODE_ENV === 'development') {
win.webContents.openDevTools({ mode: 'detach' });
win.loadURL('http://localhost:4488/');
} else {
win.loadURL(`file://${__dirname}/dist/index.html`);
}
const image = nativeImage.createFromPath(path.join(__dirname, 'public/icon.png'));
const tray = new Tray(image);
// 创建一个上下文菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示',
click: () => {
win.show();
},
},
{
label: '退出',
click: () => {
win.destroy();
app.quit();
},
},
]);
// 设置系统托盘图标的上下文菜单
tray.setContextMenu(contextMenu);
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
tray.on('click', () => {
if (win.isVisible()) {
win.hide();
} else {
win.show();
}
});
const set = store.get('set');
// store.set('set', setJson)
if (!set) {
store.set('set', setJson);
}
loadLyricWindow(ipcMain);
}
// 限制只能启动一个应用
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
}
app.whenReady().then(createWindow);
app.on('ready', () => {
globalShortcut.register('CommandOrControl+Alt+Shift+M', () => {
if (mainWin.isVisible()) {
mainWin.hide();
} else {
mainWin.show();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
ipcMain.on('minimize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.minimize();
});
ipcMain.on('maximize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
});
ipcMain.on('close-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.destroy();
app.quit();
});
ipcMain.on('drag-start', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.webContents.beginFrameSubscription((frameBuffer) => {
event.reply('frame-buffer', frameBuffer);
});
});
ipcMain.on('mini-tray', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.hide();
});
// 重启
ipcMain.on('restart', () => {
app.relaunch();
app.exit(0);
});
const store = new Store();
// 定义ipcRenderer监听事件
ipcMain.on('setStore', (_, key, value) => {
store.set(key, value);
});
ipcMain.on('getStore', (_, key) => {
const value = store.get(key);
_.returnValue = value || '';
});

136
auto-imports.d.ts vendored
View File

@@ -3,68 +3,88 @@
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const EffectScope: (typeof import('vue'))['EffectScope'];
const computed: (typeof import('vue'))['computed'];
const createApp: (typeof import('vue'))['createApp'];
const customRef: (typeof import('vue'))['customRef'];
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent'];
const defineComponent: (typeof import('vue'))['defineComponent'];
const effectScope: (typeof import('vue'))['effectScope'];
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance'];
const getCurrentScope: (typeof import('vue'))['getCurrentScope'];
const h: (typeof import('vue'))['h'];
const inject: (typeof import('vue'))['inject'];
const isProxy: (typeof import('vue'))['isProxy'];
const isReactive: (typeof import('vue'))['isReactive'];
const isReadonly: (typeof import('vue'))['isReadonly'];
const isRef: (typeof import('vue'))['isRef'];
const markRaw: (typeof import('vue'))['markRaw'];
const nextTick: (typeof import('vue'))['nextTick'];
const onActivated: (typeof import('vue'))['onActivated'];
const onBeforeMount: (typeof import('vue'))['onBeforeMount'];
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount'];
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate'];
const onDeactivated: (typeof import('vue'))['onDeactivated'];
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured'];
const onMounted: (typeof import('vue'))['onMounted'];
const onRenderTracked: (typeof import('vue'))['onRenderTracked'];
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered'];
const onScopeDispose: (typeof import('vue'))['onScopeDispose'];
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch'];
const onUnmounted: (typeof import('vue'))['onUnmounted'];
const onUpdated: (typeof import('vue'))['onUpdated'];
const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup'];
const provide: (typeof import('vue'))['provide'];
const reactive: (typeof import('vue'))['reactive'];
const readonly: (typeof import('vue'))['readonly'];
const ref: (typeof import('vue'))['ref'];
const resolveComponent: (typeof import('vue'))['resolveComponent'];
const shallowReactive: (typeof import('vue'))['shallowReactive'];
const shallowReadonly: (typeof import('vue'))['shallowReadonly'];
const shallowRef: (typeof import('vue'))['shallowRef'];
const toRaw: (typeof import('vue'))['toRaw'];
const toRef: (typeof import('vue'))['toRef'];
const toRefs: (typeof import('vue'))['toRefs'];
const toValue: (typeof import('vue'))['toValue'];
const triggerRef: (typeof import('vue'))['triggerRef'];
const unref: (typeof import('vue'))['unref'];
const useAttrs: (typeof import('vue'))['useAttrs'];
const useCssModule: (typeof import('vue'))['useCssModule'];
const useCssVars: (typeof import('vue'))['useCssVars'];
const useDialog: (typeof import('naive-ui'))['useDialog'];
const useId: (typeof import('vue'))['useId'];
const useLoadingBar: (typeof import('naive-ui'))['useLoadingBar'];
const useMessage: (typeof import('naive-ui'))['useMessage'];
const useModel: (typeof import('vue'))['useModel'];
const useNotification: (typeof import('naive-ui'))['useNotification'];
const useSlots: (typeof import('vue'))['useSlots'];
const useTemplateRef: (typeof import('vue'))['useTemplateRef'];
const watch: (typeof import('vue'))['watch'];
const watchEffect: (typeof import('vue'))['watchEffect'];
const watchPostEffect: (typeof import('vue'))['watchPostEffect'];
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect'];
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
export type {
Component,
ComponentPublicInstance,
ComputedRef,
DirectiveBinding,
ExtractDefaultPropTypes,
ExtractPropTypes,
ExtractPublicPropTypes,
InjectionKey,
PropType,
Ref,
MaybeRef,
MaybeRefOrGetter,
VNode,
WritableComputedRef
} from 'vue';
import('vue');
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

13
build/installer.nsh Normal file
View File

@@ -0,0 +1,13 @@
# 设置 Windows 7 兼容性
ManifestDPIAware true
ManifestSupportedOS all
!macro customInit
# 检查系统版本
${If} ${AtLeastWin7}
# Windows 7 或更高版本
${Else}
MessageBox MB_OK|MB_ICONSTOP "此应用程序需要 Windows 7 或更高版本。"
Abort
${EndIf}
!macroend

View File

@@ -1,31 +0,0 @@
{
"appId": "com.alger.music",
"productName": "AlgerMusic",
"artifactName": "${productName}_${version}_Setup_x86.${ext}",
"directories": {
"output": "dist_electron/win-x86"
},
"files": ["dist/**/*", "package.json", "app.js", "electron/**/*"],
"win": {
"icon": "public/icon.png",
"target": [
{
"target": "nsis",
"arch": ["ia32"]
}
],
"extraFiles": [
{
"from": "installer/installer.nsh",
"to": "$INSTDIR"
}
]
},
"nsis": {
"oneClick": false,
"language": "2052",
"allowToChangeInstallationDirectory": true,
"differentialPackage": true,
"shortcutName": "Alger Music"
}
}

View File

@@ -1,31 +0,0 @@
{
"appId": "com.alger.music",
"productName": "AlgerMusic",
"artifactName": "${productName}_${version}_Setup_x64.${ext}",
"directories": {
"output": "dist_electron/win-x64"
},
"files": ["dist/**/*", "package.json", "app.js", "electron/**/*"],
"win": {
"icon": "public/icon.png",
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"extraFiles": [
{
"from": "installer/installer.nsh",
"to": "$INSTDIR"
}
]
},
"nsis": {
"oneClick": false,
"language": "2052",
"allowToChangeInstallationDirectory": true,
"differentialPackage": true,
"shortcutName": "Alger Music"
}
}

View File

@@ -1,31 +0,0 @@
{
"appId": "com.alger.music",
"productName": "AlgerMusic",
"artifactName": "${productName}_${version}_Setup_arm64.${ext}",
"directories": {
"output": "dist_electron/win-arm64"
},
"files": ["dist/**/*", "package.json", "app.js", "electron/**/*", "!node_modules/**/*"],
"win": {
"icon": "public/icon.png",
"target": [
{
"target": "nsis",
"arch": ["arm64"]
}
],
"extraFiles": [
{
"from": "installer/installer.nsh",
"to": "$INSTDIR"
}
]
},
"nsis": {
"oneClick": false,
"language": "2052",
"allowToChangeInstallationDirectory": true,
"differentialPackage": true,
"shortcutName": "Alger Music"
}
}

20
components.d.ts vendored
View File

@@ -2,40 +2,32 @@
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
export {};
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
MPop: typeof import('./src/components/common/MPop.vue')['default']
MusicList: typeof import('./src/components/MusicList.vue')['default']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default']
PlayListsItem: typeof import('./src/components/common/PlayListsItem.vue')['default']
PlaylistType: typeof import('./src/components/PlaylistType.vue')['default']
PlayVideo: typeof import('./src/components/common/PlayVideo.vue')['default']
RecommendAlbum: typeof import('./src/components/RecommendAlbum.vue')['default']
RecommendSinger: typeof import('./src/components/RecommendSinger.vue')['default']
RecommendSonglist: typeof import('./src/components/RecommendSonglist.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchItem: typeof import('./src/components/common/SearchItem.vue')['default']
SongItem: typeof import('./src/components/common/SongItem.vue')['default']
}
}

3
dev-app-update.yml Normal file
View File

@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-lan-file-updater

BIN
docs/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
docs/image1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
docs/image2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
docs/image3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

45
electron-builder.yml Normal file
View File

@@ -0,0 +1,45 @@
appId: com.electron.app
productName: electron-lan-file
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: electron-lan-file
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/

60
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,60 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { resolve } from 'path';
import AutoImport from 'unplugin-auto-import/vite';
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
import Components from 'unplugin-vue-components/vite';
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@': resolve('src/renderer'),
'@renderer': resolve('src/renderer')
}
},
plugins: [
vue(),
viteCompression(),
// VueDevTools(),
AutoImport({
imports: [
'vue',
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar']
}
]
}),
Components({
resolvers: [NaiveUiResolver()]
})
],
server: {
proxy: {
// with options
[process.env.VITE_API_LOCAL as string]: {
target: process.env.VITE_API,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_LOCAL}`), '')
},
[process.env.VITE_API_MUSIC_PROXY as string]: {
target: process.env.VITE_API_MUSIC,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_MUSIC_PROXY}`), '')
},
[process.env.VITE_API_PROXY_MUSIC as string]: {
target: process.env.VITE_API_PROXY,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_PROXY_MUSIC}`), '')
}
}
}
}
});

View File

@@ -1,71 +0,0 @@
const { BrowserWindow } = require('electron');
const path = require('path');
let lyricWindow = null;
const createWin = () => {
lyricWindow = new BrowserWindow({
width: 800,
height: 300,
frame: false,
show: false,
transparent: true,
webPreferences: {
nodeIntegration: true,
preload: `${__dirname}/preload.js`,
contextIsolation: false,
},
});
};
const loadLyricWindow = (ipcMain) => {
ipcMain.on('open-lyric', () => {
if (lyricWindow) {
if (lyricWindow.isMinimized()) lyricWindow.restore();
lyricWindow.focus();
lyricWindow.show();
return;
}
createWin();
if (process.env.NODE_ENV === 'development') {
lyricWindow.webContents.openDevTools({ mode: 'detach' });
lyricWindow.loadURL('http://localhost:4678/#/lyric');
} else {
const distPath = path.resolve(__dirname, '../dist');
lyricWindow.loadURL(`file://${distPath}/index.html#/lyric`);
}
lyricWindow.setMinimumSize(600, 200);
// 隐藏任务栏
lyricWindow.setSkipTaskbar(true);
lyricWindow.show();
});
ipcMain.on('send-lyric', (e, data) => {
if (lyricWindow) {
lyricWindow.webContents.send('receive-lyric', data);
}
});
ipcMain.on('top-lyric', (e, data) => {
lyricWindow.setAlwaysOnTop(data);
});
ipcMain.on('close-lyric', () => {
lyricWindow.close();
lyricWindow = null;
});
ipcMain.on('mouseenter-lyric', () => {
lyricWindow.setIgnoreMouseEvents(true);
});
ipcMain.on('mouseleave-lyric', () => {
lyricWindow.setIgnoreMouseEvents(false);
});
};
module.exports = {
loadLyricWindow,
};

View File

@@ -1,28 +0,0 @@
const { contextBridge, ipcRenderer, app } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
minimize: () => ipcRenderer.send('minimize-window'),
maximize: () => ipcRenderer.send('maximize-window'),
close: () => ipcRenderer.send('close-window'),
dragStart: (data) => ipcRenderer.send('drag-start', data),
miniTray: () => ipcRenderer.send('mini-tray'),
restart: () => ipcRenderer.send('restart'),
openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
});
const electronHandler = {
ipcRenderer: {
setStoreValue: (key, value) => {
ipcRenderer.send('setStore', key, value);
},
getStoreValue(key) {
const resp = ipcRenderer.sendSync('getStore', key);
return resp;
},
},
app,
};
contextBridge.exposeInMainWorld('electron', electronHandler);

View File

@@ -1,5 +0,0 @@
{
"version": "1.5.1",
"isProxy": false,
"author": "alger"
}

View File

@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>网抑云 | algerkong</title>
<link rel="manifest" href="./public/manifest.json" />
<link rel="stylesheet" href="./public/icon/iconfont.css" />
<link rel="stylesheet" href="./public/css/animate.css" />
<link rel="stylesheet" href="./public/css/base.css" />
<style>
:root {
--animate-delay: 0.5s;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,61 +1,153 @@
{
"name": "alger-music",
"version": "2.0.0",
"description": "这是一个用于音乐播放的应用程序。",
"name": "AlgerMusicPlayer",
"version": "3.0.0",
"description": "Alger Music Player",
"author": "Alger <algerkc@qq.com>",
"main": "app.js",
"main": "./out/main/index.js",
"homepage": "https://github.com/algerkong/AlgerMusicPlayer",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"start": "set NODE_ENV=development&&electron .",
"lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
"b:win:x64": "electron-builder --config ./build/win64.json",
"b:win:x86": "electron-builder --config ./build/win32.json",
"b:win:arm": "electron-builder --config ./build/winarm64.json"
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"electron-store": "^8.1.0"
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@unblockneteasemusic/server": "^0.27.8-patch.1",
"electron-store": "^8.1.0",
"electron-updater": "^6.1.7",
"netease-cloud-music-api-alger": "^4.25.0"
},
"devDependencies": {
"devDependencies": {
"marked": "^15.0.4",
"@electron-toolkit/eslint-config": "^1.0.2",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@rushstack/eslint-patch": "^1.10.3",
"@tailwindcss/postcss7-compat": "^2.2.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.1.3",
"@types/howler": "^2.2.12",
"@types/node": "^20.14.8",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/compiler-sfc": "^3.5.0",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/runtime-core": "^3.5.0",
"@vueuse/core": "^11.0.3",
"@vueuse/electron": "^11.0.3",
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
"electron": "^32.0.1",
"electron-builder": "^25.0.5",
"eslint": "^8.56.0",
"cross-env": "^7.0.3",
"electron": "^31.0.2",
"electron-builder": "^24.13.3",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-vue": "^9.21.1",
"eslint-plugin-vue": "^9.26.0",
"eslint-plugin-vue-scoped-css": "^2.7.2",
"howler": "^2.2.4",
"lodash": "^4.17.21",
"naive-ui": "^2.39.0",
"postcss": "^8.4.44",
"prettier": "^3.3.3",
"postcss": "^8.4.49",
"prettier": "^3.3.2",
"remixicon": "^4.2.0",
"sass": "^1.78.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^5.5.4",
"sass": "^1.82.0",
"tailwindcss": "^3.4.15",
"typescript": "^5.5.2",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4",
"vfonts": "^0.1.0",
"vite": "^5.4.3",
"vite": "^5.3.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "7.4.0",
"vue": "^3.5.0",
"vue": "^3.4.30",
"vue-router": "^4.4.3",
"vue-tsc": "^2.1.4",
"vue-tsc": "^2.0.22",
"vuex": "^4.1.0"
},
"build": {
"appId": "com.alger.music",
"productName": "AlgerMusicPlayer",
"publish": [{
"provider": "github",
"owner": "algerkong",
"repo": "AlgerMusicPlayer"
}],
"mac": {
"icon": "resources/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"universal"
]
}
],
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"darkModeSupport": true,
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"notarize": false,
"identity": null,
"type": "distribution",
"binaries": [
"Contents/MacOS/AlgerMusicPlayer"
]
},
"win": {
"icon": "resources/favicon.ico",
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
}
],
"artifactName": "${productName}-${version}-win-${arch}.${ext}",
"requestedExecutionLevel": "asInvoker"
},
"linux": {
"icon": "resources/icon.png",
"target": [
{
"target": "AppImage",
"arch": ["x64"]
},
{
"target": "deb",
"arch": ["x64"]
}
],
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",
"category": "Audio",
"maintainer": "Alger <algerkc@qq.com>"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerIcon": "resources/favicon.ico",
"uninstallerIcon": "resources/favicon.ico",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "AlgerMusicPlayer",
"include": "build/installer.nsh"
}
}
}

View File

@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
autoprefixer: {}
}
};

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
body{
background-color: #000;
}
.n-popover:has(.music-play){
border-radius: 1.5rem !important;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

View File

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

BIN
resources/icon.icns Normal file

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
resources/icon_16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -1,48 +0,0 @@
<template>
<div class="app" :class="isMobile ? 'mobile' : ''">
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
<n-config-provider :theme="darkTheme">
<n-dialog-provider>
<router-view></router-view>
</n-dialog-provider>
</n-config-provider>
</div>
</template>
<script lang="ts" setup>
import { darkTheme } from 'naive-ui';
import store from '@/store';
import { isMobile } from './utils';
const playMusicUrl = computed(() => store.state.playMusicUrl as string);
// 是否播放
const play = computed(() => store.state.play as boolean);
const windowData = window as any;
onMounted(() => {
if (windowData.electron) {
const setData = windowData.electron.ipcRenderer.getStoreValue('set');
store.commit('setSetData', setData);
}
});
</script>
<style lang="scss" scoped>
div {
box-sizing: border-box;
}
.app {
user-select: none;
}
.mobile {
.text-base {
font-size: 14px !important;
}
}
.html:has(.mobile) {
font-size: 14px;
}
</style>

View File

@@ -1,30 +0,0 @@
import { IData } from '@/type';
import { IMvItem, IMvUrlData } from '@/type/mv';
import request from '@/utils/request';
// 获取 mv 排行
export const getTopMv = (limit: number) => {
return request.get<IData<Array<IMvItem>>>('/top/mv', {
params: {
limit,
},
});
};
// 获取 mv 数据
export const getMvDetail = (mvid: string) => {
return request.get('/mv/detail', {
params: {
mvid,
},
});
};
// 获取 mv 地址
export const getMvUrl = (id: Number) => {
return request.get<IData<IMvUrlData>>('/mv/url', {
params: {
id,
},
});
};

View File

@@ -1,199 +0,0 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100vh' : '70vh'"
placement="bottom"
block-scroll
mask-closable
:style="{ backgroundColor: 'transparent' }"
@mask-click="close"
>
<div class="music-page">
<div class="music-close">
<i class="icon ri-layout-column-line" @click="doubleDisply = !doubleDisply"></i>
<i class="icon iconfont icon-icon_error" @click="close"></i>
</div>
<div class="music-title text-el">{{ name }}</div>
<!-- 歌单歌曲列表 -->
<div class="music-list">
<n-scrollbar @scroll="handleScroll">
<div
v-loading="loading || !songList.length"
class="music-list-content"
:class="{ 'double-list': doubleDisply }"
>
<div
v-for="(item, index) in displayedSongs"
:key="item.id"
class="double-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 5)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
</div>
<play-bottom />
</n-scrollbar>
<!-- <n-virtual-list :item-size="42" :items="displayedSongs" item-resizable @scroll="handleScroll">
<template #default="{ item, index }">
<div :key="item.id" class="double-item">
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
</template>
</n-virtual-list>
<play-bottom /> -->
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
const store = useStore();
const {
songList,
loading = false,
listInfo,
} = defineProps<{
show: boolean;
name: string;
songList: any[];
loading?: boolean;
listInfo?: any;
}>();
const emit = defineEmits(['update:show', 'update:loading']);
const page = ref(0);
const pageSize = 20;
const total = ref(0);
const isLoadingMore = ref(false);
const displayedSongs = ref<any[]>([]);
// 双排显示开关
const doubleDisply = ref(false);
const formatDetail = computed(() => (detail: any) => {
const song = {
artists: detail.ar,
name: detail.al.name,
id: detail.al.id,
};
detail.song = song;
detail.picUrl = detail.al.picUrl;
return detail;
});
const handlePlay = () => {
const tracks = songList || [];
store.commit(
'setPlayList',
tracks.map((item) => ({
...item,
picUrl: item.al.picUrl,
song: {
artists: item.ar,
},
})),
);
};
const close = () => {
emit('update:show', false);
};
const loadMoreSongs = async () => {
if (displayedSongs.value.length >= total.value) return;
isLoadingMore.value = true;
try {
const trackIds = listInfo.trackIds
.slice(page.value * pageSize, (page.value + 1) * pageSize)
.map((item: any) => item.id);
const reslist = await getMusicDetail(trackIds);
// displayedSongs.value = displayedSongs.value.concat(reslist.data.songs);
displayedSongs.value = JSON.parse(JSON.stringify([...displayedSongs.value, ...reslist.data.songs]));
page.value++;
} catch (error) {
console.error('error', error);
} finally {
isLoadingMore.value = false;
}
};
const handleScroll = (e: any) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop + clientHeight >= scrollHeight - 50 && !isLoadingMore.value) {
loadMoreSongs();
}
};
watch(
() => songList,
(newSongs) => {
displayedSongs.value = JSON.parse(JSON.stringify(newSongs));
total.value = listInfo ? listInfo.trackIds.length : displayedSongs.value.length;
},
{ deep: true, immediate: true },
);
</script>
<style scoped lang="scss">
.music {
&-page {
@apply px-8 w-full h-full bg-black bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px);
}
&-title {
@apply text-lg font-bold text-white p-4;
}
&-close {
@apply absolute top-4 right-8 cursor-pointer text-white flex gap-2 items-center;
.icon {
@apply text-3xl;
}
}
&-list {
height: calc(100% - 60px);
&-content {
min-height: 400px;
}
}
}
.mobile {
.music-page {
@apply px-4;
}
}
.loading-more {
@apply text-center text-white py-10;
}
.double-list {
@apply flex flex-wrap gap-5;
.double-item {
width: calc(50% - 10px);
}
.song-item {
background-color: #191919;
}
}
</style>

View File

@@ -1,83 +0,0 @@
<template>
<!-- 歌单分类列表 -->
<div class="play-list-type">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
<div>
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
<span
v-show="isShowAllPlaylistCategory || index <= 19"
class="play-list-type-item"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index <= 19 ? index : index - 19)"
@click="handleClickPlaylistType(item.name)"
>{{ item.name }}</span
>
</template>
<div
class="play-list-type-showall"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30)"
@click="isShowAllPlaylistCategory = !isShowAllPlaylistCategory"
>
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import type { IPlayListSort } from '@/type/playlist';
import { setAnimationClass, setAnimationDelay } from '@/utils';
// 歌单分类
const playlistCategory = ref<IPlayListSort>();
// 是否显示全部歌单分类
const isShowAllPlaylistCategory = ref<boolean>(false);
// 加载歌单分类
const loadPlaylistCategory = async () => {
const { data } = await getPlaylistCategory();
playlistCategory.value = data;
};
const router = useRouter();
const handleClickPlaylistType = (type: string) => {
router.push({
path: '/list',
query: {
type,
},
});
};
// 页面初始化
onMounted(() => {
loadPlaylistCategory();
});
</script>
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4;
}
.play-list-type {
width: 250px;
@apply mx-6;
&-item,
&-showall {
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 transition;
background-color: #1a1a1a;
}
&-showall {
@apply block text-center;
}
}
.mobile {
.play-list-type {
@apply mx-0 w-full;
}
}
</style>

View File

@@ -1,70 +0,0 @@
<template>
<n-drawer :show="show" height="100vh" placement="bottom" :z-index="999999999">
<div class="mv-detail">
<video :src="url" controls autoplay></video>
<div class="mv-detail-title">
<div class="title">{{ title }}</div>
<button @click="close">
<i class="iconfont icon-xiasanjiaoxing"></i>
</button>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
const props = defineProps<{
show: boolean;
title: string;
url: string;
}>();
const store = useStore();
watch(
() => props.show,
(val) => {
if (val) {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
}
},
);
const emit = defineEmits(['update:show']);
const close = () => {
emit('update:show', false);
};
</script>
<style scoped lang="scss">
.mv-detail {
@apply w-full h-full bg-black relative;
&-title {
@apply absolute w-full left-0 flex justify-between h-16 px-6 py-2 text-xl font-bold items-center z-50 transition-all duration-300 ease-in-out -top-24;
background: linear-gradient(0, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%);
button .icon-xiasanjiaoxing {
@apply text-3xl;
}
button:hover {
@apply text-green-400;
}
}
video {
@apply w-full h-full;
}
video:hover + .mv-detail-title {
@apply top-0;
}
.mv-detail-title:hover {
@apply top-0;
}
}
</style>

View File

@@ -1,178 +0,0 @@
<template>
<div class="song-item" :class="{ 'song-mini': mini }">
<n-image
v-if="item.picUrl"
ref="songImg"
:src="getImgUrl(item.picUrl, '40y40')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous',
}"
@load="imageLoad"
/>
<div class="song-item-content">
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
>{{ artists.name }}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
>
</n-ellipsis>
</div>
</div>
<div class="song-item-operating">
<div class="song-item-operating-like">
<i class="iconfont icon-likefill"></i>
</div>
<div
class="song-item-operating-play bg-black animate__animated"
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
@click="playMusicEvent(item)"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
const props = withDefaults(
defineProps<{
item: SongResult;
mini?: boolean;
}>(),
{
mini: false,
},
);
const store = useStore();
const play = computed(() => store.state.play as boolean);
const playMusic = computed(() => store.state.playMusic);
const playLoading = computed(() => playMusic.value.id === props.item.id && playMusic.value.playLoading);
// 判断是否为正在播放的音乐
const isPlaying = computed(() => {
return playMusic.value.id === props.item.id;
});
const emits = defineEmits(['play']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
if (!songImageRef.value) {
return;
}
const { backgroundColor } = await getImageBackground(
(songImageRef.value as any).imageRef as unknown as HTMLImageElement,
);
// eslint-disable-next-line vue/no-mutating-props
props.item.backgroundColor = backgroundColor;
};
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => {
if (playMusic.value.id === item.id) {
if (play.value) {
store.commit('setPlayMusic', false);
} else {
store.commit('setPlayMusic', true);
}
return;
}
await store.commit('setPlay', item);
store.commit('setIsPlay', true);
emits('play', item);
};
</script>
<style lang="scss" scoped>
// 配置文字不可选中
.text-ellipsis {
width: 100%;
}
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition;
&-img {
@apply w-12 h-12 rounded-2xl mr-4;
}
&-content {
@apply flex-1;
&-title {
@apply text-base text-white;
}
&-name {
@apply text-xs;
@apply text-gray-400;
}
}
&-operating {
@apply flex items-center pl-4 rounded-full border border-gray-700 ml-4;
background-color: #0d0d0d;
.iconfont {
@apply text-xl;
}
.icon-likefill {
color: #868686;
@apply text-xl hover:text-red-600 transition;
}
&-like {
@apply mr-2 cursor-pointer;
}
&-play {
@apply cursor-pointer border border-gray-500 rounded-full w-10 h-10 flex justify-center items-center hover:bg-green-600 transition;
animation-iteration-count: infinite;
}
}
}
.song-mini {
@apply p-2 rounded-2xl;
.song-item {
@apply p-0;
&-img {
@apply w-10 h-10 mr-2;
}
&-content {
@apply flex-1;
&-title {
@apply text-sm;
}
&-name {
@apply text-xs;
}
}
&-operating {
@apply pl-2;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1;
}
&-play {
@apply w-8 h-8;
}
}
}
}
</style>

10
src/electron.d.ts vendored
View File

@@ -1,10 +0,0 @@
declare global {
interface Window {
electronAPI: {
minimize: () => void;
maximize: () => void;
close: () => void;
dragStart: () => void;
};
}
}

View File

@@ -1,198 +0,0 @@
import { computed, ref } from 'vue';
import { getMusicLrc } from '@/api/music';
import store from '@/store';
import { ILyric } from '@/type/lyric';
import type { ILyricText, SongResult } from '@/type/music';
const windowData = window as any;
export const isElectron = computed(() => !!windowData.electronAPI);
export const lrcArray = ref<ILyricText[]>([]); // 歌词数组
export const lrcTimeArray = ref<number[]>([]); // 歌词时间数组
export const nowTime = ref(0); // 当前播放时间
export const allTime = ref(0); // 总播放时间
export const nowIndex = ref(0); // 当前播放歌词
export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const audio = ref<HTMLAudioElement>(); // 音频对象
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
watch(
() => store.state.playMusic,
() => {
nextTick(() => {
lrcArray.value = playMusic.value.lyric?.lrcArray || [];
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
});
},
{
deep: true,
},
);
const isPlaying = computed(() => store.state.play as boolean);
// 增加矫正时间
export const addCorrectionTime = (time: number) => (correctionTime.value += time);
// 减少矫正时间
export const reduceCorrectionTime = (time: number) => (correctionTime.value -= time);
// 获取当前播放歌词
export const isCurrentLrc = (index: number, time: number): boolean => {
const currentTime = lrcTimeArray.value[index];
const nextTime = lrcTimeArray.value[index + 1];
const nowTime = time + correctionTime.value;
const isTrue = nowTime > currentTime && nowTime < nextTime;
return isTrue;
};
// 获取当前播放歌词INDEX
export const getLrcIndex = (time: number): number => {
for (let i = 0; i < lrcTimeArray.value.length; i++) {
if (isCurrentLrc(i, time)) {
nowIndex.value = i;
return i;
}
}
return nowIndex.value;
};
// 获取当前播放歌词进度
const currentLrcTiming = computed(() => {
const start = lrcTimeArray.value[nowIndex.value] || 0;
const end = lrcTimeArray.value[nowIndex.value + 1] || start + 1;
return { start, end };
});
// 获取歌词样式
export const getLrcStyle = (index: number) => {
if (index === nowIndex.value) {
return {
backgroundImage: `linear-gradient(to right, #ffffff ${currentLrcProgress.value}%, #ffffff8a ${currentLrcProgress.value}%)`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
transition: 'background-image 0.1s linear',
};
}
return {};
};
watch(nowTime, (newTime) => {
const newIndex = getLrcIndex(newTime);
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0; // 重置进度
}
});
// 播放进度
export const useLyricProgress = () => {
let animationFrameId: number | null = null;
const updateProgress = () => {
if (!isPlaying.value) return;
audio.value = audio.value || (document.querySelector('#MusicAudio') as HTMLAudioElement);
if (!audio.value) return;
const { start, end } = currentLrcTiming.value;
const duration = end - start;
const elapsed = audio.value.currentTime - start;
currentLrcProgress.value = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
animationFrameId = requestAnimationFrame(updateProgress);
};
const startProgressAnimation = () => {
if (!animationFrameId && isPlaying.value) {
updateProgress();
}
};
const stopProgressAnimation = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
};
watch(isPlaying, (newIsPlaying) => {
if (newIsPlaying) {
startProgressAnimation();
} else {
stopProgressAnimation();
}
});
onMounted(() => {
if (isPlaying.value) {
startProgressAnimation();
}
});
onUnmounted(() => {
stopProgressAnimation();
});
return {
currentLrcProgress,
getLrcStyle,
};
};
// 设置当前播放时间
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
audio.currentTime = lrcTimeArray.value[index];
audio.play();
};
// 获取当前播放的歌词
export const getCurrentLrc = () => {
const index = getLrcIndex(nowTime.value);
return {
currentLrc: lrcArray.value[index],
nextLrc: lrcArray.value[index + 1],
};
};
// 获取一句歌词播放时间是 几秒到几秒
export const getLrcTimeRange = (index: number) => ({
currentTime: lrcTimeArray.value[index],
nextTime: lrcTimeArray.value[index + 1],
});
export const sendLyricToWin = (isPlay: boolean = true) => {
if (!isElectron.value) return;
try {
if (lrcArray.value.length > 0) {
const nowIndex = getLrcIndex(nowTime.value);
const { currentLrc, nextLrc } = getCurrentLrc();
const { currentTime, nextTime } = getLrcTimeRange(nowIndex);
// 设置lyricWinData 获取 当前播放的两句歌词 和歌词时间
const lyricWinData = {
currentLrc,
nextLrc,
currentTime,
nextTime,
nowIndex,
lrcTimeArray: lrcTimeArray.value,
lrcArray: lrcArray.value,
nowTime: nowTime.value,
allTime: allTime.value,
startCurrentTime: lrcTimeArray.value[nowIndex],
isPlay,
};
windowData.electronAPI.sendLyric(JSON.stringify(lyricWinData));
}
} catch (error) {
console.error('Error sending lyric to window:', error);
}
};
export const openLyric = () => {
if (!isElectron.value) return;
windowData.electronAPI.openLyric();
sendLyricToWin();
};

View File

@@ -1,15 +0,0 @@
/* ./src/index.css */
/*! @import */
@tailwind base;
@tailwind components;
@tailwind utilities;
.n-image img {
background-color: #111111;
width: 100%;
}
.text-el {
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
}

View File

@@ -1,163 +0,0 @@
<template>
<div class="layout-page">
<div class="layout-main" :style="{ background: backgroundColor }">
<title-bar v-if="isElectron" />
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
<!-- 侧边菜单栏 -->
<app-menu v-if="!isMobile" class="menu" :menus="menus" />
<div class="main">
<!-- 搜索栏 -->
<search-bar />
<!-- 主页面路由 -->
<div class="main-content" :native-scrollbar="false">
<n-message-provider>
<router-view
v-slot="{ Component }"
class="main-page"
:class="route.meta.noScroll && !isMobile ? 'pr-3' : ''"
>
<keep-alive :include="keepAliveInclude">
<component :is="Component" />
</keep-alive>
</router-view>
</n-message-provider>
</div>
<play-bottom height="5rem" />
<app-menu v-if="isMobile" class="menu" :menus="menus" />
</div>
</div>
<!-- 底部音乐播放 -->
<play-bar v-if="isPlay" />
</div>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import PlayBottom from '@/components/common/PlayBottom.vue';
import { isElectron } from '@/hooks/MusicHook';
import homeRouter from '@/router/home';
import { isMobile } from '@/utils';
const keepAliveInclude = computed(() =>
homeRouter
.filter((item) => {
return item.meta.keepAlive;
})
.map((item) => {
// return item.name;
// 首字母大写
return item.name.charAt(0).toUpperCase() + item.name.slice(1);
}),
);
const AppMenu = defineAsyncComponent(() => import('./components/AppMenu.vue'));
const PlayBar = defineAsyncComponent(() => import('./components/PlayBar.vue'));
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean);
const { menus } = store.state;
const play = computed(() => store.state.play as boolean);
const route = useRoute();
const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
const backgroundColor = ref('#000');
// watch(
// () => store.state.playMusic,
// () => {
// backgroundColor.value = store.state.playMusic.backgroundColor;
// console.log('backgroundColor.value', backgroundColor.value);
// },
// {
// immediate: true,
// deep: true,
// },
// );
onMounted(() => {
// 监听音乐是否播放
watch(
() => play.value,
(value) => {
if (value && audio.value) {
audioPlay();
} else {
audioPause();
}
},
);
document.onkeyup = (e) => {
switch (e.code) {
case 'Space':
playMusicEvent();
break;
default:
}
};
});
const audioPlay = () => {
if (audio.value) {
audio.value.play();
}
};
const audioPause = () => {
if (audio.value) {
audio.value.pause();
}
};
const playMusicEvent = async () => {
if (play.value) {
store.commit('setPlayMusic', false);
} else {
store.commit('setPlayMusic', true);
}
};
</script>
<style lang="scss" scoped>
.layout-page {
width: 100vw;
height: 100vh;
@apply flex justify-center items-center overflow-hidden bg-black;
}
.layout-main {
@apply text-white shadow-xl flex flex-col relative transition-all;
height: 100%;
width: 100%;
overflow: hidden;
&-page {
@apply flex flex-1 overflow-hidden;
}
.main {
@apply flex-1 box-border flex flex-col overflow-hidden;
height: 100%;
&-content {
@apply box-border flex-1 overflow-hidden;
}
}
// :deep(.n-scrollbar-content) {
// @apply pr-3;
// }
}
.mobile {
.layout-main {
&-page {
@apply pt-4;
}
}
}
</style>

View File

@@ -1,106 +0,0 @@
<template>
<div>
<!-- menu -->
<div class="app-menu">
<div class="app-menu-header">
<div class="app-menu-logo">
<img src="/icon.png" class="w-9 h-9" alt="logo" />
</div>
</div>
<div class="app-menu-list">
<div v-for="(item, index) in menus" :key="item.path" class="app-menu-item">
<router-link class="app-menu-item-link" :to="item.path">
<i class="iconfont app-menu-item-icon" :style="iconStyle(index)" :class="item.meta.icon"></i>
<span v-if="isText" class="app-menu-item-text ml-3">{{ item.meta.title }}</span>
</router-link>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
const props = defineProps({
isText: {
type: Boolean,
default: false,
},
size: {
type: String,
default: '26px',
},
color: {
type: String,
default: '#aaa',
},
selectColor: {
type: String,
default: '#10B981',
},
menus: {
type: Array as any,
default: () => [],
},
});
const route = useRoute();
const path = ref(route.path);
watch(
() => route.path,
async (newParams) => {
path.value = newParams;
},
);
const iconStyle = (index: number) => {
const style = {
fontSize: props.size,
color: path.value === props.menus[index].path ? props.selectColor : props.color,
};
return style;
};
</script>
<style lang="scss" scoped>
.app-menu {
@apply flex-col items-center justify-center px-6;
max-width: 100px;
}
.app-menu-item-link,
.app-menu-header {
@apply flex items-center justify-center;
}
.app-menu-item-link {
@apply mb-6 mt-6;
}
.app-menu-item-icon:hover {
color: #10b981 !important;
transform: scale(1.05);
transition: 0.2s ease-in-out;
}
.mobile {
.app-menu {
max-width: 100%;
width: 100vw;
position: relative;
z-index: 999999;
background-color: #000;
&-header {
display: none;
}
&-list {
@apply flex justify-between;
}
&-item {
&-link {
@apply my-4;
}
}
}
}
</style>

View File

@@ -1,217 +0,0 @@
<template>
<n-drawer :show="musicFull" height="100vh" placement="bottom" :style="{ background: background }">
<div id="drawer-target">
<div class="drawer-back"></div>
<div class="music-img">
<n-image ref="PicImgRef" :src="getImgUrl(playMusic?.picUrl, '300y300')" class="img" lazy preview-disabled />
<div>
<div class="music-content-name">{{ playMusic.name }}</div>
<div class="music-content-singer">
<span v-for="(item, index) in playMusic.song.artists" :key="index">
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
</span>
</div>
</div>
</div>
<div class="music-content">
<n-layout
ref="lrcSider"
class="music-lrc"
style="height: 60vh"
:native-scrollbar="false"
@mouseover="mouseOverLayout"
@mouseleave="mouseLeaveLayout"
>
<div ref="lrcContainer">
<div
v-for="(item, index) in lrcArray"
:id="`music-lrc-text-${index}`"
:key="index"
class="music-lrc-text"
:class="{ 'now-text': index === nowIndex }"
@click="setAudioTime(index, audio)"
>
<span :style="getLrcStyle(index)">{{ item.text }}</span>
<div class="music-lrc-text-tr">{{ item.trText }}</div>
</div>
</div>
</n-layout>
<!-- 时间矫正 -->
<!-- <div class="music-content-time">
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
<n-button @click="addCorrectionTime(0.2)">+</n-button>
</div> -->
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core';
import {
addCorrectionTime,
lrcArray,
nowIndex,
playMusic,
reduceCorrectionTime,
setAudioTime,
useLyricProgress,
} from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
const { getLrcStyle } = useLyricProgress();
// const isPlaying = computed(() => store.state.play as boolean);
// 获取歌词滚动dom
const lrcSider = ref<any>(null);
const isMouse = ref(false);
const lrcContainer = ref<HTMLElement | null>(null);
const props = defineProps({
musicFull: {
type: Boolean,
default: false,
},
audio: {
type: HTMLAudioElement,
default: null,
},
background: {
type: String,
default: '',
},
});
// 歌词滚动方法
const lrcScroll = (behavior = 'smooth') => {
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`);
if (props.musicFull && !isMouse.value && nowEl && lrcContainer.value) {
const containerRect = lrcContainer.value.getBoundingClientRect();
const nowElRect = nowEl.getBoundingClientRect();
const relativeTop = nowElRect.top - containerRect.top;
const scrollTop = relativeTop - lrcSider.value.$el.getBoundingClientRect().height / 2;
lrcSider.value.scrollTo({ top: scrollTop, behavior });
}
};
const debouncedLrcScroll = useDebounceFn(lrcScroll, 200);
const mouseOverLayout = () => {
isMouse.value = true;
};
const mouseLeaveLayout = () => {
setTimeout(() => {
isMouse.value = false;
lrcScroll();
}, 2000);
};
watch(nowIndex, () => {
debouncedLrcScroll();
});
watch(
() => props.musicFull,
() => {
if (props.musicFull) {
nextTick(() => {
lrcScroll('instant');
});
}
},
);
defineExpose({
lrcScroll,
});
</script>
<style scoped lang="scss">
@keyframes round {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.drawer-back {
@apply absolute bg-cover bg-center;
z-index: -1;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
}
.drawer-back.paused {
animation-play-state: paused;
}
#drawer-target {
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
backdrop-filter: blur(20px);
animation-duration: 300ms;
.music-img {
@apply flex-1 flex justify-center mr-16 flex-col;
max-width: 360px;
max-height: 360px;
.img {
@apply rounded-xl w-full h-full shadow-2xl;
}
}
.music-content {
@apply flex flex-col justify-center items-center relative;
&-name {
@apply font-bold text-xl pb-1 pt-4;
}
&-singer {
@apply text-base;
}
}
.music-content-time {
display: none;
@apply flex justify-center items-center;
}
.music-lrc {
background-color: inherit;
width: 500px;
height: 550px;
&-text {
@apply text-2xl cursor-pointer font-bold px-2 py-4;
color: #ffffff8a;
// transition: all 0.5s ease;
span {
padding-right: 100px;
}
&:hover {
@apply font-bold opacity-100 rounded-xl;
background-color: #ffffff26;
color: #fff;
}
&-tr {
@apply font-normal;
}
}
}
}
.mobile {
#drawer-target {
@apply flex-col p-4 pt-8;
.music-img {
display: none;
}
.music-lrc {
height: calc(100vh - 260px) !important;
}
}
}
</style>

View File

@@ -1,379 +0,0 @@
<template>
<!-- 展开全屏 -->
<music-full
ref="MusicFullRef"
v-model:music-full="musicFullVisible"
:audio="audio.value as HTMLAudioElement"
:background="background"
/>
<!-- 底部播放栏 -->
<div
class="music-play-bar"
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
>
<n-image
:src="getImgUrl(playMusic?.picUrl, '300y300')"
class="play-bar-img"
lazy
preview-disabled
@click="setMusicFull"
/>
<div class="music-content">
<div class="music-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">
{{ playMusic.name }}
</n-ellipsis>
</div>
<div class="music-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span v-for="(item, index) in playMusic.song.artists" :key="index">
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
</span>
</n-ellipsis>
</div>
</div>
<div class="music-buttons">
<div class="music-buttons-prev" @click="handlePrev">
<i class="iconfont icon-prev"></i>
</div>
<div class="music-buttons-play" @click="playMusicEvent">
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
</div>
<div class="music-buttons-next" @click="handleEnded">
<i class="iconfont icon-next"></i>
</div>
</div>
<div class="music-time">
<div class="time">{{ getNowTime }}</div>
<n-slider v-model:value="timeSlider" :step="0.05" :tooltip="false"></n-slider>
<div class="time">{{ getAllTime }}</div>
</div>
<div class="audio-volume">
<div>
<i class="iconfont icon-notificationfill"></i>
</div>
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false"></n-slider>
</div>
<div class="audio-button">
<!-- <n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-likefill"></i>
</template>
喜欢
</n-tooltip> -->
<!-- <n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-Play" @click="parsingMusic"></i>
</template>
解析播放
</n-tooltip> -->
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont ri-netease-cloud-music-line" @click="openLyric"></i>
</template>
歌词
</n-tooltip>
<n-popover
trigger="click"
:z-index="99999999"
content-class="music-play"
raw
:show-arrow="false"
:delay="200"
arrow-wrapper-style=" border-radius:1.5rem"
@update-show="scrollToPlayList"
>
<template #trigger>
<n-tooltip trigger="manual" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-list"></i>
</template>
播放列表
</n-tooltip>
</template>
<div class="music-play-list">
<div class="music-play-list-back"></div>
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
<template #default="{ item }">
<div class="music-play-list-content">
<song-item :key="item.id" :item="item" mini></song-item>
</div>
</template>
</n-virtual-list>
</div>
</n-popover>
</div>
<!-- 播放音乐 -->
</div>
</template>
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, getCurrentLrc, isElectron, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
import MusicFull from './MusicFull.vue';
const store = useStore();
// 播放的音乐信息
const playMusic = computed(() => store.state.playMusic as SongResult);
// 是否播放
const play = computed(() => store.state.play as boolean);
const playList = computed(() => store.state.playList as SongResult[]);
const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
const background = ref('#000');
watch(
() => store.state.playMusic,
async () => {
background.value = playMusic.value.backgroundColor as string;
},
{ immediate: true, deep: true },
);
const audioPlay = () => {
if (audio.value) {
audio.value.play();
}
};
// 计算属性 获取当前播放时间的进度
const timeSlider = computed({
get: () => (nowTime.value / allTime.value) * 100,
set: (value) => {
if (!audio.value) return;
audio.value.currentTime = (value * allTime.value) / 100;
audioPlay();
store.commit('setPlayMusic', true);
},
});
// 音量条
const audioVolume = ref(1);
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
if (!audio.value) return;
audio.value.volume = value / 100;
},
});
// 获取当前播放时间
const getNowTime = computed(() => {
return secondToMinute(nowTime.value);
});
// 获取总时间
const getAllTime = computed(() => {
return secondToMinute(allTime.value);
});
// 监听音乐播放 获取时间
const onAudio = () => {
if (audio.value) {
audio.value.removeEventListener('timeupdate', handleGetAudioTime);
audio.value.removeEventListener('ended', handleEnded);
audio.value.addEventListener('timeupdate', handleGetAudioTime);
audio.value.addEventListener('ended', handleEnded);
// 监听音乐播放暂停
audio.value.addEventListener('pause', () => {
store.commit('setPlayMusic', false);
});
audio.value.addEventListener('play', () => {
store.commit('setPlayMusic', true);
});
}
};
onAudio();
function handleEnded() {
store.commit('nextPlay');
}
function handlePrev() {
store.commit('prevPlay');
}
const MusicFullRef = ref<any>(null);
function handleGetAudioTime(this: HTMLAudioElement) {
// 监听音频播放的实时时间事件
const audio = this as HTMLAudioElement;
// 获取当前播放时间
nowTime.value = audio.currentTime;
getCurrentLrc();
// 获取总时间
allTime.value = audio.duration;
// 获取音量
audioVolume.value = audio.volume;
sendLyricToWin(store.state.isPlay);
// if (musicFullVisible.value) {
// MusicFullRef.value?.lrcScroll();
// }
}
// 播放暂停按钮事件
const playMusicEvent = async () => {
if (play.value) {
store.commit('setPlayMusic', false);
} else {
store.commit('setPlayMusic', true);
}
};
const musicFullVisible = ref(false);
// 设置musicFull
const setMusicFull = () => {
musicFullVisible.value = !musicFullVisible.value;
};
const palyListRef = useTemplateRef('palyListRef');
const scrollToPlayList = (val: boolean) => {
if (!val) return;
setTimeout(() => {
palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 });
}, 50);
};
</script>
<style lang="scss" scoped>
.text-ellipsis {
width: 100%;
}
.music-play-bar {
@apply h-20 w-full absolute bottom-0 left-0 flex items-center rounded-t-2xl overflow-hidden box-border px-6 py-2;
z-index: 9999;
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
background-color: #212121;
animation-duration: 0.5s !important;
.music-content {
width: 140px;
@apply ml-4;
&-title {
@apply text-base text-white;
}
&-name {
@apply text-xs mt-1;
@apply text-gray-400;
}
}
}
.play-bar-opcity {
@apply bg-transparent;
box-shadow: 0 0 20px 5px #0000001d;
}
.play-bar-img {
@apply w-14 h-14 rounded-2xl;
}
.music-buttons {
@apply mx-6;
.iconfont {
@apply text-2xl hover:text-green-500 transition;
}
.icon {
@apply text-xl hover:text-white;
}
@apply flex items-center;
> div {
@apply cursor-pointer;
}
&-play {
background: #383838;
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition bg-opacity-40;
}
}
.music-time {
@apply flex flex-1 items-center;
.time {
@apply mx-4 mt-1;
}
}
.audio-volume {
width: 140px;
@apply flex items-center mx-4;
.iconfont {
@apply text-2xl hover:text-green-500 transition cursor-pointer mr-4;
}
}
.audio-button {
@apply flex items-center mx-4;
.iconfont {
@apply text-2xl hover:text-green-500 transition cursor-pointer m-4;
}
}
.music-play {
&-list {
height: 50vh;
width: 300px;
@apply relative rounded-3xl overflow-hidden py-2;
&-back {
backdrop-filter: blur(20px);
@apply absolute top-0 left-0 w-full h-full bg-gray-800 bg-opacity-75;
}
&-content {
@apply mx-2;
}
}
}
.mobile {
.music-play-bar {
@apply px-4;
bottom: 70px;
}
.music-time {
display: none;
}
.ri-netease-cloud-music-line {
display: none;
}
.audio-volume {
display: none;
}
.audio-button {
@apply mx-0;
}
.music-buttons {
@apply m-0;
&-prev,
&-next {
display: none;
}
&-play {
@apply m-0;
}
}
.music-content {
flex: 1;
}
}
</style>

View File

@@ -1,157 +0,0 @@
<template>
<div class="search-box flex">
<div class="search-box-input flex-1">
<n-input
v-model:value="searchValue"
size="medium"
round
:placeholder="hotSearchKeyword"
class="border border-gray-600"
@keydown.enter="search"
>
<template #prefix>
<i class="iconfont icon-search"></i>
</template>
<template #suffix>
<div class="w-20 px-3 flex justify-between items-center">
<div>{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }}</div>
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
<i class="iconfont icon-xiasanjiaoxing"></i>
</n-dropdown>
</div>
</template>
</n-input>
</div>
<div class="user-box">
<n-dropdown trigger="hover" :options="userSetOptions" @select="selectItem">
<i class="iconfont icon-xiasanjiaoxing"></i>
</n-dropdown>
<n-avatar
v-if="store.state.user"
class="ml-2 cursor-pointer"
circle
size="medium"
:src="getImgUrl(store.state.user.avatarUrl)"
/>
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getSearchKeyword } from '@/api/home';
import { getUserDetail, logout } from '@/api/login';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { getImgUrl } from '@/utils';
const router = useRouter();
const store = useStore();
const userSetOptions = ref(USER_SET_OPTIONS);
// 推荐热搜词
const hotSearchKeyword = ref('搜索点什么吧...');
const hotSearchValue = ref('');
const loadHotSearchKeyword = async () => {
const { data } = await getSearchKeyword();
hotSearchKeyword.value = data.data.showKeyword;
hotSearchValue.value = data.data.realkeyword;
};
const loadPage = async () => {
const token = localStorage.getItem('token');
if (!token) return;
const { data } = await getUserDetail();
store.state.user = data.profile;
localStorage.setItem('user', JSON.stringify(data.profile));
};
loadPage();
watchEffect(() => {
if (store.state.user) {
userSetOptions.value = USER_SET_OPTIONS;
} else {
userSetOptions.value = USER_SET_OPTIONS.filter((item) => item.key !== 'logout');
}
});
const toLogin = () => {
router.push('/login');
};
// 页面初始化
onMounted(() => {
loadHotSearchKeyword();
loadPage();
});
// 搜索词
const searchValue = ref('');
const search = () => {
const { value } = searchValue;
if (value === '') {
searchValue.value = hotSearchValue.value;
return;
}
if (router.currentRoute.value.path === '/search') {
store.state.searchValue = value;
return;
}
router.push({
path: '/search',
query: {
keyword: value,
},
});
};
const selectSearchType = (key: number) => {
store.state.searchType = key;
};
const searchTypeOptions = ref(SEARCH_TYPES);
const selectItem = async (key: string) => {
// switch 判断
switch (key) {
case 'logout':
logout().then(() => {
store.state.user = null;
localStorage.clear();
router.push('/login');
});
break;
case 'login':
router.push('/login');
break;
case 'set':
router.push('/set');
break;
default:
}
};
</script>
<style lang="scss" scoped>
.user-box {
@apply ml-4 flex text-lg justify-center items-center rounded-full pl-3 border border-gray-600;
background: #1a1a1a;
}
.search-box {
@apply pb-4 pr-4;
}
.search-box-input {
@apply relative;
}
.mobile {
.search-box {
@apply pl-4;
}
}
</style>

209
src/main/index.ts Normal file
View File

@@ -0,0 +1,209 @@
import { electronApp, is, optimizer } from '@electron-toolkit/utils';
import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, shell, Tray } from 'electron';
import Store from 'electron-store';
import { join } from 'path';
import set from './set.json';
// 导入所有图标
const iconPath = join(__dirname, '../../resources');
const icon = nativeImage.createFromPath(
process.platform === 'darwin'
? join(iconPath, 'icon.icns')
: process.platform === 'win32'
? join(iconPath, 'favicon.ico')
: join(iconPath, 'icon.png')
);
import { loadLyricWindow } from './lyric';
import { startMusicApi } from './server';
let mainWindow: BrowserWindow;
function createWindow(): void {
startMusicApi();
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1200,
height: 780,
show: false,
frame: false,
autoHideMenuBar: true,
icon,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true
}
});
mainWindow.setMinimumSize(1200, 780);
mainWindow.on('ready-to-show', () => {
mainWindow.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
// 创建托盘图标
const trayIcon = nativeImage.createFromPath(join(iconPath, 'icon_16x16.png')).resize({ width: 16, height: 16 });
const tray = new Tray(trayIcon);
// 创建一个上下文菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示',
click: () => {
mainWindow.show();
},
},
{
label: '退出',
click: () => {
mainWindow.destroy();
app.quit();
},
},
]);
// 设置系统托盘图标的上下文菜单
tray.setContextMenu(contextMenu);
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
tray.on('click', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
loadLyricWindow(ipcMain, mainWindow);
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.alger.music');
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// IPC test
ipcMain.on('ping', () => console.log('pong'));
createWindow();
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('ready', () => {
globalShortcut.register('CommandOrControl+Alt+Shift+M', () => {
if (mainWindow.isVisible()) {
mainWindow.hide();
} else {
mainWindow.show();
}
});
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
ipcMain.on('minimize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.minimize();
}
});
ipcMain.on('maximize-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
}
});
ipcMain.on('close-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.destroy();
app.quit();
}
});
ipcMain.on('drag-start', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.webContents.beginFrameSubscription((frameBuffer) => {
event.reply('frame-buffer', frameBuffer);
});
}
});
ipcMain.on('mini-tray', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
win.hide();
}
});
// 重启
ipcMain.on('restart', () => {
app.relaunch();
app.exit(0);
});
const store = new Store({
name: 'config', // 配置文件名
defaults: {
set: set
}
});
// 定义ipcRenderer监听事件
ipcMain.on('set-store-value', (_, key, value) => {
store.set(key, value);
});
ipcMain.on('get-store-value', (_, key) => {
const value = store.get(key);
_.returnValue = value || '';
});
// 添加 IPC 处理程序
ipcMain.on('get-arch', (event) => {
event.returnValue = process.arch;
});
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.

176
src/main/lyric.ts Normal file
View File

@@ -0,0 +1,176 @@
import { BrowserWindow, IpcMain, screen } from 'electron';
import Store from 'electron-store';
import path, { join } from 'path';
const store = new Store();
let lyricWindow: BrowserWindow | null = null;
const createWin = () => {
console.log('Creating lyric window');
// 获取保存的窗口位置
const windowBounds =
(store.get('lyricWindowBounds') as {
x?: number;
y?: number;
width?: number;
height?: number;
}) || {};
const { x, y, width, height } = windowBounds;
// 获取屏幕尺寸
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
// 验证保存的位置是否有效
const validPosition =
x !== undefined && y !== undefined && x >= 0 && y >= 0 && x < screenWidth && y < screenHeight;
lyricWindow = new BrowserWindow({
width: width || 800,
height: height || 200,
x: validPosition ? x : undefined,
y: validPosition ? y : undefined,
frame: false,
show: false,
transparent: true,
hasShadow: false,
alwaysOnTop: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true
}
});
// 监听窗口关闭事件
lyricWindow.on('closed', () => {
if (lyricWindow) {
lyricWindow.destroy();
lyricWindow = null;
}
});
return lyricWindow;
};
export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void => {
const showLyricWindow = () => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
if (lyricWindow.isMinimized()) {
lyricWindow.restore();
}
lyricWindow.focus();
lyricWindow.show();
return true;
}
return false;
};
ipcMain.on('open-lyric', () => {
console.log('Received open-lyric request');
if (showLyricWindow()) {
return;
}
console.log('Creating new lyric window');
const win = createWin();
if (!win) {
console.error('Failed to create lyric window');
return;
}
if (process.env.NODE_ENV === 'development') {
win.webContents.openDevTools({ mode: 'detach' });
win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/#/lyric`);
} else {
const distPath = path.resolve(__dirname, '../renderer');
win.loadURL(`file://${distPath}/index.html#/lyric`);
}
win.setMinimumSize(600, 200);
win.setSkipTaskbar(true);
win.once('ready-to-show', () => {
console.log('Lyric window ready to show');
win.show();
});
});
ipcMain.on('send-lyric', (_, data) => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
try {
lyricWindow.webContents.send('receive-lyric', data);
} catch (error) {
console.error('Error processing lyric data:', error);
}
}
});
ipcMain.on('top-lyric', (_, data) => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
lyricWindow.setAlwaysOnTop(data);
}
});
ipcMain.on('close-lyric', () => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
lyricWindow.webContents.send('lyric-window-close');
mainWin.webContents.send('lyric-control-back', 'close');
lyricWindow.destroy();
lyricWindow = null;
}
});
// 处理鼠标事件
ipcMain.on('mouseenter-lyric', () => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
lyricWindow.setIgnoreMouseEvents(true);
}
});
ipcMain.on('mouseleave-lyric', () => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
lyricWindow.setIgnoreMouseEvents(false);
}
});
// 处理拖动移动
ipcMain.on('lyric-drag-move', (_, { deltaX, deltaY }) => {
if (!lyricWindow || lyricWindow.isDestroyed()) return;
const [currentX, currentY] = lyricWindow.getPosition();
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
const [windowWidth, windowHeight] = lyricWindow.getSize();
// 计算新位置,确保窗口不会移出屏幕
const newX = Math.max(0, Math.min(currentX + deltaX, screenWidth - windowWidth));
const newY = Math.max(0, Math.min(currentY + deltaY, screenHeight - windowHeight));
lyricWindow.setPosition(newX, newY);
// 保存新位置
store.set('lyricWindowBounds', {
...lyricWindow.getBounds(),
x: newX,
y: newY
});
});
// 添加鼠标穿透事件处理
ipcMain.on('set-ignore-mouse', (_, shouldIgnore) => {
if (!lyricWindow || lyricWindow.isDestroyed()) return;
lyricWindow.setIgnoreMouseEvents(shouldIgnore, { forward: true });
});
// 添加播放控制处理
ipcMain.on('control-back', (_, command) => {
console.log('command', command);
if (mainWin && !mainWin.isDestroyed()) {
console.log('Sending control-back command:', command);
mainWin.webContents.send('lyric-control-back', command);
}
});
};

32
src/main/server.ts Normal file
View File

@@ -0,0 +1,32 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { unblockMusic } from './unblockMusic';
const store = new Store();
if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
}
// 处理解锁音乐请求
ipcMain.handle('unblock-music', async (_, id) => {
return unblockMusic(id);
});
import server from 'netease-cloud-music-api-alger/server';
async function startMusicApi(): Promise<void> {
console.log('MUSIC API STARTED');
const port = (store.get('set') as any).musicApiPort || 30488;
await server.serveNcmApi({
port
});
}
export { startMusicApi };

8
src/main/set.json Normal file
View File

@@ -0,0 +1,8 @@
{
"isProxy": false,
"noAnimate": false,
"animationSpeed": 1,
"author": "Alger",
"authorUrl": "https://github.com/algerkong",
"musicApiPort": 30488
}

23
src/main/unblockMusic.ts Normal file
View File

@@ -0,0 +1,23 @@
import match from '@unblockneteasemusic/server';
const unblockMusic = async (id: any): Promise<any> => {
return new Promise((resolve, reject) => {
match(parseInt(id, 10), ['qq', 'migu', 'kugou', 'joox'])
.then((data) => {
resolve({
data: {
data,
params: {
id,
type: 'song'
}
}
});
})
.catch((err) => {
reject(err);
});
});
};
export { unblockMusic };

18
src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { ElectronAPI } from '@electron-toolkit/preload';
declare global {
interface Window {
electron: ElectronAPI;
api: {
sendLyric: (data: string) => void;
openLyric: () => void;
minimize: () => void;
maximize: () => void;
close: () => void;
dragStart: (data: string) => void;
miniTray: () => void;
restart: () => void;
unblockMusic: (id: number) => Promise<any>;
};
}
}

32
src/preload/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { electronAPI } from '@electron-toolkit/preload';
import { contextBridge, ipcRenderer } from 'electron';
// Custom APIs for renderer
const api = {
minimize: () => ipcRenderer.send('minimize-window'),
maximize: () => ipcRenderer.send('maximize-window'),
close: () => ipcRenderer.send('close-window'),
dragStart: (data) => ipcRenderer.send('drag-start', data),
miniTray: () => ipcRenderer.send('mini-tray'),
restart: () => ipcRenderer.send('restart'),
openLyric: () => ipcRenderer.send('open-lyric'),
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
unblockMusic: (id) => ipcRenderer.invoke('unblock-music', id)
};
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api);
} catch (error) {
console.error(error);
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts)
window.api = api;
}

58
src/renderer/App.vue Normal file
View File

@@ -0,0 +1,58 @@
<template>
<div class="app-container" :class="{ mobile: isMobile, noElectron: !isElectron }">
<n-config-provider :theme="theme === 'dark' ? darkTheme : lightTheme">
<n-dialog-provider>
<n-message-provider>
<router-view></router-view>
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</div>
</template>
<script setup lang="ts">
import { darkTheme, lightTheme } from 'naive-ui';
import { onMounted } from 'vue';
import { isElectron } from '@/utils';
import homeRouter from '@/router/home';
import store from '@/store';
import { isMobile } from './utils';
const theme = computed(() => {
return store.state.theme;
});
onMounted(() => {
store.dispatch('initializeSettings');
store.dispatch('initializeTheme');
if (isMobile.value) {
store.commit(
'setMenus',
homeRouter.filter((item) => item.meta.isMobile)
);
console.log(
'qqq ',
homeRouter.filter((item) => item.meta.isMobile)
);
}
});
</script>
<style lang="scss" scoped>
.app-container {
@apply h-full w-full;
user-select: none;
}
.mobile {
.text-base {
font-size: 14px !important;
}
}
.html:has(.mobile) {
font-size: 14px;
}
</style>

View File

@@ -22,7 +22,7 @@ export function getListByTag(params: IListByTagParams) {
// 根据cat 获取歌单列表
export function getListByCat(params: IListByCatParams) {
return request.get('/top/playlist', {
params,
params
});
}

View File

@@ -41,6 +41,6 @@ export function logout() {
export function loginByCellphone(phone: string, password: string) {
return request.post('/login/cellphone', {
phone,
password,
password
});
}

View File

@@ -1,5 +1,6 @@
import { ILyric } from '@/type/lyric';
import { IPlayMusicUrl } from '@/type/music';
import { isElectron } from '@/utils';
import request from '@/utils/request';
import requestMusic from '@/utils/request_music';
// 根据音乐Id获取音乐播放URl
@@ -18,5 +19,8 @@ export const getMusicLrc = (id: number) => {
};
export const getParsingMusicUrl = (id: number) => {
if (isElectron) {
return window.api.unblockMusic(id);
}
return requestMusic.get<any>('/music', { params: { id } });
};

45
src/renderer/api/mv.ts Normal file
View File

@@ -0,0 +1,45 @@
import { IData } from '@/type';
import { IMvUrlData } from '@/type/mv';
import request from '@/utils/request';
interface MvParams {
limit?: number;
offset?: number;
area?: string;
}
// 获取 mv 排行
export const getTopMv = (params: MvParams) => {
return request({
url: '/mv/all',
method: 'get',
params
});
};
// 获取所有mv
export const getAllMv = (params: MvParams) => {
return request({
url: '/mv/all',
method: 'get',
params
});
};
// 获取 mv 数据
export const getMvDetail = (mvid: string) => {
return request.get('/mv/detail', {
params: {
mvid
}
});
};
// 获取 mv 地址
export const getMvUrl = (id: Number) => {
return request.get<IData<IMvUrlData>>('/mv/url', {
params: {
id
}
});
};

View File

@@ -7,6 +7,6 @@ interface IParams {
// 搜索内容
export const getSearch = (params: IParams) => {
return request.get<any>('/cloudsearch', {
params,
params
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3687
src/renderer/assets/css/animate.css vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
body {
/* background-color: #000; */
}
.n-popover:has(.music-play) {
border-radius: 1.5rem !important;
}

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,12 +1,13 @@
@font-face {
font-family: "iconfont"; /* Project id 2685283 */
src: url('iconfont.woff2?t=1703643214551') format('woff2'),
url('iconfont.woff?t=1703643214551') format('woff'),
url('iconfont.ttf?t=1703643214551') format('truetype');
font-family: 'iconfont'; /* Project id 2685283 */
src:
url('iconfont.woff2?t=1703643214551') format('woff2'),
url('iconfont.woff?t=1703643214551') format('woff'),
url('iconfont.ttf?t=1703643214551') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
@@ -14,270 +15,269 @@
}
.icon-list:before {
content: "\e603";
content: '\e603';
}
.icon-maxsize:before {
content: "\e692";
content: '\e692';
}
.icon-close:before {
content: "\e616";
content: '\e616';
}
.icon-minisize:before {
content: "\e602";
content: '\e602';
}
.icon-shuaxin:before {
content: "\e627";
content: '\e627';
}
.icon-icon_error:before {
content: "\e615";
content: '\e615';
}
.icon-a-3User:before {
content: "\e601";
content: '\e601';
}
.icon-Chat:before {
content: "\e605";
content: '\e605';
}
.icon-Category:before {
content: "\e606";
content: '\e606';
}
.icon-Document:before {
content: "\e607";
content: '\e607';
}
.icon-Heart:before {
content: "\e608";
content: '\e608';
}
.icon-Hide:before {
content: "\e609";
content: '\e609';
}
.icon-Home:before {
content: "\e60a";
content: '\e60a';
}
.icon-a-Image2:before {
content: "\e60b";
content: '\e60b';
}
.icon-Profile:before {
content: "\e60c";
content: '\e60c';
}
.icon-Search:before {
content: "\e60d";
content: '\e60d';
}
.icon-Paper:before {
content: "\e60e";
content: '\e60e';
}
.icon-Play:before {
content: "\e60f";
content: '\e60f';
}
.icon-Setting:before {
content: "\e610";
content: '\e610';
}
.icon-a-TicketStar:before {
content: "\e611";
content: '\e611';
}
.icon-a-VolumeOff:before {
content: "\e612";
content: '\e612';
}
.icon-a-VolumeUp:before {
content: "\e613";
content: '\e613';
}
.icon-a-VolumeDown:before {
content: "\e614";
content: '\e614';
}
.icon-stop:before {
content: "\e600";
content: '\e600';
}
.icon-next:before {
content: "\e6a9";
content: '\e6a9';
}
.icon-prev:before {
content: "\e6ac";
content: '\e6ac';
}
.icon-play:before {
content: "\e6aa";
content: '\e6aa';
}
.icon-xiasanjiaoxing:before {
content: "\e642";
content: '\e642';
}
.icon-videofill:before {
content: "\e7c7";
content: '\e7c7';
}
.icon-favorfill:before {
content: "\e64b";
content: '\e64b';
}
.icon-favor:before {
content: "\e64c";
content: '\e64c';
}
.icon-loading:before {
content: "\e64f";
content: '\e64f';
}
.icon-search:before {
content: "\e65c";
content: '\e65c';
}
.icon-likefill:before {
content: "\e668";
content: '\e668';
}
.icon-like:before {
content: "\e669";
content: '\e669';
}
.icon-notificationfill:before {
content: "\e66a";
content: '\e66a';
}
.icon-notification:before {
content: "\e66b";
content: '\e66b';
}
.icon-evaluate:before {
content: "\e672";
content: '\e672';
}
.icon-homefill:before {
content: "\e6bb";
content: '\e6bb';
}
.icon-link:before {
content: "\e6bf";
content: '\e6bf';
}
.icon-roundaddfill:before {
content: "\e6d8";
content: '\e6d8';
}
.icon-roundadd:before {
content: "\e6d9";
content: '\e6d9';
}
.icon-add:before {
content: "\e6da";
content: '\e6da';
}
.icon-appreciatefill:before {
content: "\e6e3";
content: '\e6e3';
}
.icon-forwardfill:before {
content: "\e6ea";
content: '\e6ea';
}
.icon-voicefill:before {
content: "\e6f0";
content: '\e6f0';
}
.icon-wefill:before {
content: "\e6f4";
content: '\e6f4';
}
.icon-keyboard:before {
content: "\e71b";
content: '\e71b';
}
.icon-picfill:before {
content: "\e72c";
content: '\e72c';
}
.icon-markfill:before {
content: "\e730";
content: '\e730';
}
.icon-presentfill:before {
content: "\e732";
content: '\e732';
}
.icon-peoplefill:before {
content: "\e735";
content: '\e735';
}
.icon-read:before {
content: "\e742";
content: '\e742';
}
.icon-backwardfill:before {
content: "\e74d";
content: '\e74d';
}
.icon-playfill:before {
content: "\e74f";
content: '\e74f';
}
.icon-all:before {
content: "\e755";
content: '\e755';
}
.icon-hotfill:before {
content: "\e757";
content: '\e757';
}
.icon-recordfill:before {
content: "\e7a4";
content: '\e7a4';
}
.icon-full:before {
content: "\e7bc";
content: '\e7bc';
}
.icon-favor_fill_light:before {
content: "\e7ec";
content: '\e7ec';
}
.icon-round_favor_fill:before {
content: "\e80a";
content: '\e80a';
}
.icon-round_location_fill:before {
content: "\e80b";
content: '\e80b';
}
.icon-round_like_fill:before {
content: "\e80c";
content: '\e80c';
}
.icon-round_people_fill:before {
content: "\e80d";
content: '\e80d';
}
.icon-round_skin_fill:before {
content: "\e80e";
content: '\e80e';
}
.icon-broadcast_fill:before {
content: "\e81d";
content: '\e81d';
}
.icon-card_fill:before {
content: "\e81f";
content: '\e81f';
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

75
src/renderer/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,75 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

37
src/renderer/components.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -0,0 +1,69 @@
<template>
<div class="relative inline-block">
<n-popover trigger="hover" placement="top" :show-arrow="true" :raw="true" :delay="100">
<template #trigger>
<slot>
<n-button
quaternary
class="inline-flex items-center gap-2 px-4 py-2 transition-all duration-300 hover:-translate-y-0.5"
>
请我喝咖啡
</n-button>
</slot>
</template>
<div class="p-6 rounded-lg shadow-lg bg-light dark:bg-gray-800">
<div class="flex gap-10">
<div class="flex flex-col items-center gap-2">
<n-image
:src="alipayQR"
alt="支付宝收款码"
class="w-32 h-32 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">支付宝</span>
</div>
<div class="flex flex-col items-center gap-2">
<n-image
:src="wechatQR"
alt="微信收款码"
class="w-32 h-32 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">微信支付</span>
</div>
</div>
<div class="mt-4">
<p
class="text-sm text-gray-700 dark:text-gray-200 text-center cursor-pointer hover:text-green-500"
@click="copyQQ"
>
QQ群789288579
</p>
</div>
</div>
</n-popover>
</div>
</template>
<script setup>
import { NButton, NImage, NPopover } from 'naive-ui';
const message = useMessage();
const copyQQ = () => {
navigator.clipboard.writeText('789288579');
message.success('已复制到剪贴板');
};
defineProps({
alipayQR: {
type: String,
required: true
},
wechatQR: {
type: String,
required: true
}
});
</script>

View File

@@ -0,0 +1,316 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100%' : '80%'"
placement="bottom"
block-scroll
mask-closable
:style="{ backgroundColor: 'transparent' }"
:to="`#layout-main`"
@mask-click="close"
>
<div class="music-page">
<div class="music-header h-12 flex items-center justify-between">
<n-ellipsis :line-clamp="1">
<div class="music-title">
{{ name }}
</div>
</n-ellipsis>
<div class="music-close">
<i class="icon iconfont icon-icon_error" @click="close"></i>
</div>
</div>
<div class="music-content">
<!-- 左侧歌单信息 -->
<div class="music-info">
<div class="music-cover">
<n-image
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '500y500')"
class="cover-img"
preview-disabled
:class="setAnimationClass('animate__fadeIn')"
object-fit="cover"
/>
</div>
<div v-if="listInfo?.creator" class="creator-info">
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
</div>
<n-scrollbar style="max-height: 200">
<div v-if="listInfo?.description" class="music-desc">
{{ listInfo.description }}
</div>
<play-bottom />
</n-scrollbar>
</div>
<!-- 右侧歌曲列表 -->
<div class="music-list-container">
<div class="music-list">
<n-scrollbar @scroll="handleScroll">
<n-spin :show="loadingList || loading">
<div class="music-list-content">
<div
v-for="(item, index) in displayedSongs"
:key="item.id"
class="double-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="getItemAnimationDelay(index)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
<play-bottom />
</div>
</n-spin>
</n-scrollbar>
</div>
<play-bottom />
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
const store = useStore();
const props = withDefaults(
defineProps<{
show: boolean;
name: string;
songList: any[];
loading?: boolean;
listInfo?: {
trackIds: { id: number }[];
[key: string]: any;
};
cover?: boolean;
}>(),
{
loading: false,
cover: true
}
);
const emit = defineEmits(['update:show', 'update:loading']);
const page = ref(0);
const pageSize = 20;
const isLoadingMore = ref(false);
const displayedSongs = ref<any[]>([]);
const loadingList = ref(false);
// 计算总数
const total = computed(() => {
if (props.listInfo?.trackIds) {
return props.listInfo.trackIds.length;
}
return props.songList.length;
});
const formatDetail = computed(() => (detail: any) => {
const song = {
artists: detail.ar,
name: detail.al.name,
id: detail.al.id
};
detail.song = song;
detail.picUrl = detail.al.picUrl;
return detail;
});
const handlePlay = () => {
const tracks = props.songList || [];
store.commit(
'setPlayList',
tracks.map((item) => ({
...item,
picUrl: item.al.picUrl,
song: {
artists: item.ar
}
}))
);
};
const close = () => {
emit('update:show', false);
};
// 优化加载更多歌曲的函数
const loadMoreSongs = async () => {
if (isLoadingMore.value || displayedSongs.value.length >= total.value) return;
isLoadingMore.value = true;
try {
if (props.listInfo?.trackIds) {
// 如果有 trackIds需要分批请求歌曲详情
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, total.value);
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
if (trackIds.length > 0) {
const { data } = await getMusicDetail(trackIds);
displayedSongs.value = [...displayedSongs.value, ...data.songs];
page.value++;
}
} else {
// 如果没有 trackIds直接使用 songList 分页
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
const newSongs = props.songList.slice(start, end);
displayedSongs.value = [...displayedSongs.value, ...newSongs];
page.value++;
}
} catch (error) {
console.error('加载歌曲失败:', error);
} finally {
isLoadingMore.value = false;
loadingList.value = false;
}
};
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize;
return setAnimationDelay(currentPageIndex, 20);
};
// 修改滚动处理函数
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
if (!target) return;
const { scrollTop, scrollHeight, clientHeight } = target;
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) {
loadMoreSongs();
}
};
watch(
() => props.show,
(newVal) => {
loadingList.value = newVal;
if (!props.cover) {
loadingList.value = false;
}
}
);
// 监听 songList 变化,重置分页状态
watch(
() => props.songList,
(newSongs) => {
page.value = 0;
displayedSongs.value = newSongs.slice(0, pageSize);
if (newSongs.length > pageSize) {
page.value = 1;
}
loadingList.value = false;
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.music {
&-title {
@apply text-xl font-bold text-gray-900 dark:text-white;
}
&-page {
@apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px);
}
&-close {
@apply cursor-pointer text-gray-900 dark:text-white flex gap-2 items-center;
.icon {
@apply text-3xl;
}
}
&-content {
@apply flex h-[calc(100%-60px)];
}
&-info {
@apply w-[25%] flex-shrink-0 pr-8 flex flex-col;
.music-cover {
@apply w-full aspect-square rounded-2xl overflow-hidden mb-4 min-h-[250px];
.cover-img {
@apply w-full h-full object-cover;
}
}
.creator-info {
@apply flex items-center mb-4;
.creator-name {
@apply ml-2 text-gray-700 dark:text-gray-300;
}
}
.music-desc {
@apply text-sm text-gray-600 dark:text-gray-400 leading-relaxed pr-4;
}
}
&-list {
@apply flex-grow min-h-0;
&-container {
@apply flex-grow min-h-0 flex flex-col relative;
}
&-content {
@apply min-h-[calc(80vh-60px)];
}
:deep(.n-virtual-list__scroll) {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
}
.mobile {
.music-page {
@apply px-4;
}
.music-content {
@apply flex-col;
}
.music-info {
@apply w-full pr-0 mb-2 flex flex-row;
.music-cover {
@apply w-[100px] h-[100px] rounded-lg overflow-hidden mb-4;
}
.music-detail {
@apply flex-1 ml-4;
}
}
}
.loading-more {
@apply text-center py-4 text-gray-500 dark:text-gray-400;
}
.double-item {
@apply mb-2 bg-light-100 bg-opacity-20 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
}
</style>

View File

@@ -0,0 +1,695 @@
<template>
<n-drawer :show="show" height="100%" placement="bottom" :z-index="999999999" :to="`#layout-main`">
<div class="mv-detail">
<div
ref="videoContainerRef"
class="video-container"
:class="{ 'cursor-hidden': !showCursor }"
>
<video
ref="videoRef"
:src="mvUrl"
class="video-player"
@ended="handleEnded"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@play="isPlaying = true"
@pause="isPlaying = false"
@click="togglePlay"
></video>
<div v-if="autoPlayBlocked" class="play-hint" @click="togglePlay">
<n-button quaternary circle size="large">
<template #icon>
<n-icon size="48">
<i class="ri-play-circle-line"></i>
</n-icon>
</template>
</n-button>
</div>
<div class="custom-controls" :class="{ 'controls-hidden': !showControls }">
<div class="progress-bar custom-slider">
<n-slider
v-model:value="progress"
:min="0"
:max="100"
:tooltip="false"
:step="0.1"
@update:value="handleProgressChange"
>
<template #rail>
<div class="progress-rail">
<div class="progress-buffer" :style="{ width: `${bufferedProgress}%` }"></div>
</div>
</template>
</n-slider>
</div>
<div class="controls-main">
<div class="left-controls">
<n-tooltip v-if="!props.noList" placement="top">
<template #trigger>
<n-button quaternary circle @click="handlePrev">
<template #icon>
<n-icon size="24">
<n-spin v-if="prevLoading" size="small" />
<i v-else class="ri-skip-back-line"></i>
</n-icon>
</template>
</n-button>
</template>
上一个
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="togglePlay">
<template #icon>
<n-icon size="24">
<n-spin v-if="playLoading" size="small" />
<i v-else :class="isPlaying ? 'ri-pause-line' : 'ri-play-line'"></i>
</n-icon>
</template>
</n-button>
</template>
{{ isPlaying ? '暂停' : '播放' }}
</n-tooltip>
<n-tooltip v-if="!props.noList" placement="top">
<template #trigger>
<n-button quaternary circle @click="handleNext">
<template #icon>
<n-icon size="24">
<n-spin v-if="nextLoading" size="small" />
<i v-else class="ri-skip-forward-line"></i>
</n-icon>
</template>
</n-button>
</template>
下一个
</n-tooltip>
<div class="time-display">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
</div>
<div class="right-controls">
<div v-if="!isMobile" class="volume-control custom-slider">
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleMute">
<template #icon>
<n-icon size="24">
<i
:class="volume === 0 ? 'ri-volume-mute-line' : 'ri-volume-up-line'"
></i>
</n-icon>
</template>
</n-button>
</template>
{{ volume === 0 ? '取消静音' : '静音' }}
</n-tooltip>
<n-slider
v-model:value="volume"
:min="0"
:max="100"
:tooltip="false"
class="volume-slider"
/>
</div>
<n-tooltip v-if="!props.noList" placement="top">
<template #trigger>
<n-button quaternary circle class="play-mode-btn" @click="togglePlayMode">
<template #icon>
<n-icon size="24">
<i
:class="
playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'
"
></i>
</n-icon>
</template>
</n-button>
</template>
{{ playMode === 'single' ? '单曲循环' : '列表循环' }}
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleFullscreen">
<template #icon>
<n-icon size="24">
<i
:class="isFullscreen ? 'ri-fullscreen-exit-line' : 'ri-fullscreen-line'"
></i>
</n-icon>
</template>
</n-button>
</template>
{{ isFullscreen ? '退出全屏' : '全屏' }}
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="handleClose">
<template #icon>
<n-icon size="24">
<i class="ri-close-line"></i>
</n-icon>
</template>
</n-button>
</template>
关闭
</n-tooltip>
</div>
</div>
</div>
<!-- 添加模式切换提示 -->
<transition name="fade">
<div v-if="showModeHint" class="mode-hint">
<n-icon size="48" class="mode-icon">
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
</n-icon>
<div class="mode-text">
{{ playMode === 'single' ? '单曲循环' : '自动播放下一个' }}
</div>
</div>
</transition>
</div>
<div class="mv-detail-title" :class="{ 'title-hidden': !showControls }">
<div class="title">
<n-ellipsis>{{ currentMv?.name }}</n-ellipsis>
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { getMvUrl } from '@/api/mv';
import { IMvItem } from '@/type/mv';
type PlayMode = 'single' | 'auto';
const PLAY_MODE = {
Single: 'single' as PlayMode,
Auto: 'auto' as PlayMode
} as const;
const props = withDefaults(
defineProps<{
show: boolean;
currentMv?: IMvItem;
noList?: boolean;
}>(),
{
show: false,
currentMv: undefined,
noList: false
}
);
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'next', loading: (value: boolean) => void): void;
(e: 'prev', loading: (value: boolean) => void): void;
}>();
const store = useStore();
const mvUrl = ref<string>();
const playMode = ref<PlayMode>(PLAY_MODE.Auto);
const videoRef = ref<HTMLVideoElement>();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const progress = ref(0);
const bufferedProgress = ref(0);
const volume = ref(100);
const showControls = ref(true);
let controlsTimer: NodeJS.Timeout | null = null;
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
const togglePlay = () => {
if (!videoRef.value) return;
if (isPlaying.value) {
videoRef.value.pause();
} else {
videoRef.value.play();
}
resetCursorTimer();
};
const toggleMute = () => {
if (!videoRef.value) return;
if (volume.value === 0) {
volume.value = 100;
} else {
volume.value = 0;
}
};
watch(volume, (newVolume) => {
if (videoRef.value) {
videoRef.value.volume = newVolume / 100;
}
});
const handleProgressChange = (value: number) => {
if (!videoRef.value || !duration.value) return;
const newTime = (value / 100) * duration.value;
videoRef.value.currentTime = newTime;
};
const handleTimeUpdate = () => {
if (!videoRef.value) return;
currentTime.value = videoRef.value.currentTime;
if (!isDragging.value) {
progress.value = (currentTime.value / duration.value) * 100;
}
if (videoRef.value.buffered.length > 0) {
bufferedProgress.value = (videoRef.value.buffered.end(0) / duration.value) * 100;
}
};
const handleLoadedMetadata = () => {
if (!videoRef.value) return;
duration.value = videoRef.value.duration;
};
const resetControlsTimer = () => {
if (controlsTimer) {
clearTimeout(controlsTimer);
}
showControls.value = true;
controlsTimer = setTimeout(() => {
if (isPlaying.value) {
showControls.value = false;
}
}, 3000);
};
const handleMouseMove = () => {
resetControlsTimer();
resetCursorTimer();
};
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove);
});
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove);
if (controlsTimer) {
clearTimeout(controlsTimer);
}
if (cursorTimer) {
clearTimeout(cursorTimer);
}
unlockScreenOrientation();
});
// 监听 currentMv 的变化
watch(
() => props.currentMv,
async (newMv) => {
if (newMv) {
await loadMvUrl(newMv);
}
}
);
const autoPlayBlocked = ref(false);
const playLoading = ref(false);
const loadMvUrl = async (mv: IMvItem) => {
playLoading.value = true;
autoPlayBlocked.value = false;
try {
const res = await getMvUrl(mv.id);
mvUrl.value = res.data.data.url;
await nextTick();
if (videoRef.value) {
try {
await videoRef.value.play();
} catch (error) {
console.warn('自动播放失败,可能需要用户交互:', error);
autoPlayBlocked.value = true;
}
}
} catch (error) {
console.error('加载MV地址失败:', error);
} finally {
playLoading.value = false;
}
};
const handleClose = () => {
emit('update:show', false);
if (store.state.playMusicUrl) {
store.commit('setIsPlay', true);
}
};
const handleEnded = () => {
if (playMode.value === PLAY_MODE.Single) {
// 单曲循环模式重新加载当前MV
if (props.currentMv) {
loadMvUrl(props.currentMv);
}
} else {
// 自动播放模式,触发下一个
emit('next', (value: boolean) => {
nextLoading.value = value;
});
}
};
const togglePlayMode = () => {
playMode.value = playMode.value === PLAY_MODE.Auto ? PLAY_MODE.Single : PLAY_MODE.Auto;
showModeHint.value = true;
setTimeout(() => {
showModeHint.value = false;
}, 1500);
};
const isDragging = ref(false);
// 添加全屏相关的状态和方法
const videoContainerRef = ref<HTMLElement>();
const isFullscreen = ref(false);
// 检查是否支持全屏API
const checkFullscreenAPI = () => {
const doc = document as any;
return {
requestFullscreen:
videoContainerRef.value?.requestFullscreen ||
(videoContainerRef.value as any)?.webkitRequestFullscreen ||
(videoContainerRef.value as any)?.mozRequestFullScreen ||
(videoContainerRef.value as any)?.msRequestFullscreen,
exitFullscreen:
doc.exitFullscreen ||
doc.webkitExitFullscreen ||
doc.mozCancelFullScreen ||
doc.msExitFullscreen,
fullscreenElement:
doc.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement,
fullscreenEnabled:
doc.fullscreenEnabled ||
doc.webkitFullscreenEnabled ||
doc.mozFullScreenEnabled ||
doc.msFullscreenEnabled
};
};
// 添加横屏锁定功能
const lockScreenOrientation = async () => {
try {
if ('orientation' in screen) {
await (screen as any).orientation.lock('landscape');
}
} catch (error) {
console.warn('无法锁定屏幕方向:', error);
}
};
const unlockScreenOrientation = () => {
try {
if ('orientation' in screen) {
(screen as any).orientation.unlock();
}
} catch (error) {
console.warn('无法解锁屏幕方向:', error);
}
};
// 修改切换全屏状态的方法
const toggleFullscreen = async () => {
const api = checkFullscreenAPI();
if (!api.fullscreenEnabled) {
console.warn('全屏API不可用');
return;
}
try {
if (!api.fullscreenElement) {
await videoContainerRef.value?.requestFullscreen();
isFullscreen.value = true;
// 在移动端进入全屏时锁定横屏
if (window.innerWidth <= 768) {
await lockScreenOrientation();
}
} else {
await document.exitFullscreen();
isFullscreen.value = false;
// 退出全屏时解锁屏幕方向
if (window.innerWidth <= 768) {
unlockScreenOrientation();
}
}
} catch (error) {
console.error('切换全屏失败:', error);
}
};
// 监听全屏状态变化
const handleFullscreenChange = () => {
const api = checkFullscreenAPI();
isFullscreen.value = !!api.fullscreenElement;
};
// 在组件挂载时添加全屏变化监听
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
});
// 在组件卸载时移除监听
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
});
// 添加键盘快捷键支持
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'f' || e.key === 'F') {
toggleFullscreen();
}
};
onMounted(() => {
// 添加到现有的 onMounted 中
document.addEventListener('keydown', handleKeyPress);
});
onUnmounted(() => {
// 添加到现有的 onUnmounted 中
document.removeEventListener('keydown', handleKeyPress);
});
// 添加提示状态
const showModeHint = ref(false);
// 添加加载状态
const prevLoading = ref(false);
const nextLoading = ref(false);
// 添加处理函数
const handlePrev = () => {
prevLoading.value = true;
emit('prev', (value: boolean) => {
prevLoading.value = value;
});
};
const handleNext = () => {
nextLoading.value = true;
emit('next', (value: boolean) => {
nextLoading.value = value;
});
};
// 添加鼠标显示状态
const showCursor = ref(true);
let cursorTimer: NodeJS.Timeout | null = null;
// 添加重置鼠标计时器的函数
const resetCursorTimer = () => {
if (cursorTimer) {
clearTimeout(cursorTimer);
}
showCursor.value = true;
if (isPlaying.value && !showControls.value) {
cursorTimer = setTimeout(() => {
showCursor.value = false;
}, 3000);
}
};
// 监听播放状态变化
watch(isPlaying, (newValue) => {
if (!newValue) {
showCursor.value = true;
if (cursorTimer) {
clearTimeout(cursorTimer);
}
} else {
resetCursorTimer();
}
});
// 添加控制栏状态监听
watch(showControls, (newValue) => {
if (newValue) {
showCursor.value = true;
if (cursorTimer) {
clearTimeout(cursorTimer);
}
} else {
resetCursorTimer();
}
});
const isMobile = computed(() => store.state.isMobile);
</script>
<style scoped lang="scss">
.mv-detail {
@apply h-full bg-light dark:bg-black;
&-title {
@apply fixed top-0 left-0 right-0 p-4 z-10 transition-opacity duration-300;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent);
.title {
@apply text-white text-lg font-bold;
}
}
}
.video-container {
@apply h-full w-full relative;
.video-player {
@apply h-full w-full object-contain bg-black;
}
.play-hint {
@apply absolute inset-0 flex items-center justify-center bg-black bg-opacity-50;
.n-button {
@apply text-white hover:text-green-500 transition-colors;
}
}
.custom-controls {
@apply absolute bottom-0 left-0 right-0 p-4 transition-opacity duration-300;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
.controls-main {
@apply flex justify-between items-center;
.left-controls,
.right-controls {
@apply flex items-center gap-2;
.n-button {
@apply text-white hover:text-green-500 transition-colors;
}
.time-display {
@apply text-white text-sm ml-4;
}
}
}
}
}
.mode-hint {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center;
.mode-icon {
@apply text-white mb-2;
}
.mode-text {
@apply text-white text-sm;
}
}
.custom-slider {
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: rgba(255, 255, 255, 0.2);
--n-fill-color: #10b981;
--n-handle-size: 12px;
--n-handle-color: #10b981;
}
}
.progress-bar {
@apply mb-4;
.progress-rail {
@apply relative w-full h-1 bg-gray-600;
.progress-buffer {
@apply absolute top-0 left-0 h-full bg-gray-400;
}
}
}
.volume-control {
@apply flex items-center gap-2;
.volume-slider {
width: 100px;
}
}
.controls-hidden {
opacity: 0;
pointer-events: none;
}
.cursor-hidden {
cursor: none;
}
.title-hidden {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<!-- 歌单分类列表 -->
<div class="play-list-type">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
<div>
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
<span
v-show="isShowAllPlaylistCategory || index <= 19 || isHiding"
class="play-list-type-item"
:class="
setAnimationClass(
index <= 19
? 'animate__bounceIn'
: !isShowAllPlaylistCategory
? 'animate__backOutLeft'
: 'animate__bounceIn'
) +
' ' +
'type-item-' +
index
"
:style="getAnimationDelay(index)"
@click="handleClickPlaylistType(item.name)"
>{{ item.name }}</span
>
</template>
<div
class="play-list-type-showall"
:class="setAnimationClass('animate__bounceIn')"
:style="
setAnimationDelay(
!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30
)
"
@click="handleToggleShowAllPlaylistCategory"
>
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import type { IPlayListSort } from '@/type/playlist';
import { setAnimationClass, setAnimationDelay } from '@/utils';
// 歌单分类
const playlistCategory = ref<IPlayListSort>();
// 是否显示全部歌单分类
const isShowAllPlaylistCategory = ref<boolean>(false);
const DELAY_TIME = 40;
const getAnimationDelay = computed(() => {
return (index: number) => {
if (index <= 19) {
return setAnimationDelay(index, DELAY_TIME);
}
if (!isShowAllPlaylistCategory.value) {
const nowIndex = (playlistCategory.value?.sub.length || 0) - index;
return setAnimationDelay(nowIndex, DELAY_TIME);
}
return setAnimationDelay(index - 19, DELAY_TIME);
};
});
watch(isShowAllPlaylistCategory, (newVal) => {
if (!newVal) {
const elements = playlistCategory.value?.sub.map((_, index) =>
document.querySelector(`.type-item-${index}`)
) as HTMLElement[];
elements
.slice(20)
.reverse()
.forEach((element, index) => {
if (element) {
setTimeout(
() => {
(element as HTMLElement).style.position = 'absolute';
},
index * DELAY_TIME + 400
);
}
});
setTimeout(
() => {
isHiding.value = false;
document.querySelectorAll('.play-list-type-item').forEach((element) => {
if (element) {
console.log('element', element);
(element as HTMLElement).style.position = 'none';
}
});
},
(playlistCategory.value?.sub.length || 0 - 19) * DELAY_TIME
);
} else {
document.querySelectorAll('.play-list-type-item').forEach((element) => {
if (element) {
(element as HTMLElement).style.position = 'none';
}
});
}
});
// 加载歌单分类
const loadPlaylistCategory = async () => {
const { data } = await getPlaylistCategory();
playlistCategory.value = data;
};
const router = useRouter();
const handleClickPlaylistType = (type: string) => {
router.push({
path: '/list',
query: {
type
}
});
};
const isHiding = ref<boolean>(false);
const handleToggleShowAllPlaylistCategory = () => {
isShowAllPlaylistCategory.value = !isShowAllPlaylistCategory.value;
if (!isShowAllPlaylistCategory.value) {
isHiding.value = true;
}
};
// 页面初始化
onMounted(() => {
loadPlaylistCategory();
});
</script>
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.play-list-type {
width: 250px;
@apply mr-4;
&-item,
&-showall {
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-200 dark:border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 hover:text-white transition;
}
&-showall {
@apply block text-center;
}
}
.mobile {
.play-list-type {
@apply mx-0 w-full;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="recommend-album">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div>
<div class="title" :class="setAnimationClass('animate__fadeInRight')">最新专辑</div>
<div class="recommend-album-list">
<template v-for="(item, index) in albumData?.albums" :key="item.id">
<div
@@ -20,7 +20,14 @@
</div>
</template>
</div>
<MusicList v-model:show="showMusic" :name="albumName" :song-list="songList" />
<MusicList
v-model:show="showMusic"
:name="albumName"
:song-list="songList"
:cover="false"
:loading="loadingList"
:list-info="albumInfo"
/>
</div>
</template>
@@ -31,6 +38,7 @@ import { getNewAlbum } from '@/api/home';
import { getAlbum } from '@/api/list';
import type { IAlbumNew } from '@/type/album';
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
import MusicList from '@/components/MusicList.vue';
const albumData = ref<IAlbumNew>();
const loadAlbumList = async () => {
@@ -41,15 +49,28 @@ const loadAlbumList = async () => {
const showMusic = ref(false);
const songList = ref([]);
const albumName = ref('');
const loadingList = ref(false);
const albumInfo = ref<any>({});
const handleClick = async (item: any) => {
songList.value = [];
albumInfo.value = {};
albumName.value = item.name;
loadingList.value = true;
showMusic.value = true;
const res = await getAlbum(item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || item.picUrl;
return song;
});
albumInfo.value = {
...res.data.album,
creator: {
avatarUrl: res.data.album.artist.img1v1Url,
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
},
description: res.data.album.description
};
loadingList.value = false;
};
onMounted(() => {
@@ -61,7 +82,7 @@ onMounted(() => {
.recommend-album {
@apply flex-1 mx-5;
.title {
@apply text-lg font-bold mb-4;
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.recommend-album-list {
@@ -75,7 +96,7 @@ onMounted(() => {
filter: brightness(50%);
}
&-content {
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl bg-opacity-60 bg-black;
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl text-white bg-opacity-60 bg-black dark:bg-opacity-60 dark:bg-black;
}
&-content:hover {
opacity: 1;

View File

@@ -10,7 +10,9 @@
:style="setAnimationDelay(0, 100)"
>
<div
:style="setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '300y300'))"
:style="
setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '500y500'))
"
class="recommend-singer-item-bg"
></div>
<div
@@ -20,7 +22,11 @@
<div class="font-bold text-xl">每日推荐</div>
<div class="mt-2">
<p v-for="item in dayRecommendData?.dailySongs.slice(0, 5)" :key="item.id" class="text-el">
<p
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)"
:key="item.id"
class="text-el"
>
{{ item.name }}
<br />
</p>
@@ -34,8 +40,13 @@
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(index + 1, 100)"
>
<div :style="setBackgroundImg(getImgUrl(item.picUrl, '300y300'))" class="recommend-singer-item-bg"></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">{{ item.musicSize }}</div>
<div
:style="setBackgroundImg(getImgUrl(item.picUrl, '500y500'))"
class="recommend-singer-item-bg"
></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
{{ item.musicSize }}
</div>
<div class="recommend-singer-item-info z-10">
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
<i class="iconfont icon-playfill text-xl"></i>
@@ -53,6 +64,7 @@
v-model:show="showMusic"
name="每日推荐列表"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
</div>
</n-scrollbar>
@@ -67,6 +79,7 @@ import router from '@/router';
import { IDayRecommend } from '@/type/day_recommend';
import type { IHotSinger } from '@/type/singer';
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
import MusicList from '@/components/MusicList.vue';
const store = useStore();
@@ -87,14 +100,13 @@ const loadData = async () => {
//
try {
const {
data: { data: dayRecommend },
data: { data: dayRecommend }
} = await getDayRecommend();
console.log('dayRecommend', dayRecommend);
//
if (dayRecommend) {
singerData.artists = singerData.artists.slice(0, 4);
}
dayRecommendData.value = dayRecommend;
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
} catch (error) {
console.error('error', error);
}
@@ -109,8 +121,8 @@ const toSearchSinger = (keyword: string) => {
router.push({
path: '/search',
query: {
keyword,
},
keyword
}
});
};
@@ -131,14 +143,20 @@ watchEffect(() => {
&-item {
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between overflow-hidden;
&-bg {
@apply bg-gray-900 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
filter: brightness(60%);
}
&-info {
@apply flex items-center p-2;
&-play {
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer;
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer text-white;
}
&-name {
@apply text-gray-100 dark:text-gray-100;
}
}
&-count {
@apply text-gray-100 dark:text-gray-100;
}
}
}

View File

@@ -9,7 +9,10 @@
>
<!-- 推荐音乐列表 -->
<template v-for="(item, index) in recommendMusic?.result" :key="item.id">
<div :class="setAnimationClass('animate__bounceInUp')" :style="setAnimationDelay(index, 100)">
<div
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 100)"
>
<song-item :item="item" @play="handlePlay" />
</div>
</template>
@@ -51,17 +54,15 @@ const handlePlay = () => {
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4;
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.recommend-music {
@apply flex-auto;
// width: 530px;
.text-ellipsis {
width: 100%;
}
&-list {
@apply rounded-3xl p-2 w-full border border-gray-700;
background-color: #0d0d0d;
@apply rounded-3xl p-2 w-full border border-gray-200 dark:border-gray-700 bg-light dark:bg-black;
}
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:mask-closable="true"
class="install-app-modal"
>
<div class="modal-content">
<div class="modal-header">
<div class="app-icon">
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">Alger Music Player {{ config.version }}</h2>
<p class="app-desc mb-2">在桌面安装应用获得更好的体验</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div>
</div>
<div class="modal-actions">
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
</div>
<div class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
<a
class="text-green-500"
target="_blank"
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
>GitHub</a
>
下载最新版本
</p>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { isElectron, isMobile } from '@/utils';
import config from '../../../../package.json';
const showModal = ref(false);
const noPrompt = ref(false);
const closeModal = () => {
showModal.value = false;
if (noPrompt.value) {
localStorage.setItem('installPromptDismissed', 'true');
}
};
onMounted(() => {
// 如果是 electron 环境,不显示安装提示
if (isElectron || isMobile.value) {
return;
}
// 检查是否已经点击过"暂不安装"
const isDismissed = localStorage.getItem('installPromptDismissed') === 'true';
if (isDismissed) {
return;
}
showModal.value = true;
});
const handleInstall = async (): Promise<void> => {
const { userAgent } = navigator;
console.log('userAgent', userAgent);
const isMac: boolean = userAgent.includes('Mac');
const isWindows: boolean = userAgent.includes('Win');
const isARM: boolean =
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
const isX64: boolean =
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
const isX86: boolean =
!isX64 &&
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
const getDownloadUrl = (os: string, arch: string): string => {
const version = config.version as string;
const setup = os !== 'mac' ? 'Setup_' : '';
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
};
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
};
</script>
<style lang="scss" scoped>
.install-app-modal {
:deep(.n-modal) {
@apply max-w-sm;
}
.modal-content {
@apply p-4 pb-0;
.modal-header {
@apply flex items-center mb-6;
.app-icon {
@apply w-20 h-20 mr-4 rounded-2xl overflow-hidden;
img {
@apply w-full h-full object-cover;
}
}
.app-info {
@apply flex-1;
.app-name {
@apply text-xl font-bold mb-1;
}
.app-desc {
@apply text-sm text-gray-400;
}
}
}
.modal-actions {
@apply flex gap-3 mt-4;
.n-button {
@apply flex-1;
}
.cancel-btn {
@apply bg-gray-800 text-gray-300 border-none;
&:hover {
@apply bg-gray-700;
}
}
.install-btn {
@apply bg-green-600 border-none;
&:hover {
@apply bg-green-500;
}
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More