Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f728191a8f | ||
|
|
dfa8b51a53 | ||
|
|
b2c13121fd | ||
|
|
d28adb61a4 | ||
|
|
9a7d5a3834 | ||
|
|
2037798fbe | ||
|
|
85bd0ad015 | ||
|
|
e1557a51a3 | ||
|
|
1ecc6f136f | ||
|
|
53b3061b03 | ||
|
|
3d2f6a2330 | ||
|
|
3b1470f28f | ||
|
|
100268448a | ||
|
|
51f67bb2c2 | ||
|
|
7be126cf5f | ||
|
|
f2f5d3ac15 | ||
|
|
34c45e0105 | ||
|
|
f9333f5f78 | ||
|
|
7365daf700 | ||
|
|
cebf313075 | ||
|
|
bb99049991 | ||
|
|
df74dafbc5 | ||
|
|
721d2a9704 | ||
|
|
1e60fa9a95 | ||
|
|
f24e8232f8 | ||
|
|
a1b1d861ac | ||
|
|
f24263b416 | ||
|
|
17795e5da2 | ||
|
|
f1030d3a78 | ||
|
|
b979ce250f | ||
|
|
d0d8966875 | ||
|
|
d39ba65263 | ||
|
|
62d400827e | ||
|
|
75b99c46b5 | ||
|
|
e7ae79144c | ||
|
|
04d6cbe7f3 | ||
|
|
bea1e5751f | ||
|
|
f2ebb04fab | ||
|
|
42048764d5 | ||
|
|
e326253fd8 | ||
|
|
edf5c77ea0 | ||
|
|
8870390770 | ||
|
|
c9514e6e19 | ||
|
|
08fa160de4 | ||
|
|
5d4c4922fd | ||
|
|
c5e7c87658 | ||
|
|
f6923b4c47 | ||
|
|
4cf7598a7d | ||
|
|
81b09bef0d | ||
|
|
b21df3de25 | ||
|
|
c49d814182 | ||
|
|
1cb3c72ab7 | ||
|
|
f03372de6a | ||
|
|
d925f40303 | ||
|
|
dc12d895d8 | ||
|
|
0bb14902f2 | ||
|
|
3027a5f6ff | ||
|
|
f320f4760b | ||
|
|
e939933d6f | ||
|
|
06bffe7618 | ||
|
|
7abc087d70 | ||
|
|
eb2ea1981d | ||
|
|
6dc14ec51b | ||
|
|
36f8257a3e | ||
|
|
c55544df46 | ||
|
|
008f2183de | ||
|
|
dd3a3c3bbb | ||
|
|
941eb2e66e | ||
|
|
a98fcb43d6 | ||
|
|
791121ae06 | ||
|
|
0c156e2708 | ||
|
|
017b47fded | ||
|
|
e27ed22c16 | ||
|
|
904d8744ef | ||
|
|
800e0b7360 | ||
|
|
b6a5461a1d | ||
|
|
a4eda61a86 | ||
|
|
a79d0712a4 | ||
|
|
8f782cdc9d | ||
|
|
2f851f3172 | ||
|
|
9fcf455c08 | ||
|
|
9b14906a46 | ||
|
|
14ce428951 | ||
|
|
8c93124311 | ||
|
|
c09707867b | ||
|
|
a2af0f3904 | ||
|
|
73982f0e84 | ||
|
|
449a6fd335 | ||
|
|
32b39c7927 | ||
|
|
c6f1e0b233 | ||
|
|
7c1a3ae4bc | ||
|
|
6bd6622484 | ||
|
|
433aff385d | ||
|
|
c37ad07f93 | ||
|
|
e4c1f855fb | ||
|
|
6978656061 | ||
|
|
973d60c98f | ||
|
|
5a43ba2576 | ||
|
|
e52a02cf3c | ||
|
|
da8216e2ca | ||
|
|
bd0e2ec35c | ||
|
|
7c8598ffa5 | ||
|
|
50e594b91d | ||
|
|
a9e5bb33e4 | ||
|
|
5e8676a039 |
@@ -1,4 +1,12 @@
|
||||
VITE_API = /api
|
||||
VITE_API_MT = /mt
|
||||
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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
VITE_API = http://110.42.251.190:9898
|
||||
VITE_API_MT = http://mt.myalger.top
|
||||
VITE_API_MUSIC = http://110.42.251.190:4100
|
||||
VITE_API_PROXY = http://110.42.251.190:9856
|
||||
13
.eslintignore
Normal file
@@ -0,0 +1,13 @@
|
||||
snapshot*
|
||||
dist
|
||||
lib
|
||||
es
|
||||
esm
|
||||
node_modules
|
||||
static
|
||||
cypress
|
||||
script/test/cypress
|
||||
_site
|
||||
temp*
|
||||
static/
|
||||
!.prettierrc.js
|
||||
140
.eslintrc
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"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-nested-ternary": "off",
|
||||
"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)
|
||||
"consistent-return": "off",
|
||||
"no-promise-executor-return": "off",
|
||||
"prefer-promise-reject-errors": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @algerkong
|
||||
70
.github/ISSUE_TEMPLATE/bug-report.zh-CN.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: 反馈 Bug
|
||||
description: 通过 github 模板进行 Bug 反馈。
|
||||
title: "描述问题的标题"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 欢迎你的参与
|
||||
Issue 列表接受 bug 报告或是新功能请求。
|
||||
|
||||
在发布一个 Issue 前,请确保:
|
||||
- 在Issue中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
|
||||
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
|
||||
|
||||
- type: input
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: 重现链接
|
||||
description: 请提供尽可能精简的 CodePen、CodeSandbox 或 GitHub 仓库的链接。请不要填无关链接,否则你的 Issue 将被关闭。
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: reproduceSteps
|
||||
attributes:
|
||||
label: 重现步骤
|
||||
description: 请清晰的描述重现该 Issue 的步骤,这能帮助我们快速定位问题。没有清晰重现步骤将不会被修复,标有 'need reproduction' 的 Issue 在 7 天内不提供相关步骤,将被关闭。
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: expect
|
||||
attributes:
|
||||
label: 期望结果
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际结果
|
||||
placeholder: 请填写
|
||||
|
||||
- type: input
|
||||
id: frameworkVersion
|
||||
attributes:
|
||||
label: 框架版本
|
||||
placeholder: Vue(3.3.0)
|
||||
|
||||
- type: input
|
||||
id: browsersVersion
|
||||
attributes:
|
||||
label: 浏览器版本
|
||||
placeholder: Chrome(8.213.231.123)
|
||||
|
||||
- type: input
|
||||
id: systemVersion
|
||||
attributes:
|
||||
label: 系统版本
|
||||
placeholder: MacOS(11.2.3)
|
||||
|
||||
- type: input
|
||||
id: nodeVersion
|
||||
attributes:
|
||||
label: Node版本
|
||||
placeholder: 请填写
|
||||
|
||||
- type: textarea
|
||||
id: remarks
|
||||
attributes:
|
||||
label: 补充说明
|
||||
description: 可以是遇到这个 bug 的业务场景、上下文等信息。
|
||||
placeholder: 请填写
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name:
|
||||
url:
|
||||
about:
|
||||
29
.github/ISSUE_TEMPLATE/feature-report.zh-CN.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: 反馈新功能
|
||||
description: 通过 github 模板进行新功能反馈。
|
||||
title: "描述问题的标题"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# 欢迎你的参与
|
||||
|
||||
在发布一个 Issue 前,请确保:
|
||||
- 在 Issue 中搜索过你的问题。(你的问题可能已有人提出,也可能已在最新版本中被修正)
|
||||
- 如果你发现一个已经关闭的旧 Issue 在最新版本中仍然存在,不要在旧 Issue 下面留言,请建一个新的 issue。
|
||||
|
||||
- type: textarea
|
||||
id: functionContent
|
||||
attributes:
|
||||
label: 这个功能解决了什么问题
|
||||
description: 请详尽说明这个需求的用例和场景。最重要的是:解释清楚是怎样的用户体验需求催生了这个功能上的需求。我们将考虑添加在现有 API 无法轻松实现的功能。新功能的用例也应当足够常见。
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: functionalExpectations
|
||||
attributes:
|
||||
label: 你建议的方案是什么
|
||||
placeholder: 请填写
|
||||
validations:
|
||||
required: true
|
||||
51
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
首先,感谢你的贡献!😄
|
||||
PR 在维护者审核通过后会合并,谢谢!
|
||||
-->
|
||||
|
||||
### 🤔 这个 PR 的性质是?
|
||||
|
||||
- [ ] 日常 bug 修复
|
||||
- [ ] 新特性提交
|
||||
- [ ] 文档改进
|
||||
- [ ] 演示代码改进
|
||||
- [ ] 组件样式/交互改进
|
||||
- [ ] CI/CD 改进
|
||||
- [ ] 重构
|
||||
- [ ] 代码风格优化
|
||||
- [ ] 测试用例
|
||||
- [ ] 分支合并
|
||||
- [ ] 其他
|
||||
|
||||
### 🔗 相关 Issue
|
||||
|
||||
<!--
|
||||
1. 描述相关需求的来源,如相关的 issue 讨论链接。
|
||||
-->
|
||||
|
||||
### 💡 需求背景和解决方案
|
||||
|
||||
<!--
|
||||
1. 要解决的具体问题。
|
||||
2. 列出最终的 API 实现和用法。
|
||||
3. 涉及UI/交互变动需要有截图或 GIF。
|
||||
-->
|
||||
|
||||
### 📝 更新日志
|
||||
|
||||
<!--
|
||||
从用户角度描述具体变化,以及可能的 breaking change 和其他风险。
|
||||
-->
|
||||
|
||||
- fix(组件名称): 处理问题或特性描述 ...
|
||||
|
||||
- [ ] 本条 PR 不需要纳入 Changelog
|
||||
|
||||
### ☑️ 请求合并前的自查清单
|
||||
|
||||
⚠️ 请自检并全部**勾选全部选项**。⚠️
|
||||
|
||||
- [ ] 文档已补充或无须补充
|
||||
- [ ] 代码演示已提供或无须提供
|
||||
- [ ] TypeScript 定义已补充或无须补充
|
||||
- [ ] Changelog 已提供或无须提供
|
||||
20
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Basic dependabot.yml file with
|
||||
# minimum configuration for two package managers
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: 'npm'
|
||||
# Look for `package.json` and `lock` files in the `root` directory
|
||||
directory: '/'
|
||||
# Check the npm registry for updates every day (weekdays)
|
||||
schedule:
|
||||
interval: 'monthly'
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: 'docker'
|
||||
# Look for a `Dockerfile` in the `root` directory
|
||||
directory: '/'
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
interval: 'monthly'
|
||||
8
.github/issue-shoot.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
## IssueShoot
|
||||
- 预估时长: {{ .duration }}
|
||||
- 期望完成时间: {{ .deadline }}
|
||||
- 开发难度: {{ .level }}
|
||||
- 参与人数: 1
|
||||
- 需求对接人: ivringpeng
|
||||
- 验收标准: 实现期望改造效果,提 PR 并通过验收无误
|
||||
- 备注: 最终激励以实际提交 `pull request` 并合并为准
|
||||
11
.gitignore
vendored
@@ -1,11 +1,20 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
dist_electron
|
||||
.idea
|
||||
|
||||
# lock
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
dist.zip
|
||||
dist.zip
|
||||
|
||||
.vscode
|
||||
|
||||
bun.lockb
|
||||
|
||||
.env.*.local
|
||||
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
39
.prettierrc.js
Normal file
@@ -0,0 +1,39 @@
|
||||
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',
|
||||
};
|
||||
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"compile-hero.disable-compile-files-on-did-save-code": true
|
||||
}
|
||||
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
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.
|
||||
105
README.md
@@ -1,5 +1,102 @@
|
||||
# Vue 3 + Typescript + Vite
|
||||
# Alger Music Player
|
||||
主要功能如下
|
||||
|
||||
vue3 + TypeScript + NaiveUI + animateCss + Vuex + VueRouter + Axios等实现音乐桌面web端
|
||||
实现各项功能
|
||||
网站地址:http://mc.myalger.top/
|
||||
- 音乐推荐
|
||||
- 音乐播放
|
||||
- 网易云登录
|
||||
- 播放历史
|
||||
- 桌面歌词
|
||||
- 歌单 mv 搜索 专辑等功能
|
||||
- 识别无法播放歌曲 并代理播放
|
||||
- 可听周杰伦(搜索专辑)
|
||||
|
||||
## 项目简介
|
||||
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
||||
|
||||
## 预览地址
|
||||
[http://mc.alger.fun/](http://mc.alger.fun/)
|
||||
|
||||
QQ群:789288579
|
||||
|
||||
## 软件截图
|
||||

|
||||

|
||||

|
||||
|
||||
## 技术栈
|
||||
|
||||
### 主要框架
|
||||
- Vue 3 - 渐进式 JavaScript 框架
|
||||
- TypeScript - JavaScript 的超集,添加了类型系统
|
||||
- Electron - 跨平台桌面应用开发框架
|
||||
- Vite - 下一代前端构建工具
|
||||
|
||||
### UI 框架
|
||||
- Naive UI - 基于 Vue 3 的组件库
|
||||
|
||||
### 项目特点
|
||||
- 完整的类型支持(TypeScript)
|
||||
- 模块化设计
|
||||
- 自动化组件和 API 导入
|
||||
- 多平台支持(Web、Desktop、Mobile Web)
|
||||
- 构建优化(代码分割、压缩)
|
||||
|
||||
## 咖啡☕️
|
||||
| 微信 | 支付宝 |
|
||||
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
|
||||
| <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
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 运行项目 web
|
||||
npm run dev
|
||||
|
||||
# 运行项目 electron
|
||||
npm run start
|
||||
|
||||
# 打包项目 web
|
||||
npm run build
|
||||
|
||||
# 打包项目 electron
|
||||
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
|
||||
[](https://starchart.cc/algerkong/AlgerMusicPlayer)
|
||||
|
||||
|
||||
|
||||
|
||||
## 欢迎提Issues
|
||||
|
||||
## 免责声明
|
||||
本软件仅用于学习交流,禁止用于商业用途,否则后果自负。
|
||||
|
||||
129
app.js
@@ -1,139 +1,148 @@
|
||||
const { app, BrowserWindow, ipcMain, Tray, Menu, globalShortcut, nativeImage } = require('electron')
|
||||
const path = require('path')
|
||||
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 setJson = require('./electron/set.json');
|
||||
const { loadLyricWindow } = require('./electron/lyric');
|
||||
const config = require('./electron/config');
|
||||
|
||||
let mainWin = null
|
||||
let mainWin = null;
|
||||
function createWindow() {
|
||||
mainWin = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 780,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, '/electron/preload.js'),
|
||||
},
|
||||
})
|
||||
const win = mainWin
|
||||
win.setMinimumSize(1200, 780)
|
||||
});
|
||||
const win = mainWin;
|
||||
win.setMinimumSize(1200, 780);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
win.webContents.openDevTools({ mode: 'detach' })
|
||||
win.loadURL('http://localhost:4678/')
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
win.loadURL(`http://localhost:${config.development.mainPort}/`);
|
||||
} else {
|
||||
win.loadURL(`file://${__dirname}/dist/index.html`)
|
||||
win.loadURL(`file://${__dirname}/dist/index.html`);
|
||||
}
|
||||
const image = nativeImage.createFromPath(path.join(__dirname, 'public/icon.png'))
|
||||
const tray = new Tray(image)
|
||||
const image = nativeImage
|
||||
.createFromPath(path.join(__dirname, 'public/icon_16x16.png'))
|
||||
.resize({ width: 16, height: 16 });
|
||||
const tray = new Tray(image);
|
||||
|
||||
// 创建一个上下文菜单
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示',
|
||||
click: () => {
|
||||
win.show()
|
||||
win.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
win.destroy()
|
||||
win.destroy();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
// 设置系统托盘图标的上下文菜单
|
||||
tray.setContextMenu(contextMenu)
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// 当系统托盘图标被点击时,切换窗口的显示/隐藏
|
||||
tray.on('click', () => {
|
||||
if (win.isVisible()) {
|
||||
win.hide()
|
||||
win.hide();
|
||||
} else {
|
||||
win.show()
|
||||
win.show();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const set = store.get('set')
|
||||
const set = store.get('set');
|
||||
// store.set('set', setJson)
|
||||
|
||||
if (!set) {
|
||||
store.set('set', setJson)
|
||||
store.set('set', setJson);
|
||||
}
|
||||
|
||||
loadLyricWindow(ipcMain, mainWin);
|
||||
}
|
||||
|
||||
// 限制只能启动一个应用
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow)
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('ready',()=>{
|
||||
app.on('ready', () => {
|
||||
globalShortcut.register('CommandOrControl+Alt+Shift+M', () => {
|
||||
if (mainWin.isVisible()) {
|
||||
mainWin.hide()
|
||||
mainWin.hide();
|
||||
} else {
|
||||
mainWin.show()
|
||||
mainWin.show();
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
app.quit();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
globalShortcut.unregisterAll()
|
||||
})
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
|
||||
ipcMain.on('minimize-window', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win.minimize()
|
||||
})
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
win.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on('maximize-window', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win.isMaximized()) {
|
||||
win.unmaximize()
|
||||
win.unmaximize();
|
||||
} else {
|
||||
win.maximize()
|
||||
win.maximize();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
ipcMain.on('close-window', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win.destroy()
|
||||
})
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
win.destroy();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.on('drag-start', (event, data) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
ipcMain.on('drag-start', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
win.webContents.beginFrameSubscription((frameBuffer) => {
|
||||
event.reply('frame-buffer', frameBuffer)
|
||||
})
|
||||
})
|
||||
event.reply('frame-buffer', frameBuffer);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('mini-tray', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win.hide()
|
||||
})
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
win.hide();
|
||||
});
|
||||
|
||||
// 重启
|
||||
ipcMain.on('restart', () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
});
|
||||
|
||||
const store = new Store();
|
||||
|
||||
// 定义ipcRenderer监听事件
|
||||
ipcMain.on('setStore', (_, key, value) => {
|
||||
store.set(key, value)
|
||||
})
|
||||
store.set(key, value);
|
||||
});
|
||||
|
||||
ipcMain.on('getStore', (_, key) => {
|
||||
let value = store.get(key)
|
||||
_.returnValue = value || ""
|
||||
})
|
||||
const value = store.get(key);
|
||||
_.returnValue = value || '';
|
||||
});
|
||||
|
||||
7
auto-imports.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
@@ -35,6 +36,7 @@ declare global {
|
||||
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']
|
||||
@@ -53,10 +55,13 @@ declare global {
|
||||
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']
|
||||
@@ -65,6 +70,6 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
||||
49
build/mac.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"appId": "com.alger.music",
|
||||
"productName": "AlgerMusic",
|
||||
"artifactName": "${productName}_${version}_${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "dist_electron/mac"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json",
|
||||
"app.js",
|
||||
"electron/**/*",
|
||||
"**/*",
|
||||
"public/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"mac": {
|
||||
"icon": "public/icon.icns",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.music",
|
||||
"darkModeSupport": true
|
||||
},
|
||||
"dmg": {
|
||||
"title": "${productName} ${version}",
|
||||
"icon": "public/icon.icns",
|
||||
"contents": [
|
||||
{
|
||||
"x": 410,
|
||||
"y": 150,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
},
|
||||
{
|
||||
"x": 130,
|
||||
"y": 150,
|
||||
"type": "file"
|
||||
}
|
||||
],
|
||||
"window": {
|
||||
"width": 540,
|
||||
"height": 380
|
||||
}
|
||||
}
|
||||
}
|
||||
31
build/win32.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
31
build/win64.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
31
build/winarm64.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
13
components.d.ts
vendored
@@ -1,33 +1,44 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @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 {
|
||||
Coffee: typeof import('./src/components/Coffee.vue')['default']
|
||||
InstallAppModal: typeof import('./src/components/common/InstallAppModal.vue')['default']
|
||||
MPop: typeof import('./src/components/common/MPop.vue')['default']
|
||||
MusicList: typeof import('./src/components/MusicList.vue')['default']
|
||||
MvPlayer: typeof import('./src/components/MvPlayer.vue')['default']
|
||||
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']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
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']
|
||||
|
||||
BIN
docs/img/image-1.png
Normal file
|
After Width: | Height: | Size: 902 KiB |
BIN
docs/img/image-2.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
docs/img/image-3.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/img/image-4.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
docs/img/image-5.png
Normal file
|
After Width: | Height: | Size: 478 KiB |
BIN
docs/img/image-6.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
docs/img/image-7.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/img/image-8.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/img/image.png
Normal file
|
After Width: | Height: | Size: 502 KiB |
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"appId": "com.alger.music",
|
||||
"productName": "AlgerMusic",
|
||||
"directories": {
|
||||
"output": "dist_electron"
|
||||
},
|
||||
"files": ["dist/**/*", "package.json", "app.js", "electron/**/*"],
|
||||
"win": {
|
||||
"icon": "public/icon.png",
|
||||
"target": "nsis",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "installer/installer.nsh",
|
||||
"to": "$INSTDIR"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
electron/config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
// 开发环境配置
|
||||
development: {
|
||||
mainPort: 4488,
|
||||
lyricPort: 4488,
|
||||
},
|
||||
// 生产环境配置
|
||||
production: {
|
||||
distPath: '../dist',
|
||||
},
|
||||
};
|
||||
156
electron/lyric.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const { BrowserWindow, screen } = require('electron');
|
||||
const path = require('path');
|
||||
const Store = require('electron-store');
|
||||
const config = require('./config');
|
||||
|
||||
const store = new Store();
|
||||
let lyricWindow = null;
|
||||
|
||||
const createWin = () => {
|
||||
console.log('Creating lyric window');
|
||||
|
||||
// 获取保存的窗口位置
|
||||
const windowBounds = store.get('lyricWindowBounds') || {};
|
||||
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: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: `${__dirname}/preload.js`,
|
||||
webSecurity: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 监听窗口关闭事件
|
||||
lyricWindow.on('closed', () => {
|
||||
console.log('Lyric window closed');
|
||||
lyricWindow = null;
|
||||
});
|
||||
};
|
||||
|
||||
const loadLyricWindow = (ipcMain, mainWin) => {
|
||||
ipcMain.on('open-lyric', () => {
|
||||
console.log('Received open-lyric request');
|
||||
if (lyricWindow) {
|
||||
console.log('Lyric window exists, focusing');
|
||||
if (lyricWindow.isMinimized()) lyricWindow.restore();
|
||||
lyricWindow.focus();
|
||||
lyricWindow.show();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Creating new lyric window');
|
||||
createWin();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
lyricWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
lyricWindow.loadURL(`http://localhost:${config.development.lyricPort}/#/lyric`);
|
||||
} else {
|
||||
const distPath = path.resolve(__dirname, config.production.distPath);
|
||||
lyricWindow.loadURL(`file://${distPath}/index.html#/lyric`);
|
||||
}
|
||||
|
||||
lyricWindow.setMinimumSize(600, 200);
|
||||
lyricWindow.setSkipTaskbar(true);
|
||||
|
||||
lyricWindow.once('ready-to-show', () => {
|
||||
console.log('Lyric window ready to show');
|
||||
lyricWindow.show();
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('send-lyric', (e, data) => {
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
try {
|
||||
lyricWindow.webContents.send('receive-lyric', data);
|
||||
} catch (error) {
|
||||
console.error('Error processing lyric data:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('Cannot send lyric: window not available or destroyed');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('top-lyric', (e, 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.close();
|
||||
lyricWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('mouseenter-lyric', () => {
|
||||
lyricWindow.setIgnoreMouseEvents(true);
|
||||
});
|
||||
|
||||
ipcMain.on('mouseleave-lyric', () => {
|
||||
lyricWindow.setIgnoreMouseEvents(false);
|
||||
});
|
||||
|
||||
// 处理拖动移动
|
||||
ipcMain.on('lyric-drag-move', (e, { deltaX, deltaY }) => {
|
||||
if (!lyricWindow) 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', (e, shouldIgnore) => {
|
||||
if (!lyricWindow) return;
|
||||
|
||||
if (shouldIgnore) {
|
||||
// 设置鼠标穿透,但保留拖动区域可交互
|
||||
lyricWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
// 取消鼠标穿透
|
||||
lyricWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加播放控制处理
|
||||
ipcMain.on('control-back', (e, command) => {
|
||||
console.log('Received control-back request:', command);
|
||||
mainWin.webContents.send('lyric-control-back', command);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
loadLyricWindow,
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 主进程通信
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
minimize: () => ipcRenderer.send('minimize-window'),
|
||||
maximize: () => ipcRenderer.send('maximize-window'),
|
||||
@@ -7,19 +8,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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 = {
|
||||
// 存储相关
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
ipcRenderer: {
|
||||
setStoreValue: (key, value) => {
|
||||
ipcRenderer.send("setStore", key, value)
|
||||
setStoreValue: (key, value) => ipcRenderer.send('setStore', key, value),
|
||||
getStoreValue: (key) => ipcRenderer.sendSync('getStore', key),
|
||||
on: (channel, func) => {
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||
},
|
||||
|
||||
getStoreValue(key) {
|
||||
const resp = ipcRenderer.sendSync("getStore", key)
|
||||
return resp
|
||||
once: (channel, func) => {
|
||||
ipcRenderer.once(channel, (event, ...args) => func(...args));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', electronHandler)
|
||||
send: (channel, data) => {
|
||||
ipcRenderer.send(channel, data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"isProxy": false,
|
||||
"author": "alger"
|
||||
"noAnimate": false,
|
||||
"animationSpeed": 1,
|
||||
"author": "Alger",
|
||||
"authorUrl": "https://github.com/algerkong"
|
||||
}
|
||||
|
||||
69
electron/update.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
class Updater {
|
||||
constructor(mainWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.updateUrl = 'http://your-server.com/update'; // 更新服务器地址
|
||||
this.version = app.getVersion();
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
const response = await axios.get(`${this.updateUrl}/check`, {
|
||||
params: {
|
||||
version: this.version,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.hasUpdate) {
|
||||
await this.downloadUpdate(response.data.downloadUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载更新
|
||||
async downloadUpdate(downloadUrl) {
|
||||
try {
|
||||
const response = await axios({
|
||||
url: downloadUrl,
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
const tempPath = path.join(app.getPath('temp'), 'update.zip');
|
||||
fs.writeFileSync(tempPath, response.data);
|
||||
|
||||
await this.extractUpdate(tempPath);
|
||||
} catch (error) {
|
||||
console.error('下载更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 解压更新
|
||||
async extractUpdate(zipPath) {
|
||||
try {
|
||||
const zip = new AdmZip(zipPath);
|
||||
const targetPath = path.join(__dirname, '../dist'); // 前端文件目录
|
||||
|
||||
// 解压文件
|
||||
zip.extractAllTo(targetPath, true);
|
||||
|
||||
// 删除临时文件
|
||||
fs.unlinkSync(zipPath);
|
||||
|
||||
// 刷新页面
|
||||
this.mainWindow.webContents.reload();
|
||||
} catch (error) {
|
||||
console.error('解压更新失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Updater;
|
||||
76
index.html
@@ -1,25 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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="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>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
|
||||
<!-- SEO 元数据 -->
|
||||
<title>网抑云音乐 | AlgerKong | AlgerMusicPlayer</title>
|
||||
<meta name="description"
|
||||
content="AlgerMusicPlayer 网抑云音乐 基于 网易云音乐API 的一款免费的在线音乐播放器,支持在线播放、歌词显示、音乐下载等功能。提供海量音乐资源,让您随时随地享受音乐。" />
|
||||
<meta name="keywords" content="AlgerMusic, AlgerMusicPlayer, 网抑云, 音乐播放器, 在线音乐, 免费音乐, 歌词显示, 音乐下载, AlgerKong, 网易云音乐" />
|
||||
|
||||
<!-- 作者信息 -->
|
||||
<meta name="author" content="AlgerKong" />
|
||||
<meta name="author-url" content="https://github.com/algerkong" />
|
||||
|
||||
<!-- PWA 相关 -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="网抑云音乐" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- 资源预加载 -->
|
||||
<link rel="preload" href="/icon/iconfont.css" as="style" />
|
||||
<link rel="preload" href="/css/animate.css" as="style" />
|
||||
<link rel="preload" href="/css/base.css" as="style" />
|
||||
|
||||
<!-- 样式表 -->
|
||||
<link rel="stylesheet" href="/icon/iconfont.css" />
|
||||
<link rel="stylesheet" href="/css/animate.css" />
|
||||
<link rel="stylesheet" href="/css/base.css" />
|
||||
<script defer src="https://cn.vercount.one/js"></script>
|
||||
|
||||
<!-- 动画配置 -->
|
||||
<style>
|
||||
:root {
|
||||
--animate-delay: 0.5s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<div style="display: none;">
|
||||
Total Page View <span id="vercount_value_page_pv">Loading</span>
|
||||
Total Visits <span id="vercount_value_site_pv">Loading</span>
|
||||
Site Total Visitors <span id="vercount_value_site_uv">Loading</span>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
81
package.json
@@ -1,47 +1,66 @@
|
||||
{
|
||||
"name": "alger-music",
|
||||
"version": "1.3.0",
|
||||
"version": "2.4.0",
|
||||
"description": "这是一个用于音乐播放的应用程序。",
|
||||
"author": "Alger <algerkc@qq.com>",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"serve": "vite preview",
|
||||
"es": "vite && electron .",
|
||||
"start": "set NODE_ENV=development&&electron .",
|
||||
"e:b": "electron-builder --config ./electron.config.json",
|
||||
"eb": "vite build && e:b"
|
||||
"start": "cross-env NODE_ENV=development electron .",
|
||||
"lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
|
||||
"b:win:x64": "cross-env NODE_ENV=production electron-builder --config ./build/win64.json",
|
||||
"b:win:x86": "cross-env NODE_ENV=production electron-builder --config ./build/win32.json",
|
||||
"b:win:arm": "cross-env NODE_ENV=production electron-builder --config ./build/winarm64.json",
|
||||
"b:mac": "cross-env NODE_ENV=production npm run build && electron-builder --config ./build/mac.json",
|
||||
"b:win": "cross-env NODE_ENV=production npm run build && npm run b:win:x64 && npm run b:win:x86 && npm run b:win:arm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss7-compat": "^2.2.4",
|
||||
"@vue/runtime-core": "^3.3.4",
|
||||
"@vueuse/core": "^10.7.1",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"axios": "^0.21.1",
|
||||
"@types/howler": "^2.2.12",
|
||||
"electron-store": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"naive-ui": "^2.34.4",
|
||||
"postcss": "^7.0.36",
|
||||
"sass": "^1.35.2",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"vuex": "^4.1.0"
|
||||
"howler": "^2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sicons/antd": "^0.10.0",
|
||||
"@vicons/antd": "^0.10.0",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vue/compiler-sfc": "^3.3.4",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"typescript": "^4.3.2",
|
||||
"unplugin-auto-import": "^0.17.2",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"@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",
|
||||
"@vue/compiler-sfc": "^3.5.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",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^32.2.7",
|
||||
"electron-builder": "^25.0.5",
|
||||
"eslint": "^8.56.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-scoped-css": "^2.7.2",
|
||||
"lodash": "^4.17.21",
|
||||
"naive-ui": "^2.39.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"remixicon": "^4.2.0",
|
||||
"sass": "^1.82.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.5.4",
|
||||
"unplugin-auto-import": "^0.18.2",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vfonts": "^0.1.0",
|
||||
"vite": "^4.4.7",
|
||||
"vite-plugin-vue-devtools": "1.0.0-beta.5",
|
||||
"vue-tsc": "^0.0.24"
|
||||
"vite": "^5.4.3",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "7.4.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.3",
|
||||
"vue-tsc": "^2.1.4",
|
||||
"vuex": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
body{
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.n-popover:has(.music-play){
|
||||
border-radius: 1.5rem !important;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 178 KiB |
BIN
public/icon.icns
Normal file
BIN
public/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 626 B |
10
public/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Alger Music PWA",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
}
|
||||
]
|
||||
}
|
||||
86
src/App.vue
@@ -1,37 +1,49 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<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'
|
||||
|
||||
const audio = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="app-container" :class="{ mobile: isMobile, noElectron: !isElectron }">
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<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 } from 'naive-ui';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import { isElectron } from '@/hooks/MusicHook';
|
||||
import homeRouter from '@/router/home';
|
||||
import store from '@/store';
|
||||
|
||||
import { isMobile } from './utils';
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('initializeSettings');
|
||||
if (isMobile.value) {
|
||||
store.commit(
|
||||
'setMenus',
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import request from "@/utils/request";
|
||||
import { IHotSinger } from "@/type/singer";
|
||||
import { ISearchKeyword, IHotSearch } from "@/type/search";
|
||||
import { IPlayListSort } from "@/type/playlist";
|
||||
import { IRecommendMusic } from "@/type/music";
|
||||
import { IAlbumNew } from "@/type/album";
|
||||
import { IData } from '@/type';
|
||||
import { IAlbumNew } from '@/type/album';
|
||||
import { IDayRecommend } from '@/type/day_recommend';
|
||||
import { IRecommendMusic } from '@/type/music';
|
||||
import { IPlayListSort } from '@/type/playlist';
|
||||
import { IHotSearch, ISearchKeyword } from '@/type/search';
|
||||
import { IHotSinger } from '@/type/singer';
|
||||
import request from '@/utils/request';
|
||||
|
||||
interface IHotSingerParams {
|
||||
offset: number;
|
||||
@@ -16,30 +18,35 @@ interface IRecommendMusicParams {
|
||||
|
||||
// 获取热门歌手
|
||||
export const getHotSinger = (params: IHotSingerParams) => {
|
||||
return request.get<IHotSinger>("/top/artists", { params });
|
||||
return request.get<IHotSinger>('/top/artists', { params });
|
||||
};
|
||||
|
||||
// 获取搜索推荐词
|
||||
export const getSearchKeyword = () => {
|
||||
return request.get<ISearchKeyword>("/search/default");
|
||||
return request.get<ISearchKeyword>('/search/default');
|
||||
};
|
||||
|
||||
// 获取热门搜索
|
||||
export const getHotSearch = () => {
|
||||
return request.get<IHotSearch>("/search/hot/detail");
|
||||
return request.get<IHotSearch>('/search/hot/detail');
|
||||
};
|
||||
|
||||
// 获取歌单分类
|
||||
export const getPlaylistCategory = () => {
|
||||
return request.get<IPlayListSort>("/playlist/catlist");
|
||||
return request.get<IPlayListSort>('/playlist/catlist');
|
||||
};
|
||||
|
||||
// 获取推荐音乐
|
||||
export const getRecommendMusic = (params: IRecommendMusicParams) => {
|
||||
return request.get<IRecommendMusic>("/personalized/newsong", { params });
|
||||
return request.get<IRecommendMusic>('/personalized/newsong', { params });
|
||||
};
|
||||
|
||||
// 获取每日推荐
|
||||
export const getDayRecommend = () => {
|
||||
return request.get<IData<IData<IDayRecommend>>>('/recommend/songs');
|
||||
};
|
||||
|
||||
// 获取最新专辑推荐
|
||||
export const getNewAlbum = () => {
|
||||
return request.get<IAlbumNew>("/album/newest");
|
||||
return request.get<IAlbumNew>('/album/newest');
|
||||
};
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import request from "@/utils/request";
|
||||
import { IList } from "@/type/list";
|
||||
import type { IListDetail } from "@/type/listDetail";
|
||||
|
||||
interface IListByTagParams {
|
||||
tag: string;
|
||||
before: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface IListByCatParams {
|
||||
cat: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// 根据tag 获取歌单列表
|
||||
export function getListByTag(params: IListByTagParams) {
|
||||
return request.get<IList>("/top/playlist/highquality", { params: params });
|
||||
}
|
||||
|
||||
// 根据cat 获取歌单列表
|
||||
export function getListByCat(params: IListByCatParams) {
|
||||
return request.get("/top/playlist", {
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取推荐歌单
|
||||
export function getRecommendList(limit: number = 30) {
|
||||
return request.get("/personalized", { params: { limit } });
|
||||
}
|
||||
|
||||
// 获取歌单详情
|
||||
export function getListDetail(id: number | string) {
|
||||
return request.get<IListDetail>("/playlist/detail", { params: { id } });
|
||||
}
|
||||
|
||||
// 获取专辑内容
|
||||
export function getAlbum(id: number | string) {
|
||||
return request.get("/album", { params: { id } });
|
||||
}
|
||||
import { IList } from '@/type/list';
|
||||
import type { IListDetail } from '@/type/listDetail';
|
||||
import request from '@/utils/request';
|
||||
|
||||
interface IListByTagParams {
|
||||
tag: string;
|
||||
before: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface IListByCatParams {
|
||||
cat: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// 根据tag 获取歌单列表
|
||||
export function getListByTag(params: IListByTagParams) {
|
||||
return request.get<IList>('/top/playlist/highquality', { params });
|
||||
}
|
||||
|
||||
// 根据cat 获取歌单列表
|
||||
export function getListByCat(params: IListByCatParams) {
|
||||
return request.get('/top/playlist', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取推荐歌单
|
||||
export function getRecommendList(limit: number = 30) {
|
||||
return request.get('/personalized', { params: { limit } });
|
||||
}
|
||||
|
||||
// 获取歌单详情
|
||||
export function getListDetail(id: number | string) {
|
||||
return request.get<IListDetail>('/playlist/detail', { params: { id } });
|
||||
}
|
||||
|
||||
// 获取专辑内容
|
||||
export function getAlbum(id: number | string) {
|
||||
return request.get('/album', { params: { id } });
|
||||
}
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
import request from "@/utils/request";
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 创建二维码key
|
||||
// /login/qr/key
|
||||
export function getQrKey() {
|
||||
return request.get("/login/qr/key");
|
||||
return request.get('/login/qr/key');
|
||||
}
|
||||
|
||||
// 创建二维码
|
||||
// /login/qr/create
|
||||
export function createQr(key: any) {
|
||||
return request.get("/login/qr/create", { params: { key: key, qrimg: true } });
|
||||
return request.get('/login/qr/create', { params: { key, qrimg: true } });
|
||||
}
|
||||
|
||||
// 获取二维码状态
|
||||
// /login/qr/check
|
||||
export function checkQr(key: any) {
|
||||
return request.get("/login/qr/check", { params: { key: key } });
|
||||
return request.get('/login/qr/check', { params: { key } });
|
||||
}
|
||||
|
||||
// 获取登录状态
|
||||
// /login/status
|
||||
export function getLoginStatus() {
|
||||
return request.get("/login/status");
|
||||
return request.get('/login/status');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
// /user/account
|
||||
export function getUserDetail() {
|
||||
return request.get("/user/account");
|
||||
return request.get('/user/account');
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
// /logout
|
||||
export function logout() {
|
||||
return request.get("/logout");
|
||||
return request.get('/logout');
|
||||
}
|
||||
|
||||
// 手机号登录
|
||||
// /login/cellphone
|
||||
export function loginByCellphone(phone: any, password: any) {
|
||||
return request.post("/login/cellphone", {
|
||||
phone: phone,
|
||||
password: password,
|
||||
export function loginByCellphone(phone: string, password: string) {
|
||||
return request.post('/login/cellphone', {
|
||||
phone,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { IPlayMusicUrl } from "@/type/music"
|
||||
import { ILyric } from "@/type/lyric"
|
||||
import request from "@/utils/request"
|
||||
import requestMusic from "@/utils/request_music"
|
||||
// 根据音乐Id获取音乐播放URl
|
||||
export const getMusicUrl = (id: number) => {
|
||||
return request.get<IPlayMusicUrl>("/song/url", { params: { id: id } })
|
||||
}
|
||||
|
||||
// 获取歌曲详情
|
||||
export const getMusicDetail = (ids: Array<number>) => {
|
||||
return request.get("/song/detail", { params: { ids: ids.join(",")}})
|
||||
}
|
||||
|
||||
// 根据音乐Id获取音乐歌词
|
||||
export const getMusicLrc = (id: number) => {
|
||||
return request.get<ILyric>("/lyric", { params: { id: id } })
|
||||
}
|
||||
|
||||
export const getParsingMusicUrl = (id: number) => {
|
||||
return requestMusic.get<any>("/music", { params: { id: id } })
|
||||
}
|
||||
import { ILyric } from '@/type/lyric';
|
||||
import { IPlayMusicUrl } from '@/type/music';
|
||||
import request from '@/utils/request';
|
||||
import requestMusic from '@/utils/request_music';
|
||||
// 根据音乐Id获取音乐播放URl
|
||||
export const getMusicUrl = (id: number) => {
|
||||
return request.get<IPlayMusicUrl>('/song/url', { params: { id } });
|
||||
};
|
||||
|
||||
// 获取歌曲详情
|
||||
export const getMusicDetail = (ids: Array<number>) => {
|
||||
return request.get('/song/detail', { params: { ids: ids.join(',') } });
|
||||
};
|
||||
|
||||
// 根据音乐Id获取音乐歌词
|
||||
export const getMusicLrc = (id: number) => {
|
||||
return request.get<ILyric>('/lyric', { params: { id } });
|
||||
};
|
||||
|
||||
export const getParsingMusicUrl = (id: number) => {
|
||||
return requestMusic.get<any>('/music', { params: { id } });
|
||||
};
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { IData } from '@/type'
|
||||
import { IMvItem, IMvUrlData } from '@/type/mv'
|
||||
import request from '@/utils/request'
|
||||
import { IData } from '@/type';
|
||||
import { IMvItem, IMvUrlData } from '@/type/mv';
|
||||
import request from '@/utils/request';
|
||||
|
||||
interface MvParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
area?: string;
|
||||
}
|
||||
|
||||
// 获取 mv 排行
|
||||
export const getTopMv = (limit: number) => {
|
||||
return request.get<IData<Array<IMvItem>>>('/top/mv', {
|
||||
params: {
|
||||
limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
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) => {
|
||||
@@ -17,8 +32,8 @@ export const getMvDetail = (mvid: string) => {
|
||||
params: {
|
||||
mvid,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 获取 mv 地址
|
||||
export const getMvUrl = (id: Number) => {
|
||||
@@ -26,5 +41,5 @@ export const getMvUrl = (id: Number) => {
|
||||
params: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import request from "@/utils/request"
|
||||
import { ISearchDetail } from "@/type/search"
|
||||
import request from '@/utils/request';
|
||||
|
||||
interface IParams {
|
||||
keywords: string
|
||||
type: number
|
||||
keywords: string;
|
||||
type: number;
|
||||
}
|
||||
// 搜索内容
|
||||
export const getSearch = (params: IParams) => {
|
||||
return request.get<any>('/cloudsearch', {
|
||||
params,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import request from "@/utils/request";
|
||||
import request from '@/utils/request';
|
||||
|
||||
// /user/detail
|
||||
export function getUserDetail(uid: number) {
|
||||
return request.get("/user/detail", { params: { uid } });
|
||||
return request.get('/user/detail', { params: { uid } });
|
||||
}
|
||||
|
||||
// /user/playlist
|
||||
export function getUserPlaylist(uid: number) {
|
||||
return request.get("/user/playlist", { params: { uid } });
|
||||
return request.get('/user/playlist', { params: { uid } });
|
||||
}
|
||||
|
||||
// 播放历史
|
||||
// /user/record?uid=32953014&type=1
|
||||
export function getUserRecord(uid: number, type: number = 0) {
|
||||
return request.get("/user/record", { params: { uid, type } });
|
||||
return request.get('/user/record', { params: { uid, type } });
|
||||
}
|
||||
|
||||
BIN
src/assets/alipay.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 20 KiB |
BIN
src/assets/wechat.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
56
src/components/Coffee.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<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 bg-black rounded-lg shadow-lg">
|
||||
<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-100">支付宝</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-100">微信支付</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-gray-100 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>
|
||||
@@ -1,75 +1,327 @@
|
||||
<template>
|
||||
<n-drawer :show="show" height="70vh" placement="bottom" :drawer-style="{ backgroundColor: 'transparent' }">
|
||||
<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">
|
||||
<i class="iconfont icon-icon_error music-close" @click="close"></i>
|
||||
<div class="music-title">{{ name }}</div>
|
||||
<!-- 歌单歌曲列表 -->
|
||||
<div class="music-list">
|
||||
<n-scrollbar >
|
||||
<div v-for="(item, index) in songList" :key="item.id" :class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="setAnimationDelay(index, 100)">
|
||||
<SongItem :item="formatDetail(item)" @play="handlePlay" />
|
||||
<div class="music-header h-12 flex items-center justify-between">
|
||||
<n-ellipsis :line-clamp="1">
|
||||
<div class="music-title">
|
||||
{{ name }}
|
||||
</div>
|
||||
<PlayBottom/>
|
||||
</n-scrollbar>
|
||||
</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, '300y300')"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
:class="setAnimationClass('animate__fadeIn')"
|
||||
object-fit="cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="music-detail">
|
||||
<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>
|
||||
<div v-if="listInfo?.description" class="music-desc">
|
||||
<n-ellipsis :line-clamp="isMobile ? 3 : 10">
|
||||
{{ listInfo.description }}
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</div>
|
||||
</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 { setAnimationClass, setAnimationDelay } from "@/utils";
|
||||
import SongItem from "@/components/common/SongItem.vue";
|
||||
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 store = useStore();
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
name: string;
|
||||
songList: any[]
|
||||
}>()
|
||||
const emit = defineEmits(['update:show'])
|
||||
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) => {
|
||||
let song = {
|
||||
const song = {
|
||||
artists: detail.ar,
|
||||
name: detail.al.name,
|
||||
id: detail.al.id,
|
||||
}
|
||||
};
|
||||
|
||||
detail.song = song
|
||||
detail.picUrl = detail.al.picUrl
|
||||
return detail
|
||||
})
|
||||
detail.song = song;
|
||||
detail.picUrl = detail.al.picUrl;
|
||||
return detail;
|
||||
});
|
||||
|
||||
const handlePlay = (item: any) => {
|
||||
const tracks = props.songList || []
|
||||
store.commit('setPlayList', tracks)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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-white;
|
||||
}
|
||||
|
||||
&-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 text-3xl;
|
||||
@apply cursor-pointer 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-lg overflow-hidden mb-4;
|
||||
.cover-img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
|
||||
.music-detail {
|
||||
@apply flex flex-col flex-grow;
|
||||
|
||||
.creator-info {
|
||||
@apply flex items-center mb-4;
|
||||
.creator-name {
|
||||
@apply ml-2 text-sm text-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
.music-desc {
|
||||
@apply text-sm text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-list-container {
|
||||
@apply flex-grow min-h-0 flex flex-col relative;
|
||||
}
|
||||
|
||||
&-list {
|
||||
height: calc(100% - 60px);
|
||||
@apply flex-grow min-h-0;
|
||||
|
||||
&-content {
|
||||
@apply min-h-[calc(80vh-60px)];
|
||||
}
|
||||
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.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 text-white py-10;
|
||||
}
|
||||
|
||||
.double-list {
|
||||
.double-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
background-color: #191919;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
867
src/components/MvPlayer.vue
Normal file
@@ -0,0 +1,867 @@
|
||||
<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 w-full h-full bg-black relative;
|
||||
|
||||
// 添加横屏模式支持
|
||||
@media screen and (orientation: landscape) {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
@apply w-full h-full relative;
|
||||
transition: cursor 0.3s ease;
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.custom-controls {
|
||||
.controls-main {
|
||||
@apply flex-wrap gap-2 justify-center;
|
||||
|
||||
.left-controls,
|
||||
.right-controls {
|
||||
@apply w-full justify-center;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
@apply order-first w-full text-center mb-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调整标题样式
|
||||
.mv-detail-title {
|
||||
.title {
|
||||
@apply text-base max-w-full;
|
||||
}
|
||||
}
|
||||
|
||||
// 调整进度条
|
||||
.progress-bar {
|
||||
@apply mb-2;
|
||||
}
|
||||
}
|
||||
|
||||
&.cursor-hidden {
|
||||
* {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
// 控制栏区域保持鼠标可见
|
||||
.custom-controls {
|
||||
* {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.n-slider {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:fullscreen,
|
||||
&:-webkit-full-screen,
|
||||
&:-moz-full-screen,
|
||||
&:-ms-fullscreen {
|
||||
background: black;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
// 确保全屏时标题栏正确显示
|
||||
.mv-detail-title {
|
||||
@apply px-8 py-6;
|
||||
|
||||
.title {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保全屏时控制栏正确显示
|
||||
.custom-controls {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-black opacity-0 transition-opacity duration-200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
@apply opacity-10;
|
||||
}
|
||||
|
||||
video {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
|
||||
.custom-controls {
|
||||
@apply absolute bottom-0 left-0 w-full transition-opacity duration-300 ease-in-out;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
padding: 16px 20px;
|
||||
|
||||
&.controls-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply mb-4;
|
||||
|
||||
.progress-rail {
|
||||
@apply relative w-full h-full;
|
||||
|
||||
.progress-buffer {
|
||||
@apply absolute h-full bg-gray-600 rounded-full;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controls-main {
|
||||
@apply flex justify-between items-center;
|
||||
|
||||
.left-controls,
|
||||
.right-controls {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
@apply text-sm text-white ml-2;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
.volume-slider {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.n-button {
|
||||
@apply text-white;
|
||||
|
||||
&:hover {
|
||||
@apply text-green-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.play-hint {
|
||||
@apply absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 cursor-pointer;
|
||||
z-index: 10;
|
||||
|
||||
.n-button {
|
||||
@apply text-white opacity-80 transform transition-all duration-300;
|
||||
|
||||
&:hover {
|
||||
@apply opacity-100 scale-110;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mv-detail-title {
|
||||
@apply absolute w-full left-0 top-0 px-6 py-4 transition-opacity duration-300 z-50;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
|
||||
&.title-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-white text-lg font-medium;
|
||||
max-width: 80%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-slider {
|
||||
:deep(.n-slider) {
|
||||
--n-rail-height: 4px;
|
||||
--n-rail-color: rgba(255, 255, 255, 0.2);
|
||||
--n-fill-color: var(--primary-color);
|
||||
--n-handle-size: 12px;
|
||||
--n-handle-color: var(--primary-color);
|
||||
|
||||
&:hover {
|
||||
--n-rail-height: 6px;
|
||||
--n-handle-size: 14px;
|
||||
}
|
||||
|
||||
.n-slider-rail {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.n-slider-handle {
|
||||
@apply transition-opacity duration-200;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .n-slider-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #18a058;
|
||||
}
|
||||
|
||||
// 添加模式提示样式
|
||||
.mode-hint {
|
||||
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2;
|
||||
@apply flex flex-col items-center justify-center;
|
||||
@apply bg-black bg-opacity-70 rounded-lg p-4;
|
||||
z-index: 20;
|
||||
|
||||
.mode-icon {
|
||||
@apply text-white mb-2;
|
||||
}
|
||||
|
||||
.mode-text {
|
||||
@apply text-white text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加过渡动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 添加 tooltip 样式
|
||||
:deep(.n-tooltip) {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// 调左侧控制按钮的样式
|
||||
.left-controls {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
.time-display {
|
||||
@apply text-sm text-white ml-4; // 增加时间显示的左边距
|
||||
}
|
||||
}
|
||||
|
||||
// 可以添加按钮禁用状态的样式
|
||||
:deep(.n-button--disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// 添加加载动画样式
|
||||
:deep(.n-spin) {
|
||||
.n-spin-body {
|
||||
@apply text-white;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加视频播放器样式
|
||||
.video-player {
|
||||
@apply w-full h-full cursor-pointer;
|
||||
}
|
||||
|
||||
// 添加点击反馈效果
|
||||
.video-container {
|
||||
&::after {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-black opacity-0 transition-opacity duration-200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
@apply opacity-10;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加鼠标隐藏样式
|
||||
.video-container {
|
||||
@apply w-full h-full relative;
|
||||
transition: cursor 0.3s ease;
|
||||
|
||||
&.cursor-hidden {
|
||||
* {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
// 控制栏区域保持鼠标可见
|
||||
.custom-controls {
|
||||
* {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.n-slider {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,79 +1,155 @@
|
||||
<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
|
||||
class="play-list-type-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index <= 19 ? index : index - 19)"
|
||||
v-show="isShowAllPlaylistCategory || 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 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 { onMounted, ref } from "vue";
|
||||
import { getPlaylistCategory } from "@/api/home";
|
||||
import type { IPlayListSort } from "@/type/playlist";
|
||||
import { setAnimationDelay, setAnimationClass } from "@/utils";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
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((item, 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 { data } = await getPlaylistCategory();
|
||||
playlistCategory.value = data;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const handleClickPlaylistType = (type: any) => {
|
||||
router.push({
|
||||
path: "/list",
|
||||
query: {
|
||||
type: type,
|
||||
}
|
||||
});
|
||||
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();
|
||||
loadPlaylistCategory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4;
|
||||
@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;
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -1,86 +1,106 @@
|
||||
<template>
|
||||
<div class="recommend-album">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div>
|
||||
<div class="recommend-album-list">
|
||||
<template v-for="(item,index) in albumData?.albums" :key="item.id">
|
||||
<div
|
||||
v-if="index < 6"
|
||||
class="recommend-album-list-item"
|
||||
:class="setAnimationClass('animate__backInUp')"
|
||||
:style="setAnimationDelay(index, 100)"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<n-image
|
||||
class="recommend-album-list-item-img"
|
||||
:src="getImgUrl( item.blurPicUrl, '200y200')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="recommend-album-list-item-content">{{ item.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="recommend-album">
|
||||
<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
|
||||
v-if="index < 6"
|
||||
class="recommend-album-list-item"
|
||||
:class="setAnimationClass('animate__backInUp')"
|
||||
:style="setAnimationDelay(index, 100)"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<n-image
|
||||
class="recommend-album-list-item-img"
|
||||
:src="getImgUrl(item.blurPicUrl, '200y200')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="recommend-album-list-item-content">{{ item.name }}</div>
|
||||
</div>
|
||||
<MusicList v-model:show="showMusic" :name="albumName" :song-list="songList" />
|
||||
</template>
|
||||
</div>
|
||||
<MusicList
|
||||
v-model:show="showMusic"
|
||||
:name="albumName"
|
||||
:song-list="songList"
|
||||
:cover="false"
|
||||
:loading="loadingList"
|
||||
:list-info="albumInfo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getNewAlbum } from "@/api/home"
|
||||
import { ref, onMounted } from "vue";
|
||||
import type { IAlbumNew } from "@/type/album"
|
||||
import { setAnimationClass, setAnimationDelay, getImgUrl } from "@/utils";
|
||||
import { getAlbum } from "@/api/list";
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { getNewAlbum } from '@/api/home';
|
||||
import { getAlbum } from '@/api/list';
|
||||
import type { IAlbumNew } from '@/type/album';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const albumData = ref<IAlbumNew>()
|
||||
const albumData = ref<IAlbumNew>();
|
||||
const loadAlbumList = async () => {
|
||||
const { data } = await getNewAlbum();
|
||||
albumData.value = data
|
||||
}
|
||||
const { data } = await getNewAlbum();
|
||||
albumData.value = data;
|
||||
};
|
||||
|
||||
const showMusic = ref(false)
|
||||
const songList = ref([])
|
||||
const albumName = ref('')
|
||||
|
||||
const handleClick = async (item:any) => {
|
||||
albumName.value = item.name
|
||||
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
|
||||
})
|
||||
}
|
||||
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(() => {
|
||||
loadAlbumList()
|
||||
})
|
||||
loadAlbumList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recommend-album {
|
||||
@apply flex-1 mx-5;
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4;
|
||||
}
|
||||
@apply flex-1 mx-5;
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4;
|
||||
}
|
||||
|
||||
.recommend-album-list {
|
||||
@apply grid grid-cols-2 grid-rows-3 gap-2;
|
||||
&-item {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
&-img {
|
||||
@apply rounded-xl transition w-full h-full;
|
||||
}
|
||||
&:hover img {
|
||||
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;
|
||||
}
|
||||
&-content:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.recommend-album-list {
|
||||
@apply grid grid-cols-2 grid-rows-3 gap-2;
|
||||
&-item {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
&-img {
|
||||
@apply rounded-xl transition w-full h-full;
|
||||
}
|
||||
&:hover img {
|
||||
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;
|
||||
}
|
||||
&-content:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +1,158 @@
|
||||
<template>
|
||||
<!-- 推荐歌手 -->
|
||||
<!-- 推荐歌手 -->
|
||||
<n-scrollbar :size="100" :x-scrollable="true">
|
||||
<div class="recommend-singer">
|
||||
<div class="recommend-singer-list">
|
||||
<div
|
||||
class="recommend-singer-item relative"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
v-for="(item, index) in hotSingerData?.artists"
|
||||
:style="setAnimationDelay(index, 100)"
|
||||
:key="item.id"
|
||||
>
|
||||
<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 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>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="recommend-singer-item-info-name">{{ item.name }}</div>
|
||||
<div class="recommend-singer-item-info-name">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recommend-singer-list">
|
||||
<div
|
||||
v-if="dayRecommendData"
|
||||
class="recommend-singer-item relative"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="setAnimationDelay(0, 100)"
|
||||
>
|
||||
<div
|
||||
:style="setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '300y300'))"
|
||||
class="recommend-singer-item-bg"
|
||||
></div>
|
||||
<div
|
||||
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
|
||||
@click="showMusic = true"
|
||||
>
|
||||
<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">
|
||||
{{ item.name }}
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in hotSingerData?.artists"
|
||||
:key="item.id"
|
||||
class="recommend-singer-item relative"
|
||||
: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 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>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
|
||||
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<music-list
|
||||
v-if="dayRecommendData?.dailySongs.length"
|
||||
v-model:show="showMusic"
|
||||
name="每日推荐列表"
|
||||
:song-list="dayRecommendData?.dailySongs"
|
||||
:cover="false"
|
||||
/>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { setBackgroundImg, setAnimationDelay, setAnimationClass,getImgUrl } from "@/utils";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { getHotSinger } from "@/api/home";
|
||||
import type { IHotSinger } from "@/type/singer";
|
||||
import router from "@/router";
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getDayRecommend, getHotSinger } from '@/api/home';
|
||||
import router from '@/router';
|
||||
import { IDayRecommend } from '@/type/day_recommend';
|
||||
import type { IHotSinger } from '@/type/singer';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// 歌手信息
|
||||
const hotSingerData = ref<IHotSinger>();
|
||||
const dayRecommendData = ref<IDayRecommend>();
|
||||
const showMusic = ref(false);
|
||||
|
||||
//加载推荐歌手
|
||||
const loadSingerList = async () => {
|
||||
const { data } = await getHotSinger({ offset: 0, limit: 5 });
|
||||
hotSingerData.value = data;
|
||||
};
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadSingerList();
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 第一个请求:获取热门歌手
|
||||
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
|
||||
|
||||
const toSearchSinger = (keyword: string) => {
|
||||
router.push({
|
||||
path: "/search",
|
||||
query: {
|
||||
keyword: keyword,
|
||||
},
|
||||
});
|
||||
// 第二个请求:获取每日推荐
|
||||
try {
|
||||
const {
|
||||
data: { data: dayRecommend },
|
||||
} = await getDayRecommend();
|
||||
// 处理数据
|
||||
if (dayRecommend) {
|
||||
singerData.artists = singerData.artists.slice(0, 4);
|
||||
}
|
||||
dayRecommendData.value = dayRecommend;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
|
||||
hotSingerData.value = singerData;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toSearchSinger = (keyword: string) => {
|
||||
router.push({
|
||||
path: '/search',
|
||||
query: {
|
||||
keyword,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 监听登录状态
|
||||
watchEffect(() => {
|
||||
if (store.state.user) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recommend-singer {
|
||||
&-list {
|
||||
@apply flex;
|
||||
height: 280px;
|
||||
&-list {
|
||||
@apply flex;
|
||||
height: 280px;
|
||||
}
|
||||
&-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;
|
||||
filter: brightness(60%);
|
||||
}
|
||||
&-item {
|
||||
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between;
|
||||
&-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;
|
||||
filter: brightness(80%);
|
||||
}
|
||||
&-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;
|
||||
}
|
||||
}
|
||||
&-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile .recommend-singer {
|
||||
&-list {
|
||||
height: 180px;
|
||||
@apply ml-4;
|
||||
}
|
||||
&-item {
|
||||
@apply p-4 rounded-xl;
|
||||
&-bg {
|
||||
@apply rounded-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<template>
|
||||
<div class="recommend-music">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
本周最热音乐
|
||||
</div>
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
|
||||
<div
|
||||
v-show="recommendMusic?.result"
|
||||
v-loading="loading"
|
||||
class="recommend-music-list"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
v-show="recommendMusic?.result"
|
||||
>
|
||||
<!-- 推荐音乐列表 -->
|
||||
<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>
|
||||
@@ -22,30 +18,35 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getRecommendMusic } from '@/api/home'
|
||||
import type { IRecommendMusic } from '@/type/music'
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils'
|
||||
import SongItem from './common/SongItem.vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getRecommendMusic } from '@/api/home';
|
||||
import type { IRecommendMusic } from '@/type/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
import SongItem from './common/SongItem.vue';
|
||||
|
||||
const store = useStore();
|
||||
// 推荐歌曲
|
||||
const recommendMusic = ref<IRecommendMusic>()
|
||||
const recommendMusic = ref<IRecommendMusic>();
|
||||
const loading = ref(false);
|
||||
|
||||
// 加载推荐歌曲
|
||||
const loadRecommendMusic = async () => {
|
||||
const { data } = await getRecommendMusic({ limit: 10 })
|
||||
recommendMusic.value = data
|
||||
}
|
||||
loading.value = true;
|
||||
const { data } = await getRecommendMusic({ limit: 10 });
|
||||
recommendMusic.value = data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadRecommendMusic()
|
||||
})
|
||||
loadRecommendMusic();
|
||||
});
|
||||
|
||||
const handlePlay = (item: any) => {
|
||||
store.commit('setPlayList', recommendMusic.value?.result)
|
||||
}
|
||||
const handlePlay = () => {
|
||||
store.commit('setPlayList', recommendMusic.value?.result);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
131
src/components/common/InstallAppModal.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<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 config from '@/../package.json';
|
||||
import { isMobile } from '@/utils';
|
||||
|
||||
const showModal = ref(false);
|
||||
const isElectron = ref((window as any).electron !== undefined);
|
||||
const noPrompt = ref(false);
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
if (noPrompt.value) {
|
||||
localStorage.setItem('installPromptDismissed', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 如果是 electron 环境,不显示安装提示
|
||||
if (isElectron.value || 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>
|
||||
@@ -1,42 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { setAnimationClass, setAnimationDelay } from "@/utils";
|
||||
|
||||
const props = defineProps({
|
||||
showPop: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
})
|
||||
|
||||
const musicFullClass = computed(() => {
|
||||
if (props.showPop) {
|
||||
return setAnimationClass('animate__fadeInUp')
|
||||
} else {
|
||||
return setAnimationClass('animate__fadeOutDown')
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pop-page" v-show="props.showPop" :class="musicFullClass">
|
||||
<i class="iconfont icon-icon_error close" v-if="props.showClose"></i>
|
||||
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pop-page {
|
||||
height: 800px;
|
||||
@apply absolute top-4 left-0 w-full;
|
||||
background-color: #000000f0;
|
||||
.close {
|
||||
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script lang="ts" setup>
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
const props = defineProps({
|
||||
showPop: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const musicFullClass = computed(() => {
|
||||
if (props.showPop) {
|
||||
return setAnimationClass('animate__fadeInUp');
|
||||
}
|
||||
return setAnimationClass('animate__fadeOutDown');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="props.showPop" class="pop-page" :class="musicFullClass">
|
||||
<i v-if="props.showClose" class="iconfont icon-icon_error close"></i>
|
||||
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pop-page {
|
||||
height: 800px;
|
||||
@apply absolute top-4 left-0 w-full;
|
||||
background-color: #000000f0;
|
||||
.close {
|
||||
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<template>
|
||||
<div class="bottom" v-if="isPlay"></div>
|
||||
<div v-if="isPlay" class="bottom" :style="{ height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
const store = useStore()
|
||||
const isPlay = computed(() => store.state.isPlay as boolean)
|
||||
|
||||
const store = useStore();
|
||||
const isPlay = computed(() => store.state.isPlay as boolean);
|
||||
defineProps({
|
||||
height: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bottom{
|
||||
.bottom {
|
||||
@apply h-28;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
73
src/components/common/PlayVideo.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<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';
|
||||
|
||||
import { audioService } from '@/services/audioService';
|
||||
|
||||
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);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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>
|
||||
@@ -1,70 +1,121 @@
|
||||
<template>
|
||||
<div class="search-item" @click="handleClick">
|
||||
<div class="search-item" :class="item.type" @click="handleClick">
|
||||
<div class="search-item-img">
|
||||
<n-image
|
||||
:src="getImgUrl(item.picUrl, 'album')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<n-image :src="getImgUrl(item.picUrl, '320y180')" lazy preview-disabled />
|
||||
<div v-if="item.type === 'mv'" class="play">
|
||||
<i class="iconfont icon icon-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-item-info">
|
||||
<div class="search-item-name">{{ item.name }}</div>
|
||||
<div class="search-item-artist">{{ item.desc}}</div>
|
||||
<p class="search-item-name">{{ item.name }}</p>
|
||||
<p class="search-item-artist">{{ item.desc }}</p>
|
||||
</div>
|
||||
|
||||
<MusicList v-model:show="showMusic" :name="item.name" :song-list="songList" />
|
||||
<MusicList
|
||||
v-if="['专辑', 'playlist'].includes(item.type)"
|
||||
v-model:show="showPop"
|
||||
:name="item.name"
|
||||
:song-list="songList"
|
||||
:list-info="listInfo"
|
||||
/>
|
||||
<mv-player v-if="item.type === 'mv'" v-model:show="showPop" :current-mv="getCurrentMv()" no-list />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getImgUrl } from '@/utils'
|
||||
import type {Album} from '@/type/album'
|
||||
import { getAlbum } from '@/api/list';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getAlbum, getListDetail } from '@/api/list';
|
||||
import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
item: {
|
||||
picUrl: string
|
||||
name: string
|
||||
desc: string
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
}>()
|
||||
picUrl: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}>();
|
||||
|
||||
const songList = ref([])
|
||||
const songList = ref<any[]>([]);
|
||||
|
||||
const showMusic = ref(false)
|
||||
const showPop = ref(false);
|
||||
const listInfo = ref<any>(null);
|
||||
|
||||
const getCurrentMv = () => {
|
||||
return {
|
||||
id: props.item.id,
|
||||
name: props.item.name,
|
||||
} as unknown as IMvItem;
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const handleClick = async () => {
|
||||
showMusic.value = true
|
||||
if(props.item.type === '专辑'){
|
||||
const res = await getAlbum(props.item.id)
|
||||
songList.value = res.data.songs.map((song:any)=>{
|
||||
song.al.picUrl = song.al.picUrl || props.item.picUrl
|
||||
return song
|
||||
})
|
||||
listInfo.value = null;
|
||||
if (props.item.type === '专辑') {
|
||||
showPop.value = true;
|
||||
const res = await getAlbum(props.item.id);
|
||||
songList.value = res.data.songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || props.item.picUrl;
|
||||
return song;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.item.type === 'playlist') {
|
||||
showPop.value = true;
|
||||
const res = await getListDetail(props.item.id);
|
||||
songList.value = res.data.playlist.tracks;
|
||||
listInfo.value = res.data.playlist;
|
||||
}
|
||||
|
||||
if (props.item.type === 'mv') {
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
showPop.value = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.search-item{
|
||||
@apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition;
|
||||
.search-item {
|
||||
@apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition cursor-pointer;
|
||||
margin: 0 10px;
|
||||
.search-item-img{
|
||||
.search-item-img {
|
||||
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
|
||||
}
|
||||
.search-item-info{
|
||||
&-name{
|
||||
.search-item-info {
|
||||
@apply flex-1 overflow-hidden;
|
||||
&-name {
|
||||
@apply text-white text-sm text-center;
|
||||
}
|
||||
&-artist{
|
||||
&-artist {
|
||||
@apply text-gray-400 text-xs text-center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
.mv {
|
||||
&:hover {
|
||||
.play {
|
||||
@apply opacity-60;
|
||||
}
|
||||
}
|
||||
.search-item-img {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
@apply rounded-lg relative;
|
||||
}
|
||||
.play {
|
||||
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity;
|
||||
.icon {
|
||||
@apply text-white text-5xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
<template>
|
||||
<div class="song-item" :class="{'song-mini': mini}">
|
||||
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
|
||||
<n-image
|
||||
v-if="item.picUrl "
|
||||
:src="getImgUrl( item.picUrl, '40y40')"
|
||||
v-if="item.picUrl"
|
||||
ref="songImg"
|
||||
:src="getImgUrl(item.picUrl, '40y40')"
|
||||
class="song-item-img"
|
||||
lazy
|
||||
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.song.artists"
|
||||
:key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{
|
||||
artistsindex < item.song.artists.length - 1 ? ' / ' : ''
|
||||
}}</span
|
||||
<div v-if="list" class="song-item-content-wrapper">
|
||||
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
||||
<div class="song-item-content-divider">-</div>
|
||||
<n-ellipsis class="song-item-content-name 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>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
<div class="song-item-operating">
|
||||
<div class="song-item-operating-like">
|
||||
<i class="iconfont icon-likefill"></i>
|
||||
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
|
||||
<div v-if="favorite" class="song-item-operating-like">
|
||||
<i class="iconfont icon-likefill" :class="{ 'like-active': isFavorite }" @click.stop="toggleFavorite"></i>
|
||||
</div>
|
||||
<div
|
||||
class="song-item-operating-play bg-black"
|
||||
:class="isPlaying ? 'bg-green-600' : ''"
|
||||
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>
|
||||
@@ -43,40 +51,90 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useStore } from 'vuex'
|
||||
import type { SongResult } from '@/type/music'
|
||||
import { getImgUrl } from '@/utils'
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: SongResult
|
||||
mini?: boolean
|
||||
}>(), {
|
||||
mini: false
|
||||
})
|
||||
import { audioService } from '@/services/audioService';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageBackground } from '@/utils/linearColor';
|
||||
|
||||
const store = useStore()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: SongResult;
|
||||
mini?: boolean;
|
||||
list?: boolean;
|
||||
favorite?: boolean;
|
||||
}>(),
|
||||
{
|
||||
mini: false,
|
||||
list: false,
|
||||
favorite: true,
|
||||
},
|
||||
);
|
||||
|
||||
const play = computed(() => store.state.play as boolean)
|
||||
const store = useStore();
|
||||
|
||||
const playMusic = computed(() => store.state.playMusic)
|
||||
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
|
||||
})
|
||||
return playMusic.value.id === props.item.id;
|
||||
});
|
||||
|
||||
const emits = defineEmits(['play'])
|
||||
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 = (item: any) => {
|
||||
store.commit('setPlay', item)
|
||||
store.commit('setIsPlay', true)
|
||||
emits('play', item)
|
||||
}
|
||||
const playMusicEvent = async (item: SongResult) => {
|
||||
if (playMusic.value.id === item.id) {
|
||||
if (play.value) {
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await store.commit('setPlay', item);
|
||||
store.commit('setIsPlay', true);
|
||||
emits('play', item);
|
||||
};
|
||||
|
||||
// 判断是否已收藏
|
||||
const isFavorite = computed(() => {
|
||||
return store.state.favoriteList.includes(props.item.id);
|
||||
});
|
||||
|
||||
// 切换收藏状态
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (isFavorite.value) {
|
||||
store.commit('removeFromFavorite', props.item.id);
|
||||
} else {
|
||||
store.commit('addToFavorite', props.item.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
// 配置文字不可选中
|
||||
.text-ellipsis {
|
||||
width: 100%;
|
||||
@@ -101,7 +159,7 @@ const playMusicEvent = (item: any) => {
|
||||
}
|
||||
}
|
||||
&-operating {
|
||||
@apply flex items-center pl-4 rounded-full border border-gray-700 ml-4;
|
||||
@apply flex items-center rounded-full border border-gray-700 ml-4;
|
||||
background-color: #0d0d0d;
|
||||
.iconfont {
|
||||
@apply text-xl;
|
||||
@@ -111,42 +169,83 @@ const playMusicEvent = (item: any) => {
|
||||
@apply text-xl hover:text-red-600 transition;
|
||||
}
|
||||
&-like {
|
||||
@apply mr-2 cursor-pointer;
|
||||
@apply mr-2 cursor-pointer ml-4;
|
||||
}
|
||||
.like-active {
|
||||
@apply text-red-600;
|
||||
}
|
||||
&-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{
|
||||
.song-mini {
|
||||
@apply p-2 rounded-2xl;
|
||||
.song-item{
|
||||
.song-item {
|
||||
@apply p-0;
|
||||
&-img{
|
||||
&-img {
|
||||
@apply w-10 h-10 mr-2;
|
||||
}
|
||||
&-content{
|
||||
&-content {
|
||||
@apply flex-1;
|
||||
&-title{
|
||||
&-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
&-name{
|
||||
&-name {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
&-operating{
|
||||
&-operating {
|
||||
@apply pl-2;
|
||||
.iconfont{
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
&-like{
|
||||
@apply mr-1;
|
||||
&-like {
|
||||
@apply mr-1 ml-1;
|
||||
}
|
||||
&-play{
|
||||
&-play {
|
||||
@apply w-8 h-8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.song-list {
|
||||
@apply p-2 rounded-lg hover:bg-gray-800/50 border border-gray-800/50 mb-2;
|
||||
.song-item-img {
|
||||
@apply w-10 h-10 rounded-lg mr-3;
|
||||
}
|
||||
.song-item-content {
|
||||
@apply flex items-center flex-1;
|
||||
&-wrapper {
|
||||
@apply flex items-center flex-1 text-sm;
|
||||
}
|
||||
&-title {
|
||||
@apply text-white flex-shrink-0 max-w-[45%];
|
||||
}
|
||||
&-divider {
|
||||
@apply mx-2 text-gray-400;
|
||||
}
|
||||
&-name {
|
||||
@apply text-gray-400 flex-1 min-w-0;
|
||||
}
|
||||
}
|
||||
.song-item-operating {
|
||||
@apply flex items-center gap-2;
|
||||
&-like {
|
||||
@apply cursor-pointer hover:scale-110 transition-transform;
|
||||
.iconfont {
|
||||
@apply text-base text-gray-400 hover:text-red-500;
|
||||
}
|
||||
}
|
||||
&-play {
|
||||
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const USER_SET_OPTIONS = [
|
||||
label: '设置',
|
||||
key: 'set',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export const SEARCH_TYPES = [
|
||||
{
|
||||
@@ -30,36 +30,36 @@ export const SEARCH_TYPES = [
|
||||
label: '专辑',
|
||||
key: 10,
|
||||
},
|
||||
{
|
||||
label: '歌手',
|
||||
key: 100,
|
||||
},
|
||||
// {
|
||||
// label: '歌手',
|
||||
// key: 100,
|
||||
// },
|
||||
{
|
||||
label: '歌单',
|
||||
key: 1000,
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
key: 1002,
|
||||
},
|
||||
// {
|
||||
// label: '用户',
|
||||
// key: 1002,
|
||||
// },
|
||||
{
|
||||
label: 'MV',
|
||||
key: 1004,
|
||||
},
|
||||
{
|
||||
label: '歌词',
|
||||
key: 1006,
|
||||
},
|
||||
{
|
||||
label: '电台',
|
||||
key: 1009,
|
||||
},
|
||||
{
|
||||
label: '视频',
|
||||
key: 1014,
|
||||
},
|
||||
{
|
||||
label: '综合',
|
||||
key: 1018,
|
||||
},
|
||||
]
|
||||
// {
|
||||
// label: '歌词',
|
||||
// key: 1006,
|
||||
// },
|
||||
// {
|
||||
// label: '电台',
|
||||
// key: 1009,
|
||||
// },
|
||||
// {
|
||||
// label: '视频',
|
||||
// key: 1014,
|
||||
// },
|
||||
// {
|
||||
// label: '综合',
|
||||
// key: 1018,
|
||||
// },
|
||||
];
|
||||
|
||||
7
src/directive/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { vLoading } from './loading/index';
|
||||
|
||||
const directives = {
|
||||
loading: vLoading,
|
||||
};
|
||||
|
||||
export default directives;
|
||||
40
src/directive/loading/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createVNode, render, VNode } from 'vue';
|
||||
|
||||
import Loading from './index.vue';
|
||||
|
||||
const vnode: VNode = createVNode(Loading) as VNode;
|
||||
|
||||
export const vLoading = {
|
||||
// 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
|
||||
mounted: (el: HTMLElement, binding: any) => {
|
||||
render(vnode, el);
|
||||
},
|
||||
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
|
||||
updated: (el: HTMLElement, binding: any) => {
|
||||
if (binding.value) {
|
||||
vnode?.component?.exposed.show();
|
||||
} else {
|
||||
vnode?.component?.exposed.hide();
|
||||
}
|
||||
// 动态添加删除自定义class: loading-parent
|
||||
formatterClass(el, binding);
|
||||
},
|
||||
// 绑定元素的父组件卸载后调用
|
||||
unmounted: () => {
|
||||
vnode?.component?.exposed.hide();
|
||||
},
|
||||
};
|
||||
|
||||
function formatterClass(el: HTMLElement, binding: any) {
|
||||
const classStr = el.getAttribute('class');
|
||||
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
|
||||
if (binding.value) {
|
||||
if (tagetClass === -1) {
|
||||
el.setAttribute('class', `${classStr} loading-parent`);
|
||||
}
|
||||
} else if (tagetClass > -1) {
|
||||
const classArray: Array<string> = classStr?.split('') as string[];
|
||||
classArray.splice(tagetClass - 1, tagetClass + 15);
|
||||
el.setAttribute('class', classArray?.join(''));
|
||||
}
|
||||
}
|
||||
92
src/directive/loading/index.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<!-- -->
|
||||
<template>
|
||||
<div v-if="isShow" class="loading-box">
|
||||
<div class="mask" :style="{ background: maskBackground }"></div>
|
||||
<div class="loading-content-box">
|
||||
<n-spin size="small" />
|
||||
<div :style="{ color: textColor }" class="tip">{{ tip }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NSpin } from 'naive-ui';
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
tip: {
|
||||
type: String,
|
||||
default() {
|
||||
return '加载中...';
|
||||
},
|
||||
},
|
||||
maskBackground: {
|
||||
type: String,
|
||||
default() {
|
||||
return 'rgba(0, 0, 0, 0.8)';
|
||||
},
|
||||
},
|
||||
loadingColor: {
|
||||
type: String,
|
||||
default() {
|
||||
return 'rgba(255, 255, 255, 1)';
|
||||
},
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
default() {
|
||||
return 'rgba(255, 255, 255, 1)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isShow = ref(false);
|
||||
const show = () => {
|
||||
isShow.value = true;
|
||||
};
|
||||
const hide = () => {
|
||||
isShow.value = false;
|
||||
};
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
isShow,
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.loading-box {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
.n-spin {
|
||||
// color: #ccc;
|
||||
}
|
||||
.mask {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.loading-content-box {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.tip {
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
10
src/electron.d.ts
vendored
@@ -1,10 +1,10 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
minimize: () => void
|
||||
maximize: () => void
|
||||
close: () => void
|
||||
dragStart: () => void
|
||||
}
|
||||
minimize: () => void;
|
||||
maximize: () => void;
|
||||
close: () => void;
|
||||
dragStart: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
183
src/hooks/IndexDBHook.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 创建一个使用 IndexedDB 的组合函数
|
||||
const useIndexedDB = () => {
|
||||
const db = ref<IDBDatabase | null>(null); // 数据库引用
|
||||
|
||||
// 打开数据库并创建表
|
||||
const initDB = (dbName: string, version: number, stores: { name: string; keyPath?: string }[]) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, version); // 打开数据库请求
|
||||
|
||||
request.onupgradeneeded = (event: any) => {
|
||||
const db = event.target.result; // 获取数据库实例
|
||||
stores.forEach((store) => {
|
||||
if (!db.objectStoreNames.contains(store.name)) {
|
||||
// 确保对象存储(表)创建
|
||||
db.createObjectStore(store.name, {
|
||||
keyPath: store.keyPath || 'id',
|
||||
autoIncrement: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
request.onsuccess = (event: any) => {
|
||||
db.value = event.target.result; // 保存数据库实例
|
||||
resolve(); // 成功时解析 Promise
|
||||
};
|
||||
|
||||
request.onerror = (event: any) => {
|
||||
reject(event.target.error); // 失败时拒绝 Promise
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 通用新增数据
|
||||
const addData = (storeName: string, value: any) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化'); // 检查数据库是否已初始化
|
||||
const tx = db.value.transaction(storeName, 'readwrite'); // 创建事务
|
||||
const store = tx.objectStore(storeName); // 获取对象存储
|
||||
|
||||
const request = store.add(value); // 添加数据请求
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('成功'); // 成功时输出
|
||||
resolve(); // 解析 Promise
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
console.error('新增失败:', (event.target as IDBRequest).error); // 输出错误
|
||||
reject((event.target as IDBRequest).error); // 拒绝 Promise
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 通用保存数据(新增或更新)
|
||||
const saveData = (storeName: string, value: any) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = store.put(value);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('成功');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 通用获取数据
|
||||
const getData = (storeName: string, key: string | number) => {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = store.get(key);
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
if (event.target) {
|
||||
resolve((event.target as IDBRequest).result);
|
||||
} else {
|
||||
reject('事件目标为空');
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const deleteData = (storeName: string, key: string | number) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readwrite');
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = store.delete(key);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('删除成功');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 查询所有数据
|
||||
const getAllData = (storeName: string) => {
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
if (event.target) {
|
||||
resolve((event.target as IDBRequest).result);
|
||||
} else {
|
||||
reject('事件目标为空');
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 分页查询数据
|
||||
const getDataWithPagination = (storeName: string, page: number, pageSize: number) => {
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
if (!db.value) return reject('数据库未初始化');
|
||||
const tx = db.value.transaction(storeName, 'readonly');
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = store.openCursor(); // 打开游标请求
|
||||
const results: any[] = []; // 存储结果的数组
|
||||
let index = 0; // 当前索引
|
||||
const skip = (page - 1) * pageSize; // 计算跳过的数量
|
||||
|
||||
request.onsuccess = (event: any) => {
|
||||
const cursor = event.target.result; // 获取游标
|
||||
if (!cursor) {
|
||||
resolve(results); // 如果没有更多数据,解析结果
|
||||
return;
|
||||
}
|
||||
|
||||
if (index >= skip && results.length < pageSize) {
|
||||
results.push(cursor.value); // 添加当前游标值到结果
|
||||
}
|
||||
|
||||
index++; // 增加索引
|
||||
cursor.continue(); // 继续游标
|
||||
};
|
||||
|
||||
request.onerror = (event: any) => {
|
||||
reject(event.target.error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
initDB,
|
||||
addData,
|
||||
saveData,
|
||||
getData,
|
||||
deleteData,
|
||||
getAllData,
|
||||
getDataWithPagination,
|
||||
};
|
||||
};
|
||||
|
||||
export default useIndexedDB;
|
||||
@@ -1,39 +1,39 @@
|
||||
// musicHistoryHooks
|
||||
import { RemovableRef, useLocalStorage } from '@vueuse/core'
|
||||
import type { SongResult } from '@/type/music'
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
|
||||
import type { SongResult } from '@/type/music';
|
||||
|
||||
export const useMusicHistory = () => {
|
||||
const musicHistory = useLocalStorage<SongResult[]>('musicHistory', [])
|
||||
const musicHistory = useLocalStorage<SongResult[]>('musicHistory', []);
|
||||
|
||||
const addMusic = (music: SongResult) => {
|
||||
const index = musicHistory.value.findIndex((item) => item.id === music.id)
|
||||
const index = musicHistory.value.findIndex((item) => item.id === music.id);
|
||||
if (index !== -1) {
|
||||
musicHistory.value[index].count =
|
||||
(musicHistory.value[index].count || 0) + 1
|
||||
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0])
|
||||
musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
|
||||
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
|
||||
} else {
|
||||
musicHistory.value.unshift({ ...music, count: 1 })
|
||||
musicHistory.value.unshift({ ...music, count: 1 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const delMusic = (music: any) => {
|
||||
const index = musicHistory.value.findIndex((item) => item.id === music.id)
|
||||
const delMusic = (music: SongResult) => {
|
||||
const index = musicHistory.value.findIndex((item) => item.id === music.id);
|
||||
if (index !== -1) {
|
||||
musicHistory.value.splice(index, 1)
|
||||
musicHistory.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
const musicList = ref(musicHistory.value)
|
||||
};
|
||||
const musicList = ref(musicHistory.value);
|
||||
watch(
|
||||
() => musicHistory.value,
|
||||
() => {
|
||||
musicList.value = musicHistory.value
|
||||
}
|
||||
)
|
||||
musicList.value = musicHistory.value;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
musicHistory,
|
||||
musicList,
|
||||
addMusic,
|
||||
delMusic,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,104 +1,343 @@
|
||||
import { getMusicLrc } from '@/api/music'
|
||||
import { ILyric } from '@/type/lyric'
|
||||
import { getIsMc } from '@/utils'
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface ILrcData {
|
||||
text: string
|
||||
trText: string
|
||||
}
|
||||
import { audioService } from '@/services/audioService';
|
||||
import store from '@/store';
|
||||
import type { ILyricText, SongResult } from '@/type/music';
|
||||
|
||||
const lrcData = ref<ILyric>()
|
||||
const newLrcIndex = ref<number>(0)
|
||||
const lrcArray = ref<Array<ILrcData>>([])
|
||||
const lrcTimeArray = ref<Array<Number>>([])
|
||||
const windowData = window as any;
|
||||
|
||||
const parseTime = (timeString: string) => {
|
||||
const [minutes, seconds] = timeString.split(':')
|
||||
return parseInt(minutes) * 60 + parseFloat(seconds)
|
||||
}
|
||||
export const isElectron = computed(() => !!windowData.electronAPI);
|
||||
|
||||
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g
|
||||
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g
|
||||
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 playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
|
||||
export const sound = ref<Howl | null>(audioService.getCurrentSound());
|
||||
export const isLyricWindowOpen = ref(false); // 新增状态
|
||||
|
||||
function parseLyricLine(lyricLine: string) {
|
||||
// [00:00.00] 作词 : 长友美知惠/
|
||||
const timeText = lyricLine.match(TIME_REGEX)?.[0] || ''
|
||||
const time = parseTime(timeText)
|
||||
const text = lyricLine.replace(LRC_REGEX, '').trim()
|
||||
return { time, text }
|
||||
}
|
||||
|
||||
interface ILyricText {
|
||||
text: string
|
||||
trText: string
|
||||
}
|
||||
|
||||
function parseLyrics(lyricsString: string) {
|
||||
const lines = lyricsString.split('\n')
|
||||
const lyrics: Array<ILyricText> = []
|
||||
const times: number[] = []
|
||||
lines.forEach((line) => {
|
||||
const { time, text } = parseLyricLine(line)
|
||||
times.push(time)
|
||||
lyrics.push({ text, trText: '' })
|
||||
})
|
||||
return { lyrics, times }
|
||||
}
|
||||
|
||||
const loadLrc = async (playMusicId: number): Promise<void> => {
|
||||
try {
|
||||
const { data } = await getMusicLrc(playMusicId)
|
||||
const { lyrics, times } = parseLyrics(data.lrc.lyric)
|
||||
lrcTimeArray.value = times
|
||||
lrcArray.value = lyrics
|
||||
} catch (err) {
|
||||
console.error('err', err)
|
||||
document.onkeyup = (e) => {
|
||||
// 检查事件目标是否是输入框元素
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 歌词矫正时间Correction time
|
||||
const correctionTime = ref(0.4)
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
if (store.state.play) {
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => store.state.playMusicUrl,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
audioService.play(newVal);
|
||||
sound.value = audioService.getCurrentSound();
|
||||
audioServiceOn(audioService);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.state.playMusic,
|
||||
() => {
|
||||
nextTick(async () => {
|
||||
lrcArray.value = playMusic.value.lyric?.lrcArray || [];
|
||||
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
|
||||
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
|
||||
if (isElectron.value && isLyricWindowOpen.value && lrcArray.value.length > 0) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const audioServiceOn = (audio: typeof audioService) => {
|
||||
let interval: any = null;
|
||||
|
||||
// 监听播放
|
||||
audio.onPlay(() => {
|
||||
store.commit('setPlayMusic', true);
|
||||
interval = setInterval(() => {
|
||||
nowTime.value = sound.value?.seek() as number;
|
||||
allTime.value = sound.value?.duration() as number;
|
||||
const newIndex = getLrcIndex(nowTime.value);
|
||||
if (newIndex !== nowIndex.value) {
|
||||
nowIndex.value = newIndex;
|
||||
currentLrcProgress.value = 0;
|
||||
// 当歌词索引更新时,发送歌词数据
|
||||
if (isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}
|
||||
// 定期发送歌词数据更新
|
||||
if (isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// 监听暂停
|
||||
audio.onPause(() => {
|
||||
store.commit('setPlayMusic', false);
|
||||
clearInterval(interval);
|
||||
// 暂停时也发送一次状态更新
|
||||
if (isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听结束
|
||||
audio.onEnd(() => {
|
||||
if (store.state.playMode === 1) {
|
||||
// 单曲循环模式
|
||||
audio.getCurrentSound()?.play();
|
||||
} else {
|
||||
// 列表循环模式
|
||||
store.commit('nextPlay');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const play = () => {
|
||||
audioService.getCurrentSound()?.play();
|
||||
};
|
||||
|
||||
export const pause = () => {
|
||||
audioService.getCurrentSound()?.pause();
|
||||
};
|
||||
|
||||
const isPlaying = computed(() => store.state.play as boolean);
|
||||
|
||||
// 增加矫正时间
|
||||
const addCorrectionTime = (time: number) => {
|
||||
correctionTime.value += time
|
||||
}
|
||||
export const addCorrectionTime = (time: number) => (correctionTime.value += time);
|
||||
|
||||
// 减少矫正时间
|
||||
const reduceCorrectionTime = (time: number) => {
|
||||
correctionTime.value -= time
|
||||
}
|
||||
export const reduceCorrectionTime = (time: number) => (correctionTime.value -= time);
|
||||
|
||||
const isCurrentLrc = (index: any, time: number) => {
|
||||
const currentTime = Number(lrcTimeArray.value[index])
|
||||
const nextTime = Number(lrcTimeArray.value[index + 1])
|
||||
const nowTime = time + correctionTime.value
|
||||
const isTrue = nowTime > currentTime && nowTime < nextTime
|
||||
if (isTrue) {
|
||||
newLrcIndex.value = index
|
||||
// 获取当前播放歌词
|
||||
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 isTrue
|
||||
}
|
||||
return nowIndex.value;
|
||||
};
|
||||
|
||||
const nowTime = ref(0)
|
||||
const allTime = ref(0)
|
||||
// 获取当前播放歌词进度
|
||||
const currentLrcTiming = computed(() => {
|
||||
const start = lrcTimeArray.value[nowIndex.value] || 0;
|
||||
const end = lrcTimeArray.value[nowIndex.value + 1] || start + 1;
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
// 设置当前播放时间
|
||||
const setAudioTime = (index: any, audio: HTMLAudioElement) => {
|
||||
audio.currentTime = lrcTimeArray.value[index] as number
|
||||
audio.play()
|
||||
}
|
||||
// 获取歌词样式
|
||||
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 {};
|
||||
};
|
||||
|
||||
export {
|
||||
lrcData,
|
||||
lrcArray,
|
||||
lrcTimeArray,
|
||||
newLrcIndex,
|
||||
loadLrc,
|
||||
isCurrentLrc,
|
||||
addCorrectionTime,
|
||||
reduceCorrectionTime,
|
||||
setAudioTime,
|
||||
nowTime,
|
||||
allTime,
|
||||
// 播放进度
|
||||
export const useLyricProgress = () => {
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
const updateProgress = () => {
|
||||
if (!isPlaying.value) return;
|
||||
const currentSound = sound.value;
|
||||
if (!currentSound) return;
|
||||
|
||||
const { start, end } = currentLrcTiming.value;
|
||||
const duration = end - start;
|
||||
const elapsed = (currentSound.seek() as number) - 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,
|
||||
};
|
||||
};
|
||||
|
||||
// 设置<E8AEBE><E7BDAE><EFBFBD>前播放时间
|
||||
export const setAudioTime = (index: number) => {
|
||||
const currentSound = sound.value;
|
||||
if (!currentSound) return;
|
||||
|
||||
currentSound.seek(lrcTimeArray.value[index]);
|
||||
currentSound.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],
|
||||
});
|
||||
|
||||
// 监听歌词数组变化,当切换歌曲时重新初始化歌词窗口
|
||||
watch(
|
||||
() => lrcArray.value,
|
||||
(newLrcArray) => {
|
||||
if (newLrcArray.length > 0 && isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 发送歌词更新数据
|
||||
export const sendLyricToWin = () => {
|
||||
if (!isElectron.value || !isLyricWindowOpen.value) {
|
||||
console.log('Cannot send lyric: electron or lyric window not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (lrcArray.value.length > 0) {
|
||||
const nowIndex = getLrcIndex(nowTime.value);
|
||||
const updateData = {
|
||||
type: 'full',
|
||||
nowIndex,
|
||||
nowTime: nowTime.value,
|
||||
startCurrentTime: lrcTimeArray.value[nowIndex],
|
||||
nextTime: lrcTimeArray.value[nowIndex + 1],
|
||||
isPlay: isPlaying.value,
|
||||
lrcArray: lrcArray.value,
|
||||
lrcTimeArray: lrcTimeArray.value,
|
||||
allTime: allTime.value,
|
||||
playMusic: playMusic.value,
|
||||
};
|
||||
windowData.electronAPI.sendLyric(JSON.stringify(updateData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending lyric update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const openLyric = () => {
|
||||
if (!isElectron.value) return;
|
||||
console.log('Opening lyric window with current song:', playMusic.value?.name);
|
||||
|
||||
isLyricWindowOpen.value = !isLyricWindowOpen.value;
|
||||
if (isLyricWindowOpen.value) {
|
||||
setTimeout(() => {
|
||||
windowData.electronAPI.openLyric();
|
||||
sendLyricToWin();
|
||||
}, 500);
|
||||
sendLyricToWin();
|
||||
} else {
|
||||
closeLyric();
|
||||
}
|
||||
};
|
||||
|
||||
// 添加关闭歌词窗口的方法
|
||||
export const closeLyric = () => {
|
||||
if (!isElectron.value) return;
|
||||
windowData.electron.ipcRenderer.send('close-lyric');
|
||||
};
|
||||
|
||||
// 添加播放控制命令监听
|
||||
if (isElectron.value) {
|
||||
windowData.electron.ipcRenderer.on('lyric-control-back', (command: string) => {
|
||||
console.log('Received playback control command:', command);
|
||||
switch (command) {
|
||||
case 'playpause':
|
||||
if (store.state.play) {
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
break;
|
||||
case 'prev':
|
||||
store.commit('prevPlay');
|
||||
break;
|
||||
case 'next':
|
||||
store.commit('nextPlay');
|
||||
break;
|
||||
case 'close':
|
||||
closeLyric();
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown command:', command);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
191
src/hooks/MusicListHook.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import type { ILyric, ILyricText, SongResult } from '@/type/music';
|
||||
import { getImgUrl, getMusicProxyUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
|
||||
const musicHistory = useMusicHistory();
|
||||
|
||||
// 获取歌曲url
|
||||
const getSongUrl = async (id: number) => {
|
||||
const { data } = await getMusicUrl(id);
|
||||
let url = '';
|
||||
try {
|
||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
||||
const res = await getParsingMusicUrl(id);
|
||||
url = res.data.data.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
url = url || data.data[0].url;
|
||||
return getMusicProxyUrl(url);
|
||||
};
|
||||
|
||||
const getSongDetail = async (playMusic: SongResult) => {
|
||||
playMusic.playLoading = true;
|
||||
const playMusicUrl = await getSongUrl(playMusic.id);
|
||||
const { backgroundColor, primaryColor } =
|
||||
playMusic.backgroundColor && playMusic.primaryColor
|
||||
? playMusic
|
||||
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor };
|
||||
};
|
||||
|
||||
// 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据
|
||||
export const useMusicListHook = () => {
|
||||
const handlePlayMusic = async (state: any, playMusic: SongResult) => {
|
||||
const updatedPlayMusic = await getSongDetail(playMusic);
|
||||
state.playMusic = updatedPlayMusic;
|
||||
state.playMusicUrl = updatedPlayMusic.playMusicUrl;
|
||||
state.play = true;
|
||||
// 设置网页标题
|
||||
document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
|
||||
loadLrcAsync(state, updatedPlayMusic.id);
|
||||
musicHistory.addMusic(state.playMusic);
|
||||
const playListIndex = state.playList.findIndex((item: SongResult) => item.id === playMusic.id);
|
||||
state.playListIndex = playListIndex;
|
||||
// 请求后续五首歌曲的详情
|
||||
fetchSongs(state, playListIndex + 1, playListIndex + 6);
|
||||
};
|
||||
|
||||
// 用于预加载下一首歌曲的 MP3 数据
|
||||
const preloadNextSong = (nextSongUrl: string) => {
|
||||
const sound = new Howl({
|
||||
src: [nextSongUrl],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false,
|
||||
});
|
||||
return sound;
|
||||
};
|
||||
|
||||
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
|
||||
const songs = state.playList.slice(Math.max(0, startIndex), Math.min(endIndex, state.playList.length));
|
||||
|
||||
const detailedSongs = await Promise.all(
|
||||
songs.map(async (song: SongResult) => {
|
||||
// 如果歌曲详情已经存在,就不重复请求
|
||||
if (!song.playMusicUrl) {
|
||||
return await getSongDetail(song);
|
||||
}
|
||||
return song;
|
||||
}),
|
||||
);
|
||||
// 加载下一首的歌词
|
||||
const nextSong = detailedSongs[0];
|
||||
if (!(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
|
||||
nextSong.lyric = await loadLrc(nextSong.id);
|
||||
}
|
||||
|
||||
// 更新播放列表中的歌曲详情
|
||||
detailedSongs.forEach((song, index) => {
|
||||
state.playList[startIndex + index] = song;
|
||||
});
|
||||
preloadNextSong(nextSong.playMusicUrl);
|
||||
};
|
||||
|
||||
const nextPlay = async (state: any) => {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true;
|
||||
return;
|
||||
}
|
||||
const playListIndex = (state.playListIndex + 1) % state.playList.length;
|
||||
await handlePlayMusic(state, state.playList[playListIndex]);
|
||||
};
|
||||
|
||||
const prevPlay = async (state: any) => {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true;
|
||||
return;
|
||||
}
|
||||
const playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
|
||||
await handlePlayMusic(state, state.playList[playListIndex]);
|
||||
await fetchSongs(state, playListIndex - 5, playListIndex);
|
||||
};
|
||||
|
||||
const parseTime = (timeString: string): number => {
|
||||
const [minutes, seconds] = timeString.split(':');
|
||||
return Number(minutes) * 60 + Number(seconds);
|
||||
};
|
||||
|
||||
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
|
||||
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
|
||||
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
|
||||
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
|
||||
const time = parseTime(timeText);
|
||||
const text = lyricLine.replace(LRC_REGEX, '').trim();
|
||||
return { time, text };
|
||||
};
|
||||
|
||||
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
|
||||
const lines = lyricsString.split('\n');
|
||||
const lyrics: ILyricText[] = [];
|
||||
const times: number[] = [];
|
||||
lines.forEach((line) => {
|
||||
const { time, text } = parseLyricLine(line);
|
||||
times.push(time);
|
||||
lyrics.push({ text, trText: '' });
|
||||
});
|
||||
return { lyrics, times };
|
||||
};
|
||||
|
||||
const loadLrc = async (playMusicId: number): Promise<ILyric> => {
|
||||
try {
|
||||
const { data } = await getMusicLrc(playMusicId);
|
||||
const { lyrics, times } = parseLyrics(data.lrc.lyric);
|
||||
const tlyric: Record<string, string> = {};
|
||||
|
||||
if (data.tlyric.lyric) {
|
||||
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
|
||||
tLyrics.forEach((lyric, index) => {
|
||||
tlyric[tTimes[index].toString()] = lyric.text;
|
||||
});
|
||||
}
|
||||
|
||||
lyrics.forEach((item, index) => {
|
||||
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
|
||||
});
|
||||
return {
|
||||
lrcTimeArray: times,
|
||||
lrcArray: lyrics,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading lyrics:', err);
|
||||
return {
|
||||
lrcTimeArray: [],
|
||||
lrcArray: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 异步加载歌词的方法
|
||||
const loadLrcAsync = async (state: any, playMusicId: number) => {
|
||||
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
|
||||
return;
|
||||
}
|
||||
const lyrics = await loadLrc(playMusicId);
|
||||
state.playMusic.lyric = lyrics;
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
audioService.getCurrentSound()?.play();
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
audioService.getCurrentSound()?.pause();
|
||||
};
|
||||
|
||||
return {
|
||||
handlePlayMusic,
|
||||
nextPlay,
|
||||
prevPlay,
|
||||
play,
|
||||
pause,
|
||||
};
|
||||
};
|
||||
@@ -8,4 +8,12 @@
|
||||
.n-image img {
|
||||
background-color: #111111;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.n-slider-handle-indicator--top {
|
||||
@apply bg-transparent text-[#ffffffdd] text-2xl px-2 py-1 shadow-none mb-0 !important;
|
||||
}
|
||||
|
||||
.text-el {
|
||||
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
|
||||
}
|
||||
|
||||
@@ -1,144 +1,104 @@
|
||||
<template>
|
||||
<div class="layout-page">
|
||||
<div class="layout-main">
|
||||
<title-bar v-if="isElectron" />
|
||||
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
|
||||
<!-- 侧边菜单栏 -->
|
||||
<app-menu class="menu" :menus="menus" />
|
||||
<div class="main">
|
||||
<!-- 搜索栏 -->
|
||||
<search-bar />
|
||||
<!-- 主页面路由 -->
|
||||
<div class="main-content bg-black pb-" :native-scrollbar="false" :class="isPlay ? 'pb-20' : ''">
|
||||
<n-message-provider>
|
||||
<router-view class="main-page" v-slot="{ Component }" :class="route.meta.noScroll? 'pr-3' : ''">
|
||||
<template v-if="route.meta.noScroll">
|
||||
<keep-alive v-if="!route.meta.noKeepAlive">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
<component v-else :is="Component"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-scrollbar>
|
||||
<keep-alive v-if="!route.meta.noKeepAlive">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
<component v-else :is="Component"/>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
</router-view>
|
||||
</n-message-provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部音乐播放 -->
|
||||
<play-bar v-if="isPlay" />
|
||||
<div class="layout-page">
|
||||
<div id="layout-main" 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">
|
||||
<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>
|
||||
</div>
|
||||
<play-bottom height="5rem" />
|
||||
<app-menu v-if="isMobile" class="menu" :menus="menus" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部音乐播放 -->
|
||||
<play-bar v-if="isPlay" />
|
||||
</div>
|
||||
<install-app-modal></install-app-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import InstallAppModal from '@/components/common/InstallAppModal.vue';
|
||||
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.menus;
|
||||
const play = computed(() => store.state.play as boolean)
|
||||
const isPlay = computed(() => store.state.isPlay as boolean);
|
||||
const { menus } = store.state;
|
||||
const route = useRoute();
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const audio = {
|
||||
value: document.querySelector('#MusicAudio') as HTMLAudioElement
|
||||
}
|
||||
|
||||
const windowData = window as any
|
||||
const isElectron = computed(() => {
|
||||
return !!windowData.electronAPI
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 监听音乐是否播放
|
||||
watch(
|
||||
() => play.value,
|
||||
value => {
|
||||
if (value && audio.value) {
|
||||
audioPlay()
|
||||
} else {
|
||||
audioPause()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
document.onkeyup = (e) => {
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
playMusicEvent()
|
||||
}
|
||||
}
|
||||
// 按下键盘按钮监听
|
||||
document.onkeydown = (e) => {
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
const backgroundColor = ref('#000');
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@apply flex justify-center items-center overflow-hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@apply flex justify-center items-center overflow-hidden bg-black;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
@apply bg-black text-white shadow-xl flex flex-col relative;
|
||||
@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%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
&-page{
|
||||
@apply flex flex-1 overflow-hidden;
|
||||
}
|
||||
.menu {
|
||||
width: 90px;
|
||||
}
|
||||
.main {
|
||||
@apply flex-1 box-border flex flex-col;
|
||||
height: 100%;
|
||||
&-content {
|
||||
@apply box-border flex-1 overflow-hidden;
|
||||
}
|
||||
}
|
||||
:deep(.n-scrollbar-content){
|
||||
@apply pr-3;
|
||||
&-content {
|
||||
@apply box-border flex-1 overflow-hidden;
|
||||
}
|
||||
}
|
||||
// :deep(.n-scrollbar-content) {
|
||||
// @apply pr-3;
|
||||
// }
|
||||
}
|
||||
</style>
|
||||
|
||||
.mobile {
|
||||
.layout-main {
|
||||
&-page {
|
||||
@apply pt-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,13 +8,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-menu-list">
|
||||
<div class="app-menu-item" v-for="(item,index) in menus">
|
||||
<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>
|
||||
<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>
|
||||
@@ -24,44 +20,47 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
isText: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '26px'
|
||||
default: '26px',
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#aaa'
|
||||
default: '#aaa',
|
||||
},
|
||||
selectColor: {
|
||||
type: String,
|
||||
default: '#10B981'
|
||||
default: '#10B981',
|
||||
},
|
||||
menus: {
|
||||
type: Array as any,
|
||||
default: []
|
||||
}
|
||||
})
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const path = ref(route.path);
|
||||
watch(() => route.path, async newParams => {
|
||||
path.value = newParams
|
||||
})
|
||||
watch(
|
||||
() => route.path,
|
||||
async (newParams) => {
|
||||
path.value = newParams;
|
||||
},
|
||||
);
|
||||
|
||||
const iconStyle = (index: any) => {
|
||||
let style = {
|
||||
const iconStyle = (index: number) => {
|
||||
const style = {
|
||||
fontSize: props.size,
|
||||
color: path.value === props.menus[index].path ? props.selectColor : props.color
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
color: path.value === props.menus[index].path ? props.selectColor : props.color,
|
||||
};
|
||||
return style;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -83,4 +82,25 @@ const iconStyle = (index: any) => {
|
||||
transform: scale(1.05);
|
||||
transition: 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
.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>
|
||||
|
||||
@@ -1,115 +1,199 @@
|
||||
<template>
|
||||
<n-drawer
|
||||
:show="musicFull"
|
||||
height="100vh"
|
||||
height="100%"
|
||||
placement="bottom"
|
||||
:drawer-style="{ backgroundColor: 'transparent' }"
|
||||
:style="{ background: currentBackground || background }"
|
||||
:to="`#layout-main`"
|
||||
>
|
||||
<div id="drawer-target">
|
||||
<div class="drawer-back" :class="{'paused': !isPlaying}" :style="{backgroundImage:`url(${getImgUrl(playMusic?.picUrl, '300y300')})`}"></div>
|
||||
<div class="drawer-back"></div>
|
||||
<div class="music-img">
|
||||
<n-image
|
||||
ref="PicImgRef"
|
||||
:src="getImgUrl(playMusic?.picUrl, '300y300')"
|
||||
class="img"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<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.ar || playMusic.song.artists" :key="index">
|
||||
{{ item.name }}{{ index < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-content">
|
||||
<div class="music-content-name">{{ playMusic.song.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>
|
||||
<n-layout
|
||||
class="music-lrc"
|
||||
style="height: 55vh"
|
||||
ref="lrcSider"
|
||||
class="music-lrc"
|
||||
style="height: 60vh"
|
||||
:native-scrollbar="false"
|
||||
@mouseover="mouseOverLayout"
|
||||
@mouseleave="mouseLeaveLayout"
|
||||
>
|
||||
<template v-for="(item, index) in lrcArray" :key="index">
|
||||
<div ref="lrcContainer">
|
||||
<div
|
||||
v-for="(item, index) in lrcArray"
|
||||
:id="`music-lrc-text-${index}`"
|
||||
:key="index"
|
||||
class="music-lrc-text"
|
||||
:class="{ 'now-text': isCurrentLrc(index, nowTime) }"
|
||||
@click="setAudioTime(index, audio)"
|
||||
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
||||
@click="setAudioTime(index)"
|
||||
>
|
||||
{{ item.text }}
|
||||
<span :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||
<div class="music-lrc-text-tr">{{ item.trText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-layout>
|
||||
<!-- 时间矫正 -->
|
||||
<div class="music-content-time">
|
||||
<!-- <div class="music-content-time">
|
||||
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
|
||||
<n-button @click="addCorrectionTime(0.2)">+</n-button>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SongResult } from '@/type/music'
|
||||
import { getImgUrl } from '@/utils'
|
||||
import { useStore } from 'vuex'
|
||||
import {
|
||||
lrcArray,
|
||||
newLrcIndex,
|
||||
isCurrentLrc,
|
||||
addCorrectionTime,
|
||||
reduceCorrectionTime,
|
||||
setAudioTime,
|
||||
nowTime,
|
||||
} from '@/hooks/MusicHook'
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
const store = useStore()
|
||||
import { lrcArray, nowIndex, playMusic, setAudioTime, useLyricProgress } from '@/hooks/MusicHook';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
|
||||
|
||||
// 定义 refs
|
||||
const lrcSider = ref<any>(null);
|
||||
const isMouse = ref(false);
|
||||
const lrcContainer = ref<HTMLElement | null>(null);
|
||||
const currentBackground = ref('');
|
||||
const animationFrame = ref<number | null>(null);
|
||||
const isDark = ref(false);
|
||||
|
||||
// 初始化 textColors
|
||||
const textColors = ref(getTextColors());
|
||||
|
||||
const props = defineProps({
|
||||
musicFull: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
audio: {
|
||||
type: HTMLAudioElement,
|
||||
default: null,
|
||||
background: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:musicFull'])
|
||||
|
||||
// 播放的音乐信息
|
||||
const playMusic = computed(() => store.state.playMusic as SongResult)
|
||||
const isPlaying = computed(() => store.state.play as boolean)
|
||||
// 获取歌词滚动dom
|
||||
const lrcSider = ref<any>(null)
|
||||
const isMouse = ref(false)
|
||||
// 歌词滚动方法
|
||||
const lrcScroll = () => {
|
||||
if (props.musicFull && !isMouse.value) {
|
||||
let top = newLrcIndex.value * 50 - 225
|
||||
lrcSider.value.scrollTo({ top: top, behavior: 'smooth' })
|
||||
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
|
||||
}
|
||||
isMouse.value = true;
|
||||
};
|
||||
const mouseLeaveLayout = () => {
|
||||
setTimeout(() => {
|
||||
isMouse.value = false
|
||||
}, 3000)
|
||||
}
|
||||
isMouse.value = false;
|
||||
lrcScroll();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
watch(nowIndex, () => {
|
||||
debouncedLrcScroll();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.musicFull,
|
||||
() => {
|
||||
if (props.musicFull) {
|
||||
nextTick(() => {
|
||||
lrcScroll('instant');
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听背景变化
|
||||
watch(
|
||||
() => props.background,
|
||||
(newBg) => {
|
||||
if (!newBg) {
|
||||
textColors.value = getTextColors();
|
||||
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
|
||||
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentBackground.value) {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
}
|
||||
animationFrame.value = animateGradient(currentBackground.value, newBg, (gradient) => {
|
||||
currentBackground.value = gradient;
|
||||
});
|
||||
} else {
|
||||
currentBackground.value = newBg;
|
||||
}
|
||||
|
||||
textColors.value = getTextColors(newBg);
|
||||
isDark.value = textColors.value.active === '#000000';
|
||||
|
||||
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(isDark.value));
|
||||
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 修改 useLyricProgress 的使用方式
|
||||
const { getLrcStyle: originalLrcStyle } = useLyricProgress();
|
||||
|
||||
// 修改 getLrcStyle 函数
|
||||
const getLrcStyle = (index: number) => {
|
||||
const colors = textColors.value || getTextColors;
|
||||
const originalStyle = originalLrcStyle(index);
|
||||
|
||||
if (index === nowIndex.value) {
|
||||
// 当前播放的歌词,使用渐变效果
|
||||
return {
|
||||
...originalStyle,
|
||||
backgroundImage: originalStyle.backgroundImage
|
||||
?.replace(/#ffffff/g, colors.active)
|
||||
.replace(/#ffffff8a/g, `${colors.primary}`),
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
// 非当前播放的歌词,使用普通颜色
|
||||
return {
|
||||
color: colors.primary,
|
||||
};
|
||||
};
|
||||
|
||||
// 组件卸载时清理动画
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame.value) {
|
||||
cancelAnimationFrame(animationFrame.value);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
lrcScroll,
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@keyframes round {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@@ -118,15 +202,13 @@ defineExpose({
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.drawer-back{
|
||||
@apply absolute bg-cover bg-center opacity-70;
|
||||
filter: blur(80px) brightness(80%);
|
||||
.drawer-back {
|
||||
@apply absolute bg-cover bg-center;
|
||||
z-index: -1;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
animation: round 20s linear infinite;
|
||||
}
|
||||
|
||||
.drawer-back.paused {
|
||||
@@ -134,56 +216,90 @@ defineExpose({
|
||||
}
|
||||
|
||||
#drawer-target {
|
||||
@apply top-0 left-0 absolute w-full h-full overflow-hidden rounded px-24 pt-24 pb-48 flex items-center;
|
||||
backdrop-filter: blur(20px);
|
||||
background-color: rgba(0, 0, 0, 0.747);
|
||||
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
|
||||
animation-duration: 300ms;
|
||||
|
||||
.music-img {
|
||||
@apply flex-1 flex justify-center mr-24;
|
||||
|
||||
@apply flex-1 flex justify-center mr-16 flex-col;
|
||||
max-width: 360px;
|
||||
max-height: 360px;
|
||||
.img {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
@apply rounded-xl;
|
||||
@apply rounded-xl w-full h-full shadow-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
.music-content {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
@apply flex flex-col justify-center items-center relative;
|
||||
|
||||
&-name {
|
||||
@apply font-bold text-3xl py-2;
|
||||
@apply font-bold text-xl pb-1 pt-4;
|
||||
}
|
||||
|
||||
&-singer {
|
||||
@apply text-base py-2;
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
|
||||
.music-content-time{
|
||||
.music-content-time {
|
||||
display: none;
|
||||
@apply flex justify-center items-center;
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
|
||||
.music-lrc {
|
||||
background-color: inherit;
|
||||
width: 500px;
|
||||
height: 550px;
|
||||
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
|
||||
&-text {
|
||||
@apply text-white text-lg flex justify-center items-center cursor-pointer;
|
||||
height: 50px;
|
||||
transition: all 0.2s ease-out;
|
||||
@apply text-2xl cursor-pointer font-bold px-2 py-4;
|
||||
transition: all 0.3s ease;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
@apply font-bold text-xl text-red-500;
|
||||
span {
|
||||
background-clip: text !important;
|
||||
-webkit-background-clip: text !important;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
&-tr {
|
||||
@apply font-normal;
|
||||
opacity: 0.7;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.now-text {
|
||||
@apply font-bold text-xl text-red-500;
|
||||
.hover-text {
|
||||
&:hover {
|
||||
@apply font-bold opacity-100 rounded-xl;
|
||||
background-color: var(--hover-bg-color);
|
||||
|
||||
span {
|
||||
color: var(--text-color-active) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
#drawer-target {
|
||||
@apply flex-col p-4 pt-8 justify-start;
|
||||
.music-img {
|
||||
display: none;
|
||||
}
|
||||
.music-lrc {
|
||||
height: calc(100vh - 260px) !important;
|
||||
width: 100vw;
|
||||
span {
|
||||
padding-right: 0px !important;
|
||||
}
|
||||
}
|
||||
.music-lrc-text {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-drawer {
|
||||
transition: none; // 移除之前的过渡效果,现在使用 JS 动画
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
<template>
|
||||
<!-- 展开全屏 -->
|
||||
<music-full ref="MusicFullRef" v-model:musicFull="musicFull" :audio="(audio.value as HTMLAudioElement)" />
|
||||
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
|
||||
<!-- 底部播放栏 -->
|
||||
<div class="music-play-bar" :class="setAnimationClass('animate__bounceInUp')">
|
||||
<n-image
|
||||
:src="getImgUrl(playMusic?.picUrl, '300y300')"
|
||||
class="play-bar-img"
|
||||
lazy
|
||||
preview-disabled
|
||||
@click="setMusicFull"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="music-play-bar"
|
||||
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
|
||||
>
|
||||
<div class="music-time custom-slider">
|
||||
<n-slider v-model:value="timeSlider" :step="1" :max="allTime" :min="0" :format-tooltip="formatTooltip"></n-slider>
|
||||
</div>
|
||||
<div class="play-bar-img-wrapper" @click="setMusicFull">
|
||||
<n-image :src="getImgUrl(playMusic?.picUrl, '300y300')" class="play-bar-img" lazy preview-disabled />
|
||||
<div class="hover-arrow">
|
||||
<div class="hover-content">
|
||||
<!-- <i class="ri-arrow-up-s-line text-3xl" :class="{ 'ri-arrow-down-s-line': musicFullVisible }"></i> -->
|
||||
<i class="text-3xl" :class="musicFullVisible ? 'ri-arrow-down-s-line' : 'ri-arrow-up-s-line'"></i>
|
||||
<span class="hover-text">{{ musicFullVisible ? '收起' : '展开' }}歌词</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-content">
|
||||
<div class="music-content-title">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
@@ -18,63 +28,65 @@
|
||||
</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>
|
||||
<span v-for="(artists, artistsindex) in playMusic.ar || playMusic.song.artists" :key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{ artistsindex < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-buttons">
|
||||
<div @click="handlePrev">
|
||||
<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 @click="handleEnded">
|
||||
<div class="music-buttons-next" @click="handleNext">
|
||||
<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">
|
||||
<div class="audio-volume custom-slider">
|
||||
<div class="volume-icon" @click="mute">
|
||||
<i class="iconfont" :class="getVolumeIcon"></i>
|
||||
</div>
|
||||
<div class="volume-slider">
|
||||
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider>
|
||||
</div>
|
||||
</div>
|
||||
<n-tooltip trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-likefill"></i>
|
||||
<i class="iconfont" :class="playModeIcon" @click="togglePlayMode"></i>
|
||||
</template>
|
||||
{{ playModeText }}
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-likefill" :class="{ 'like-active': isFavorite }" @click="toggleFavorite"></i>
|
||||
</template>
|
||||
喜欢
|
||||
</n-tooltip> -->
|
||||
<!-- <n-tooltip trigger="hover" :z-index="9999999">
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-Play" @click="parsingMusic"></i>
|
||||
</template>
|
||||
解析播放
|
||||
</n-tooltip> -->
|
||||
<!-- <n-tooltip trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-full" @click="setMusicFull"></i>
|
||||
<i
|
||||
class="iconfont ri-netease-cloud-music-line"
|
||||
:class="{ 'text-green-500': isLyricWindowOpen }"
|
||||
@click="openLyricWindow"
|
||||
></i>
|
||||
</template>
|
||||
歌词
|
||||
</n-tooltip> -->
|
||||
<n-popover trigger="click" :z-index="99999999" content-class="music-play" raw :show-arrow="false" :delay="200">
|
||||
</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>
|
||||
@@ -85,166 +97,185 @@
|
||||
</template>
|
||||
<div class="music-play-list">
|
||||
<div class="music-play-list-back"></div>
|
||||
<n-scrollbar>
|
||||
<div class="music-play-list-content">
|
||||
<song-item v-for="(item, index) in playList" :key="item.id" :item="item" mini></song-item>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
<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 type { SongResult } from '@/type/music'
|
||||
import { secondToMinute, getImgUrl } from '@/utils'
|
||||
import { useStore } from 'vuex'
|
||||
import { setAnimationClass } from '@/utils'
|
||||
import {
|
||||
loadLrc,
|
||||
nowTime,
|
||||
allTime
|
||||
} from '@/hooks/MusicHook'
|
||||
import MusicFull from './MusicFull.vue'
|
||||
import SongItem from '@/components/common/SongItem.vue'
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const store = useStore()
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { allTime, isElectron, isLyricWindowOpen, nowTime, openLyric, sound } 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 playMusic = computed(() => store.state.playMusic as SongResult);
|
||||
// 是否播放
|
||||
const play = computed(() => store.state.play as boolean)
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
const playList = computed(() => store.state.playList as SongResult[]);
|
||||
|
||||
const playList = computed(() => store.state.playList as SongResult[])
|
||||
|
||||
const audio = {
|
||||
value: document.querySelector('#MusicAudio') as HTMLAudioElement
|
||||
}
|
||||
const background = ref('#000');
|
||||
|
||||
watch(
|
||||
() => store.state.playMusicUrl,
|
||||
() => {
|
||||
loadLrc(playMusic.value.id)
|
||||
() => store.state.playMusic,
|
||||
async () => {
|
||||
background.value = playMusic.value.backgroundColor as string;
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
const audioPlay = () => {
|
||||
if (audio.value) {
|
||||
audio.value.play()
|
||||
}
|
||||
}
|
||||
// 使用 useThrottleFn 创建节流版本的 seek 函数
|
||||
const throttledSeek = useThrottleFn((value: number) => {
|
||||
if (!sound.value) return;
|
||||
sound.value.seek(value);
|
||||
nowTime.value = value;
|
||||
}, 50); // 50ms 的节流延迟
|
||||
|
||||
const audioPause = () => {
|
||||
if (audio.value) {
|
||||
audio.value.pause()
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性 获取当前播放时间的进度
|
||||
// 修改 timeSlider 计算属性
|
||||
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)
|
||||
},
|
||||
})
|
||||
get: () => nowTime.value,
|
||||
set: throttledSeek,
|
||||
});
|
||||
|
||||
const formatTooltip = (value: number) => {
|
||||
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
|
||||
};
|
||||
|
||||
// 音量条
|
||||
const audioVolume = ref(1)
|
||||
const audioVolume = ref(localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1);
|
||||
const getVolumeIcon = computed(() => {
|
||||
// 0 静音 ri-volume-mute-line 0.5 ri-volume-down-line 1 ri-volume-up-line
|
||||
if (audioVolume.value === 0) {
|
||||
return 'ri-volume-mute-line';
|
||||
}
|
||||
if (audioVolume.value <= 0.5) {
|
||||
return 'ri-volume-down-line';
|
||||
}
|
||||
return 'ri-volume-up-line';
|
||||
});
|
||||
|
||||
const volumeSlider = computed({
|
||||
get: () => audioVolume.value * 100,
|
||||
set: (value) => {
|
||||
if(!audio.value) return
|
||||
audio.value.volume = value / 100
|
||||
if (!sound.value) return;
|
||||
localStorage.setItem('volume', (value / 100).toString());
|
||||
sound.value.volume(value / 100);
|
||||
audioVolume.value = 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)
|
||||
})
|
||||
// 静音
|
||||
const mute = () => {
|
||||
if (volumeSlider.value === 0) {
|
||||
volumeSlider.value = 30;
|
||||
} else {
|
||||
volumeSlider.value = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onAudio()
|
||||
// 播放模式
|
||||
const playMode = computed(() => store.state.playMode);
|
||||
const playModeIcon = computed(() => {
|
||||
return playMode.value === 0 ? 'ri-repeat-2-line' : 'ri-repeat-one-line';
|
||||
});
|
||||
const playModeText = computed(() => {
|
||||
return playMode.value === 0 ? '列表循环' : '单曲循环';
|
||||
});
|
||||
|
||||
function handleEnded() {
|
||||
store.commit('nextPlay')
|
||||
// 切换播放模式
|
||||
const togglePlayMode = () => {
|
||||
store.commit('togglePlayMode');
|
||||
};
|
||||
|
||||
function handleNext() {
|
||||
store.commit('nextPlay');
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
store.commit('prevPlay')
|
||||
store.commit('prevPlay');
|
||||
}
|
||||
|
||||
const MusicFullRef = ref<any>(null)
|
||||
|
||||
function handleGetAudioTime(this: any) {
|
||||
// 监听音频播放的实时时间事件
|
||||
const audio = this as HTMLAudioElement
|
||||
// 获取当前播放时间
|
||||
nowTime.value = Math.floor(audio.currentTime)
|
||||
// 获取总时间
|
||||
allTime.value = audio.duration
|
||||
// 获取音量
|
||||
audioVolume.value = audio.volume
|
||||
MusicFullRef.value?.lrcScroll()
|
||||
}
|
||||
const MusicFullRef = ref<any>(null);
|
||||
|
||||
// 播放暂停按钮事件
|
||||
const playMusicEvent = async () => {
|
||||
if (play.value) {
|
||||
store.commit('setPlayMusic', false)
|
||||
if (sound.value) {
|
||||
sound.value.pause();
|
||||
}
|
||||
store.commit('setPlayMusic', false);
|
||||
} else {
|
||||
store.commit('setPlayMusic', true)
|
||||
if (sound.value) {
|
||||
sound.value.play();
|
||||
}
|
||||
store.commit('setPlayMusic', true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const musicFull = ref(false)
|
||||
const musicFullVisible = ref(false);
|
||||
|
||||
// 设置musicFull
|
||||
const setMusicFull = () => {
|
||||
musicFull.value = !musicFull.value
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
return store.state.favoriteList.includes(playMusic.value.id);
|
||||
});
|
||||
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (isFavorite.value) {
|
||||
store.commit('removeFromFavorite', playMusic.value.id);
|
||||
} else {
|
||||
store.commit('addToFavorite', playMusic.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openLyricWindow = () => {
|
||||
openLyric();
|
||||
};
|
||||
</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;
|
||||
@apply h-20 w-full absolute bottom-0 left-0 flex items-center box-border px-6 py-2 pt-3;
|
||||
z-index: 9999;
|
||||
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
|
||||
background-color: rgba(0, 0, 0, 0.747); .music-content {
|
||||
width: 140px;
|
||||
background-color: #212121;
|
||||
animation-duration: 0.5s !important;
|
||||
.music-content {
|
||||
width: 160px;
|
||||
@apply ml-4;
|
||||
|
||||
&-title {
|
||||
@@ -252,25 +283,29 @@ const setMusicFull = () => {
|
||||
}
|
||||
|
||||
&-name {
|
||||
@apply text-xs mt-1;
|
||||
@apply text-gray-400;
|
||||
@apply text-xs mt-1 text-gray-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@apply mx-6 flex-1 flex justify-center;
|
||||
|
||||
.iconfont {
|
||||
@apply text-2xl hover:text-green-500 transition;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-xl hover:text-white;
|
||||
@apply text-3xl hover:text-white;
|
||||
}
|
||||
|
||||
@apply flex items-center;
|
||||
@@ -280,25 +315,28 @@ const setMusicFull = () => {
|
||||
}
|
||||
|
||||
&-play {
|
||||
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition;
|
||||
background: #383838;
|
||||
}
|
||||
}
|
||||
|
||||
.music-time {
|
||||
@apply flex flex-1 items-center;
|
||||
|
||||
.time {
|
||||
@apply mx-4 mt-1;
|
||||
background-color: #ffffff20;
|
||||
@apply flex justify-center items-center w-20 h-12 rounded-full mx-4 hover:bg-[#ffffff40] transition;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-volume {
|
||||
width: 140px;
|
||||
@apply flex items-center mx-4;
|
||||
@apply flex items-center relative;
|
||||
&:hover {
|
||||
.volume-slider {
|
||||
@apply opacity-100 visible;
|
||||
}
|
||||
}
|
||||
.volume-icon {
|
||||
@apply cursor-pointer;
|
||||
|
||||
.iconfont {
|
||||
@apply text-2xl hover:text-green-500 transition cursor-pointer mr-4;
|
||||
.iconfont {
|
||||
@apply text-2xl hover:text-green-500 transition;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
@apply absolute opacity-0 invisible transition-all duration-300 bottom-[30px] left-1/2 -translate-x-1/2 h-[180px] px-2 py-4 bg-gray-800 bg-opacity-80 rounded-xl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,18 +348,149 @@ const setMusicFull = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.music-play{
|
||||
|
||||
&-list{
|
||||
.music-play {
|
||||
&-list {
|
||||
height: 50vh;
|
||||
@apply relative rounded-3xl overflow-hidden;
|
||||
&-back{
|
||||
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{
|
||||
padding: 10px;
|
||||
&-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;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加自定义 slider 样式
|
||||
.custom-slider {
|
||||
:deep(.n-slider) {
|
||||
--n-rail-height: 4px;
|
||||
--n-rail-color: rgba(255, 255, 255, 0.2);
|
||||
--n-fill-color: var(--primary-color);
|
||||
--n-handle-size: 12px;
|
||||
--n-handle-color: var(--primary-color);
|
||||
|
||||
&.n-slider--vertical {
|
||||
height: 100%;
|
||||
|
||||
.n-slider-rail {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.n-slider-rail {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.n-slider-handle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.n-slider-rail {
|
||||
@apply overflow-hidden transition-all duration-200;
|
||||
}
|
||||
|
||||
.n-slider-handle {
|
||||
@apply transition-all duration-200;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .n-slider-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #18a058;
|
||||
}
|
||||
|
||||
.play-bar-img-wrapper {
|
||||
@apply relative cursor-pointer w-14 h-14;
|
||||
|
||||
.hover-arrow {
|
||||
@apply absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 rounded-2xl;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
.hover-content {
|
||||
@apply flex flex-col items-center justify-center;
|
||||
|
||||
i {
|
||||
@apply text-white mb-0.5;
|
||||
}
|
||||
|
||||
.hover-text {
|
||||
@apply text-white text-xs scale-90;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.hover-arrow {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
@apply text-sm py-1 px-2;
|
||||
}
|
||||
|
||||
.play-bar-img {
|
||||
@apply w-14 h-14 rounded-2xl;
|
||||
}
|
||||
|
||||
.like-active {
|
||||
@apply text-red-600;
|
||||
}
|
||||
|
||||
.icon-loop,
|
||||
.icon-single-loop {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.music-time .n-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,168 +1,266 @@
|
||||
<template>
|
||||
<div class="search-box flex">
|
||||
<div class="search-box-input flex-1">
|
||||
<n-input
|
||||
size="medium"
|
||||
round
|
||||
v-model:value="searchValue"
|
||||
: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 === searchType)?.label }}</div>
|
||||
<n-dropdown trigger="hover" @select="selectSearchType" :options="searchTypeOptions">
|
||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<div class="user-box">
|
||||
<n-dropdown trigger="hover" @select="selectItem" :options="userSetOptions">
|
||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||
</n-dropdown>
|
||||
<n-avatar
|
||||
class="ml-2 cursor-pointer"
|
||||
circle
|
||||
size="medium"
|
||||
:src="getImgUrl(store.state.user.avatarUrl)"
|
||||
v-if="store.state.user"
|
||||
/>
|
||||
<div class="mx-2 rounded-full cursor-pointer text-sm" v-else @click="toLogin">登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getSearchKeyword } from '@/api/home';
|
||||
import { getUserDetail, logout } from '@/api/login';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import request from '@/utils/request_mt'
|
||||
import { getImgUrl } from '@/utils';
|
||||
import {USER_SET_OPTIONS, SEARCH_TYPES} from '@/const/bar-const'
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
watchEffect(() => {
|
||||
loadPage()
|
||||
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 searchType = ref(1)
|
||||
const search = () => {
|
||||
let value = searchValue.value
|
||||
if (value == "") {
|
||||
searchValue.value = hotSearchValue.value
|
||||
} else {
|
||||
router.push({
|
||||
path: "/search",
|
||||
query: {
|
||||
keyword: value,
|
||||
type: searchType.value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectSearchType = (key: any) => {
|
||||
searchType.value = key
|
||||
}
|
||||
|
||||
|
||||
const searchTypeOptions = ref(SEARCH_TYPES)
|
||||
|
||||
const selectItem = async (key: any) => {
|
||||
// switch 判断
|
||||
switch (key) {
|
||||
case 'card':
|
||||
await request.get('/?do=sign')
|
||||
.then(res => {
|
||||
console.log(res)
|
||||
})
|
||||
break;
|
||||
case 'card_music':
|
||||
await request.get('/?do=daka')
|
||||
.then(res => {
|
||||
console.log(res)
|
||||
})
|
||||
break;
|
||||
case 'listen':
|
||||
await request.get('/?do=listen&id=1885175990&time=300')
|
||||
.then(res => {
|
||||
console.log(res)
|
||||
})
|
||||
break;
|
||||
case 'logout':
|
||||
logout().then(() => {
|
||||
store.state.user = null
|
||||
localStorage.clear()
|
||||
})
|
||||
break;
|
||||
case 'login':
|
||||
router.push("/login")
|
||||
break;
|
||||
case 'set':
|
||||
router.push("/set")
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
<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>
|
||||
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
|
||||
<div class="w-20 px-3 flex justify-between items-center">
|
||||
<div>{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }}</div>
|
||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
|
||||
<template #trigger>
|
||||
<div class="user-box">
|
||||
<n-avatar
|
||||
v-if="store.state.user"
|
||||
class="ml-2 cursor-pointer"
|
||||
circle
|
||||
size="medium"
|
||||
:src="getImgUrl(store.state.user.avatarUrl)"
|
||||
@click="selectItem('user')"
|
||||
/>
|
||||
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="user-popover">
|
||||
<div v-if="store.state.user" class="user-header" @click="selectItem('user')">
|
||||
<n-avatar circle size="small" :src="getImgUrl(store.state.user?.avatarUrl)" />
|
||||
<span class="username">{{ store.state.user?.nickname || 'Theodore' }}</span>
|
||||
</div>
|
||||
<div class="menu-items">
|
||||
<div v-if="!store.state.user" class="menu-item" @click="toLogin">
|
||||
<i class="iconfont ri-login-box-line"></i>
|
||||
<span>去登录</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="selectItem('set')">
|
||||
<i class="iconfont ri-settings-3-line"></i>
|
||||
<span>设置</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="toGithubRelease">
|
||||
<i class="iconfont ri-refresh-line"></i>
|
||||
<span>当前版本</span>
|
||||
<span class="download-btn">{{ config.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-popover>
|
||||
|
||||
<coffee :alipay-q-r="alipay" :wechat-q-r="wechat">
|
||||
<div class="github" @click="toGithub">
|
||||
<i class="ri-github-fill"></i>
|
||||
</div>
|
||||
</coffee>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import config from '@/../package.json';
|
||||
import { getSearchKeyword } from '@/api/home';
|
||||
import { getUserDetail, logout } from '@/api/login';
|
||||
import alipay from '@/assets/alipay.png';
|
||||
import wechat from '@/assets/wechat.png';
|
||||
import Coffee from '@/components/Coffee.vue';
|
||||
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;
|
||||
case 'user':
|
||||
router.push('/user');
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const toGithub = () => {
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank');
|
||||
};
|
||||
|
||||
const toGithubRelease = () => {
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-box {
|
||||
@apply ml-4 flex text-lg justify-center items-center rounded-full border border-gray-600 hover:border-gray-400 transition-colors duration-200;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.search-box {
|
||||
@apply pb-4 pr-4;
|
||||
}
|
||||
.search-box-input {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.search-box {
|
||||
@apply pl-4;
|
||||
}
|
||||
}
|
||||
|
||||
.github {
|
||||
@apply cursor-pointer text-gray-100 hover:text-gray-400 text-xl ml-4 rounded-full border border-gray-600 flex justify-center items-center px-2 h-full;
|
||||
}
|
||||
|
||||
.user-popover {
|
||||
@apply min-w-[280px] p-0 rounded-xl overflow-hidden;
|
||||
background: #2c2c2c;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.user-header {
|
||||
@apply flex items-center gap-2 p-3;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
|
||||
.username {
|
||||
@apply text-sm font-medium text-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
@apply py-1;
|
||||
|
||||
.menu-item {
|
||||
@apply flex items-center px-3 py-2 text-sm cursor-pointer;
|
||||
@apply text-gray-300;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
i {
|
||||
@apply mr-1 text-lg text-gray-400;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
@apply ml-auto text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
@apply ml-auto px-2 py-0.5 text-xs rounded;
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
@apply ml-auto flex items-center gap-2;
|
||||
color: #fff;
|
||||
|
||||
.zoom-btn {
|
||||
@apply px-2 py-0.5 text-sm rounded cursor-pointer;
|
||||
background: #3a3a3a;
|
||||
|
||||
&:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
}
|
||||
|
||||
span:not(.zoom-btn) {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
<button @click="minimize">
|
||||
<i class="iconfont icon-minisize"></i>
|
||||
</button>
|
||||
<!-- <button @click="maximize">
|
||||
<i class="iconfont icon-maxsize"></i>
|
||||
</button> -->
|
||||
<button @click="close">
|
||||
<i class="iconfont icon-close"></i>
|
||||
</button>
|
||||
@@ -16,37 +13,44 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialog } from 'naive-ui'
|
||||
import { useDialog } from 'naive-ui';
|
||||
|
||||
const dialog = useDialog()
|
||||
const windowData = window as any
|
||||
import { isElectron } from '@/hooks/MusicHook';
|
||||
|
||||
const dialog = useDialog();
|
||||
const windowData = window as any;
|
||||
|
||||
const minimize = () => {
|
||||
windowData.electronAPI.minimize()
|
||||
}
|
||||
|
||||
const maximize = () => {
|
||||
windowData.electronAPI.maximize()
|
||||
}
|
||||
if (!isElectron.value) {
|
||||
return;
|
||||
}
|
||||
windowData.electronAPI.minimize();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!isElectron.value) {
|
||||
return;
|
||||
}
|
||||
dialog.warning({
|
||||
title: '提示',
|
||||
content: '确定要退出吗?',
|
||||
positiveText: '最小化',
|
||||
negativeText: '关闭',
|
||||
onPositiveClick: () => {
|
||||
windowData.electronAPI.miniTray()
|
||||
windowData.electronAPI.miniTray();
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
windowData.electronAPI.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
windowData.electronAPI.close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
windowData.electronAPI.dragStart(event)
|
||||
}
|
||||
if (!isElectron.value) {
|
||||
return;
|
||||
}
|
||||
windowData.electronAPI.dragStart(event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AppMenu from "./AppMenu.vue";
|
||||
import PlayBar from "./PlayBar.vue";
|
||||
import SearchBar from "./SearchBar.vue";
|
||||
import AppMenu from './AppMenu.vue';
|
||||
import PlayBar from './PlayBar.vue';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
|
||||
export { AppMenu, PlayBar, SearchBar };
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
lrcList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
default: () => [],
|
||||
},
|
||||
lrcIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
default: 0,
|
||||
},
|
||||
lrcTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
41
src/main.ts
@@ -1,19 +1,22 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
import naive from "naive-ui";
|
||||
import "vfonts/Lato.css";
|
||||
import "vfonts/FiraCode.css";
|
||||
|
||||
// tailwind css
|
||||
import "./index.css";
|
||||
|
||||
import router from "@/router";
|
||||
|
||||
import store from "@/store";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
// app.use(naive);
|
||||
app.mount("#app");
|
||||
import 'vfonts/Lato.css';
|
||||
import 'vfonts/FiraCode.css';
|
||||
// tailwind css
|
||||
import './index.css';
|
||||
import 'remixicon/fonts/remixicon.css';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import router from '@/router';
|
||||
import store from '@/store';
|
||||
|
||||
import App from './App.vue';
|
||||
import directives from './directive';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
Object.keys(directives).forEach((key: string) => {
|
||||
app.directive(key, directives[key as keyof typeof directives]);
|
||||
});
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -5,6 +5,8 @@ const layoutRouter = [
|
||||
meta: {
|
||||
title: '首页',
|
||||
icon: 'icon-Home',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
},
|
||||
@@ -14,8 +16,9 @@ const layoutRouter = [
|
||||
meta: {
|
||||
title: '搜索',
|
||||
noScroll: true,
|
||||
noKeepAlive: true,
|
||||
icon: 'icon-Search',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/search/index.vue'),
|
||||
},
|
||||
@@ -25,6 +28,8 @@ const layoutRouter = [
|
||||
meta: {
|
||||
title: '歌单',
|
||||
icon: 'icon-Paper',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/list/index.vue'),
|
||||
},
|
||||
@@ -34,28 +39,53 @@ const layoutRouter = [
|
||||
meta: {
|
||||
title: 'MV',
|
||||
icon: 'icon-recordfill',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/mv/index.vue'),
|
||||
},
|
||||
// {
|
||||
// path: '/history',
|
||||
// name: 'history',
|
||||
// meta: {
|
||||
// title: '历史',
|
||||
// icon: 'icon-a-TicketStar',
|
||||
// keepAlive: true,
|
||||
// },
|
||||
// component: () => import('@/views/history/index.vue'),
|
||||
// },
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: () => import('@/views/historyAndFavorite/index.vue'),
|
||||
meta: {
|
||||
title: '历史',
|
||||
title: '我的收藏和历史',
|
||||
icon: 'icon-a-TicketStar',
|
||||
keepAlive: true,
|
||||
},
|
||||
component: () => import('@/views/history/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'user',
|
||||
meta: {
|
||||
title: '用户',
|
||||
noKeepAlive: true,
|
||||
icon: 'icon-Profile',
|
||||
keepAlive: true,
|
||||
noScroll: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/user/index.vue'),
|
||||
},
|
||||
]
|
||||
{
|
||||
path: '/set',
|
||||
name: 'set',
|
||||
meta: {
|
||||
title: '设置',
|
||||
icon: 'ri-settings-3-fill',
|
||||
keepAlive: true,
|
||||
noScroll: true,
|
||||
},
|
||||
component: () => import('@/views/set/index.vue'),
|
||||
},
|
||||
];
|
||||
export default layoutRouter;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
import homeRouter from '@/router/home'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
import AppLayout from '@/layout/AppLayout.vue';
|
||||
import homeRouter from '@/router/home';
|
||||
|
||||
const loginRouter = {
|
||||
path: '/login',
|
||||
@@ -11,7 +12,7 @@ const loginRouter = {
|
||||
icon: 'icon-Home',
|
||||
},
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
}
|
||||
};
|
||||
|
||||
const setRouter = {
|
||||
path: '/set',
|
||||
@@ -22,7 +23,7 @@ const setRouter = {
|
||||
icon: 'icon-Home',
|
||||
},
|
||||
component: () => import('@/views/set/index.vue'),
|
||||
}
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -30,9 +31,13 @@ const routes = [
|
||||
component: AppLayout,
|
||||
children: [...homeRouter, loginRouter, setRouter],
|
||||
},
|
||||
]
|
||||
{
|
||||
path: '/lyric',
|
||||
component: () => import('@/views/lyric/index.vue'),
|
||||
},
|
||||
];
|
||||
|
||||
export default createRouter({
|
||||
routes: routes,
|
||||
routes,
|
||||
history: createWebHashHistory(),
|
||||
})
|
||||
});
|
||||
|
||||
55
src/services/audioService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
class AudioService {
|
||||
private currentSound: Howl | null = null;
|
||||
|
||||
play(url: string) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.unload();
|
||||
}
|
||||
this.currentSound = null;
|
||||
this.currentSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: true,
|
||||
volume: localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1,
|
||||
});
|
||||
|
||||
return this.currentSound;
|
||||
}
|
||||
|
||||
getCurrentSound() {
|
||||
return this.currentSound;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.unload();
|
||||
this.currentSound = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听播放
|
||||
onPlay(callback: () => void) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('play', callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听暂停
|
||||
onPause(callback: () => void) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('pause', callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听结束
|
||||
onEnd(callback: () => void) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('end', callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const audioService = new AudioService();
|
||||
11
src/shims-vue.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { createStore } from 'vuex'
|
||||
import { SongResult } from '@/type/music'
|
||||
import { getMusicUrl, getParsingMusicUrl } from '@/api/music'
|
||||
import homeRouter from '@/router/home'
|
||||
import { getMusicProxyUrl } from '@/utils'
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook'
|
||||
import { createStore } from 'vuex';
|
||||
|
||||
import { useMusicListHook } from '@/hooks/MusicListHook';
|
||||
import homeRouter from '@/router/home';
|
||||
import type { SongResult } from '@/type/music';
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings = {
|
||||
isProxy: false,
|
||||
noAnimate: false,
|
||||
animationSpeed: 1,
|
||||
author: 'Alger',
|
||||
authorUrl: 'https://github.com/algerkong',
|
||||
};
|
||||
|
||||
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
}
|
||||
|
||||
interface State {
|
||||
menus: any[]
|
||||
play: boolean
|
||||
isPlay: boolean
|
||||
playMusic: SongResult
|
||||
playMusicUrl: string
|
||||
user: any
|
||||
playList: SongResult[]
|
||||
playListIndex: number
|
||||
setData: any
|
||||
menus: any[];
|
||||
play: boolean;
|
||||
isPlay: boolean;
|
||||
playMusic: SongResult;
|
||||
playMusicUrl: string;
|
||||
user: any;
|
||||
playList: SongResult[];
|
||||
playListIndex: number;
|
||||
setData: any;
|
||||
lyric: any;
|
||||
isMobile: boolean;
|
||||
searchValue: string;
|
||||
searchType: number;
|
||||
favoriteList: number[];
|
||||
playMode: number;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
@@ -23,89 +42,93 @@ const state: State = {
|
||||
isPlay: false,
|
||||
playMusic: {} as SongResult,
|
||||
playMusicUrl: '',
|
||||
user: null,
|
||||
user: getLocalStorageItem('user', null),
|
||||
playList: [],
|
||||
playListIndex: 0,
|
||||
setData: null,
|
||||
}
|
||||
setData: defaultSettings,
|
||||
lyric: {},
|
||||
isMobile: false,
|
||||
searchValue: '',
|
||||
searchType: 1,
|
||||
favoriteList: getLocalStorageItem('favoriteList', []),
|
||||
playMode: getLocalStorageItem('playMode', 0),
|
||||
};
|
||||
|
||||
const windowData = window as any
|
||||
|
||||
const musicHistory = useMusicHistory()
|
||||
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
|
||||
|
||||
const mutations = {
|
||||
setMenus(state: State, menus: any[]) {
|
||||
state.menus = menus
|
||||
state.menus = menus;
|
||||
},
|
||||
async setPlay(state: State, playMusic: SongResult) {
|
||||
state.playMusic = playMusic
|
||||
state.playMusicUrl = await getSongUrl(playMusic.id)
|
||||
state.play = true
|
||||
musicHistory.addMusic(playMusic)
|
||||
await handlePlayMusic(state, playMusic);
|
||||
},
|
||||
setIsPlay(state: State, isPlay: boolean) {
|
||||
state.isPlay = isPlay
|
||||
state.isPlay = isPlay;
|
||||
},
|
||||
setPlayMusic(state: State, play: boolean) {
|
||||
state.play = play
|
||||
state.play = play;
|
||||
},
|
||||
setPlayList(state: State, playList: SongResult[]) {
|
||||
state.playListIndex = playList.findIndex(
|
||||
(item) => item.id === state.playMusic.id
|
||||
)
|
||||
state.playList = playList
|
||||
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
|
||||
state.playList = playList;
|
||||
},
|
||||
async nextPlay(state: State) {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true
|
||||
return
|
||||
}
|
||||
state.playListIndex = (state.playListIndex + 1) % state.playList.length
|
||||
await updatePlayMusic(state)
|
||||
await nextPlay(state);
|
||||
},
|
||||
async prevPlay(state: State) {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true
|
||||
return
|
||||
}
|
||||
state.playListIndex =
|
||||
(state.playListIndex - 1 + state.playList.length) % state.playList.length
|
||||
await updatePlayMusic(state)
|
||||
await prevPlay(state);
|
||||
},
|
||||
async setSetData(state: State, setData: any) {
|
||||
state.setData = setData
|
||||
windowData.electron.ipcRenderer.setStoreValue(
|
||||
'set',
|
||||
JSON.parse(JSON.stringify(setData))
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const getSongUrl = async (id: number) => {
|
||||
const { data } = await getMusicUrl(id)
|
||||
let url = ''
|
||||
try {
|
||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
||||
const res = await getParsingMusicUrl(id)
|
||||
url = res.data.data.url
|
||||
setSetData(state: State, setData: any) {
|
||||
state.setData = setData;
|
||||
const isElectron = (window as any).electronAPI !== undefined;
|
||||
if (isElectron) {
|
||||
(window as any).electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
|
||||
} else {
|
||||
localStorage.setItem('appSettings', JSON.stringify(setData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error', error)
|
||||
}
|
||||
url = url ? url : data.data[0].url
|
||||
return getMusicProxyUrl(url)
|
||||
}
|
||||
},
|
||||
addToFavorite(state: State, songId: number) {
|
||||
if (!state.favoriteList.includes(songId)) {
|
||||
state.favoriteList = [songId, ...state.favoriteList];
|
||||
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
|
||||
}
|
||||
},
|
||||
removeFromFavorite(state: State, songId: number) {
|
||||
state.favoriteList = state.favoriteList.filter((id) => id !== songId);
|
||||
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
|
||||
},
|
||||
togglePlayMode(state: State) {
|
||||
state.playMode = state.playMode === 0 ? 1 : 0;
|
||||
localStorage.setItem('playMode', JSON.stringify(state.playMode));
|
||||
},
|
||||
};
|
||||
|
||||
const updatePlayMusic = async (state: State) => {
|
||||
state.playMusic = state.playList[state.playListIndex]
|
||||
state.playMusicUrl = await getSongUrl(state.playMusic.id)
|
||||
state.play = true
|
||||
musicHistory.addMusic(state.playMusic)
|
||||
}
|
||||
const actions = {
|
||||
initializeSettings({ commit }: { commit: any }) {
|
||||
const isElectron = (window as any).electronAPI !== undefined;
|
||||
|
||||
if (isElectron) {
|
||||
const setData = (window as any).electron.ipcRenderer.getStoreValue('set');
|
||||
commit('setSetData', setData || defaultSettings);
|
||||
} else {
|
||||
const savedSettings = localStorage.getItem('appSettings');
|
||||
if (savedSettings) {
|
||||
commit('setSetData', {
|
||||
...defaultSettings,
|
||||
...JSON.parse(savedSettings),
|
||||
});
|
||||
} else {
|
||||
commit('setSetData', defaultSettings);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const store = createStore({
|
||||
state: state,
|
||||
mutations: mutations,
|
||||
})
|
||||
state,
|
||||
mutations,
|
||||
actions,
|
||||
});
|
||||
|
||||
export default store
|
||||
export default store;
|
||||
|
||||
@@ -4,30 +4,30 @@ export interface IAlbumNew {
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
name: string
|
||||
id: number
|
||||
type: string
|
||||
size: number
|
||||
picId: number
|
||||
blurPicUrl: string
|
||||
companyId: number
|
||||
pic: number
|
||||
picUrl: string
|
||||
publishTime: number
|
||||
description: string
|
||||
tags: string
|
||||
company: string
|
||||
briefDesc: string
|
||||
artist: Artist
|
||||
songs?: any
|
||||
alias: string[]
|
||||
status: number
|
||||
copyrightId: number
|
||||
commentThreadId: string
|
||||
artists: Artist2[]
|
||||
paid: boolean
|
||||
onSale: boolean
|
||||
picId_str: string
|
||||
name: string;
|
||||
id: number;
|
||||
type: string;
|
||||
size: number;
|
||||
picId: number;
|
||||
blurPicUrl: string;
|
||||
companyId: number;
|
||||
pic: number;
|
||||
picUrl: string;
|
||||
publishTime: number;
|
||||
description: string;
|
||||
tags: string;
|
||||
company: string;
|
||||
briefDesc: string;
|
||||
artist: Artist;
|
||||
songs?: any;
|
||||
alias: string[];
|
||||
status: number;
|
||||
copyrightId: number;
|
||||
commentThreadId: string;
|
||||
artists: Artist2[];
|
||||
paid: boolean;
|
||||
onSale: boolean;
|
||||
picId_str: string;
|
||||
}
|
||||
|
||||
interface Artist2 {
|
||||
|
||||
168
src/type/day_recommend.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export interface IDayRecommend {
|
||||
dailySongs: DailySong[];
|
||||
orderSongs: any[];
|
||||
recommendReasons: RecommendReason[];
|
||||
mvResourceInfos: null;
|
||||
}
|
||||
|
||||
interface RecommendReason {
|
||||
songId: number;
|
||||
reason: string;
|
||||
reasonId: string;
|
||||
targetUrl: null;
|
||||
}
|
||||
|
||||
interface DailySong {
|
||||
name: string;
|
||||
id: number;
|
||||
pst: number;
|
||||
t: number;
|
||||
ar: Ar[];
|
||||
alia: string[];
|
||||
pop: number;
|
||||
st: number;
|
||||
rt: null | string;
|
||||
fee: number;
|
||||
v: number;
|
||||
crbt: null;
|
||||
cf: string;
|
||||
al: Al;
|
||||
dt: number;
|
||||
h: H;
|
||||
m: H;
|
||||
l: H;
|
||||
sq: H | null;
|
||||
hr: H | null;
|
||||
a: null;
|
||||
cd: string;
|
||||
no: number;
|
||||
rtUrl: null;
|
||||
ftype: number;
|
||||
rtUrls: any[];
|
||||
djId: number;
|
||||
copyright: number;
|
||||
s_id: number;
|
||||
mark: number;
|
||||
originCoverType: number;
|
||||
originSongSimpleData: OriginSongSimpleDatum | null;
|
||||
tagPicList: null;
|
||||
resourceState: boolean;
|
||||
version: number;
|
||||
songJumpInfo: null;
|
||||
entertainmentTags: null;
|
||||
single: number;
|
||||
noCopyrightRcmd: null;
|
||||
rtype: number;
|
||||
rurl: null;
|
||||
mst: number;
|
||||
cp: number;
|
||||
mv: number;
|
||||
publishTime: number;
|
||||
reason: null | string;
|
||||
videoInfo: VideoInfo;
|
||||
recommendReason: null | string;
|
||||
privilege: Privilege;
|
||||
alg: string;
|
||||
tns?: string[];
|
||||
s_ctrp?: string;
|
||||
}
|
||||
|
||||
interface Privilege {
|
||||
id: number;
|
||||
fee: number;
|
||||
payed: number;
|
||||
realPayed: number;
|
||||
st: number;
|
||||
pl: number;
|
||||
dl: number;
|
||||
sp: number;
|
||||
cp: number;
|
||||
subp: number;
|
||||
cs: boolean;
|
||||
maxbr: number;
|
||||
fl: number;
|
||||
pc: null;
|
||||
toast: boolean;
|
||||
flag: number;
|
||||
paidBigBang: boolean;
|
||||
preSell: boolean;
|
||||
playMaxbr: number;
|
||||
downloadMaxbr: number;
|
||||
maxBrLevel: string;
|
||||
playMaxBrLevel: string;
|
||||
downloadMaxBrLevel: string;
|
||||
plLevel: string;
|
||||
dlLevel: string;
|
||||
flLevel: string;
|
||||
rscl: null;
|
||||
freeTrialPrivilege: FreeTrialPrivilege;
|
||||
rightSource: number;
|
||||
chargeInfoList: ChargeInfoList[];
|
||||
}
|
||||
|
||||
interface ChargeInfoList {
|
||||
rate: number;
|
||||
chargeUrl: null;
|
||||
chargeMessage: null;
|
||||
chargeType: number;
|
||||
}
|
||||
|
||||
interface FreeTrialPrivilege {
|
||||
resConsumable: boolean;
|
||||
userConsumable: boolean;
|
||||
listenType: number;
|
||||
cannotListenReason: number;
|
||||
playReason: null;
|
||||
}
|
||||
|
||||
interface VideoInfo {
|
||||
moreThanOne: boolean;
|
||||
video: Video | null;
|
||||
}
|
||||
|
||||
interface Video {
|
||||
vid: string;
|
||||
type: number;
|
||||
title: string;
|
||||
playTime: number;
|
||||
coverUrl: string;
|
||||
publishTime: number;
|
||||
artists: null;
|
||||
alias: null;
|
||||
}
|
||||
|
||||
interface OriginSongSimpleDatum {
|
||||
songId: number;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
albumMeta: Artist;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface H {
|
||||
br: number;
|
||||
fid: number;
|
||||
size: number;
|
||||
vd: number;
|
||||
sr: number;
|
||||
}
|
||||
|
||||
interface Al {
|
||||
id: number;
|
||||
name: string;
|
||||
picUrl: string;
|
||||
tns: string[];
|
||||
pic_str?: string;
|
||||
pic: number;
|
||||
}
|
||||
|
||||
interface Ar {
|
||||
id: number;
|
||||
name: string;
|
||||
tns: any[];
|
||||
alias: any[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface IData<T> {
|
||||
code: number
|
||||
data: T
|
||||
code: number;
|
||||
data: T;
|
||||
result: T;
|
||||
}
|
||||
|
||||
@@ -7,42 +7,42 @@ export interface IList {
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
name: string
|
||||
id: number
|
||||
trackNumberUpdateTime: number
|
||||
status: number
|
||||
userId: number
|
||||
createTime: number
|
||||
updateTime: number
|
||||
subscribedCount: number
|
||||
trackCount: number
|
||||
cloudTrackCount: number
|
||||
coverImgUrl: string
|
||||
coverImgId: number
|
||||
description: string
|
||||
tags: string[]
|
||||
playCount: number
|
||||
trackUpdateTime: number
|
||||
specialType: number
|
||||
totalDuration: number
|
||||
creator: Creator
|
||||
tracks?: any
|
||||
subscribers: Subscriber[]
|
||||
subscribed: boolean
|
||||
commentThreadId: string
|
||||
newImported: boolean
|
||||
adType: number
|
||||
highQuality: boolean
|
||||
privacy: number
|
||||
ordered: boolean
|
||||
anonimous: boolean
|
||||
coverStatus: number
|
||||
recommendInfo?: any
|
||||
shareCount: number
|
||||
coverImgId_str?: string
|
||||
commentCount: number
|
||||
copywriter: string
|
||||
tag: string
|
||||
name: string;
|
||||
id: number;
|
||||
trackNumberUpdateTime: number;
|
||||
status: number;
|
||||
userId: number;
|
||||
createTime: number;
|
||||
updateTime: number;
|
||||
subscribedCount: number;
|
||||
trackCount: number;
|
||||
cloudTrackCount: number;
|
||||
coverImgUrl: string;
|
||||
coverImgId: number;
|
||||
description: string;
|
||||
tags: string[];
|
||||
playCount: number;
|
||||
trackUpdateTime: number;
|
||||
specialType: number;
|
||||
totalDuration: number;
|
||||
creator: Creator;
|
||||
tracks?: any;
|
||||
subscribers: Subscriber[];
|
||||
subscribed: boolean;
|
||||
commentThreadId: string;
|
||||
newImported: boolean;
|
||||
adType: number;
|
||||
highQuality: boolean;
|
||||
privacy: number;
|
||||
ordered: boolean;
|
||||
anonimous: boolean;
|
||||
coverStatus: number;
|
||||
recommendInfo?: any;
|
||||
shareCount: number;
|
||||
coverImgId_str?: string;
|
||||
commentCount: number;
|
||||
copywriter: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
@@ -120,8 +120,8 @@ interface AvatarDetail {
|
||||
}
|
||||
|
||||
interface Expert {
|
||||
"2": string;
|
||||
"1"?: string;
|
||||
'2': string;
|
||||
'1'?: string;
|
||||
}
|
||||
|
||||
// 推荐歌单
|
||||
|
||||
@@ -1,203 +1,203 @@
|
||||
export interface IListDetail {
|
||||
code: number;
|
||||
relatedVideos?: any;
|
||||
playlist: Playlist;
|
||||
urls?: any;
|
||||
privileges: Privilege[];
|
||||
sharedPrivilege?: any;
|
||||
resEntrance?: any;
|
||||
}
|
||||
|
||||
interface Privilege {
|
||||
id: number;
|
||||
fee: number;
|
||||
payed: number;
|
||||
realPayed: number;
|
||||
st: number;
|
||||
pl: number;
|
||||
dl: number;
|
||||
sp: number;
|
||||
cp: number;
|
||||
subp: number;
|
||||
cs: boolean;
|
||||
maxbr: number;
|
||||
fl: number;
|
||||
pc?: any;
|
||||
toast: boolean;
|
||||
flag: number;
|
||||
paidBigBang: boolean;
|
||||
preSell: boolean;
|
||||
playMaxbr: number;
|
||||
downloadMaxbr: number;
|
||||
rscl?: any;
|
||||
freeTrialPrivilege: FreeTrialPrivilege;
|
||||
chargeInfoList: ChargeInfoList[];
|
||||
}
|
||||
|
||||
interface ChargeInfoList {
|
||||
rate: number;
|
||||
chargeUrl?: any;
|
||||
chargeMessage?: any;
|
||||
chargeType: number;
|
||||
}
|
||||
|
||||
interface FreeTrialPrivilege {
|
||||
resConsumable: boolean;
|
||||
userConsumable: boolean;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: number
|
||||
name: string
|
||||
coverImgId: number
|
||||
coverImgUrl: string
|
||||
coverImgId_str: string
|
||||
adType: number
|
||||
userId: number
|
||||
createTime: number
|
||||
status: number
|
||||
opRecommend: boolean
|
||||
highQuality: boolean
|
||||
newImported: boolean
|
||||
updateTime: number
|
||||
trackCount: number
|
||||
specialType: number
|
||||
privacy: number
|
||||
trackUpdateTime: number
|
||||
commentThreadId: string
|
||||
playCount: number
|
||||
trackNumberUpdateTime: number
|
||||
subscribedCount: number
|
||||
cloudTrackCount: number
|
||||
ordered: boolean
|
||||
description: string
|
||||
tags: string[]
|
||||
updateFrequency?: any
|
||||
backgroundCoverId: number
|
||||
backgroundCoverUrl?: any
|
||||
titleImage: number
|
||||
titleImageUrl?: any
|
||||
englishTitle?: any
|
||||
officialPlaylistType?: any
|
||||
subscribers: Subscriber[]
|
||||
subscribed: boolean
|
||||
creator: Subscriber
|
||||
tracks: Track[]
|
||||
videoIds?: any
|
||||
videos?: any
|
||||
trackIds: TrackId[]
|
||||
shareCount: number
|
||||
commentCount: number
|
||||
remixVideo?: any
|
||||
sharedUsers?: any
|
||||
historySharedUsers?: any
|
||||
}
|
||||
|
||||
interface TrackId {
|
||||
id: number;
|
||||
v: number;
|
||||
t: number;
|
||||
at: number;
|
||||
alg?: any;
|
||||
uid: number;
|
||||
rcmdReason: string;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
name: string;
|
||||
id: number;
|
||||
pst: number;
|
||||
t: number;
|
||||
ar: Ar[];
|
||||
alia: string[];
|
||||
pop: number;
|
||||
st: number;
|
||||
rt?: string;
|
||||
fee: number;
|
||||
v: number;
|
||||
crbt?: any;
|
||||
cf: string;
|
||||
al: Al;
|
||||
dt: number;
|
||||
h: H;
|
||||
m: H;
|
||||
l?: H;
|
||||
a?: any;
|
||||
cd: string;
|
||||
no: number;
|
||||
rtUrl?: any;
|
||||
ftype: number;
|
||||
rtUrls: any[];
|
||||
djId: number;
|
||||
copyright: number;
|
||||
s_id: number;
|
||||
mark: number;
|
||||
originCoverType: number;
|
||||
originSongSimpleData?: any;
|
||||
single: number;
|
||||
noCopyrightRcmd?: any;
|
||||
mst: number;
|
||||
cp: number;
|
||||
mv: number;
|
||||
rtype: number;
|
||||
rurl?: any;
|
||||
publishTime: number;
|
||||
tns?: string[];
|
||||
}
|
||||
|
||||
interface H {
|
||||
br: number;
|
||||
fid: number;
|
||||
size: number;
|
||||
vd: number;
|
||||
}
|
||||
|
||||
interface Al {
|
||||
id: number;
|
||||
name: string;
|
||||
picUrl: string;
|
||||
tns: any[];
|
||||
pic_str?: string;
|
||||
pic: number;
|
||||
}
|
||||
|
||||
interface Ar {
|
||||
id: number;
|
||||
name: string;
|
||||
tns: any[];
|
||||
alias: any[];
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
defaultAvatar: boolean;
|
||||
province: number;
|
||||
authStatus: number;
|
||||
followed: boolean;
|
||||
avatarUrl: string;
|
||||
accountStatus: number;
|
||||
gender: number;
|
||||
city: number;
|
||||
birthday: number;
|
||||
userId: number;
|
||||
userType: number;
|
||||
nickname: string;
|
||||
signature: string;
|
||||
description: string;
|
||||
detailDescription: string;
|
||||
avatarImgId: number;
|
||||
backgroundImgId: number;
|
||||
backgroundUrl: string;
|
||||
authority: number;
|
||||
mutual: boolean;
|
||||
expertTags?: any;
|
||||
experts?: any;
|
||||
djStatus: number;
|
||||
vipType: number;
|
||||
remarkName?: any;
|
||||
authenticationTypes: number;
|
||||
avatarDetail?: any;
|
||||
backgroundImgIdStr: string;
|
||||
anchor: boolean;
|
||||
avatarImgIdStr: string;
|
||||
avatarImgId_str: string;
|
||||
}
|
||||
export interface IListDetail {
|
||||
code: number;
|
||||
relatedVideos?: any;
|
||||
playlist: Playlist;
|
||||
urls?: any;
|
||||
privileges: Privilege[];
|
||||
sharedPrivilege?: any;
|
||||
resEntrance?: any;
|
||||
}
|
||||
|
||||
interface Privilege {
|
||||
id: number;
|
||||
fee: number;
|
||||
payed: number;
|
||||
realPayed: number;
|
||||
st: number;
|
||||
pl: number;
|
||||
dl: number;
|
||||
sp: number;
|
||||
cp: number;
|
||||
subp: number;
|
||||
cs: boolean;
|
||||
maxbr: number;
|
||||
fl: number;
|
||||
pc?: any;
|
||||
toast: boolean;
|
||||
flag: number;
|
||||
paidBigBang: boolean;
|
||||
preSell: boolean;
|
||||
playMaxbr: number;
|
||||
downloadMaxbr: number;
|
||||
rscl?: any;
|
||||
freeTrialPrivilege: FreeTrialPrivilege;
|
||||
chargeInfoList: ChargeInfoList[];
|
||||
}
|
||||
|
||||
interface ChargeInfoList {
|
||||
rate: number;
|
||||
chargeUrl?: any;
|
||||
chargeMessage?: any;
|
||||
chargeType: number;
|
||||
}
|
||||
|
||||
interface FreeTrialPrivilege {
|
||||
resConsumable: boolean;
|
||||
userConsumable: boolean;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: number;
|
||||
name: string;
|
||||
coverImgId: number;
|
||||
coverImgUrl: string;
|
||||
coverImgId_str: string;
|
||||
adType: number;
|
||||
userId: number;
|
||||
createTime: number;
|
||||
status: number;
|
||||
opRecommend: boolean;
|
||||
highQuality: boolean;
|
||||
newImported: boolean;
|
||||
updateTime: number;
|
||||
trackCount: number;
|
||||
specialType: number;
|
||||
privacy: number;
|
||||
trackUpdateTime: number;
|
||||
commentThreadId: string;
|
||||
playCount: number;
|
||||
trackNumberUpdateTime: number;
|
||||
subscribedCount: number;
|
||||
cloudTrackCount: number;
|
||||
ordered: boolean;
|
||||
description: string;
|
||||
tags: string[];
|
||||
updateFrequency?: any;
|
||||
backgroundCoverId: number;
|
||||
backgroundCoverUrl?: any;
|
||||
titleImage: number;
|
||||
titleImageUrl?: any;
|
||||
englishTitle?: any;
|
||||
officialPlaylistType?: any;
|
||||
subscribers: Subscriber[];
|
||||
subscribed: boolean;
|
||||
creator: Subscriber;
|
||||
tracks: Track[];
|
||||
videoIds?: any;
|
||||
videos?: any;
|
||||
trackIds: TrackId[];
|
||||
shareCount: number;
|
||||
commentCount: number;
|
||||
remixVideo?: any;
|
||||
sharedUsers?: any;
|
||||
historySharedUsers?: any;
|
||||
}
|
||||
|
||||
interface TrackId {
|
||||
id: number;
|
||||
v: number;
|
||||
t: number;
|
||||
at: number;
|
||||
alg?: any;
|
||||
uid: number;
|
||||
rcmdReason: string;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
name: string;
|
||||
id: number;
|
||||
pst: number;
|
||||
t: number;
|
||||
ar: Ar[];
|
||||
alia: string[];
|
||||
pop: number;
|
||||
st: number;
|
||||
rt?: string;
|
||||
fee: number;
|
||||
v: number;
|
||||
crbt?: any;
|
||||
cf: string;
|
||||
al: Al;
|
||||
dt: number;
|
||||
h: H;
|
||||
m: H;
|
||||
l?: H;
|
||||
a?: any;
|
||||
cd: string;
|
||||
no: number;
|
||||
rtUrl?: any;
|
||||
ftype: number;
|
||||
rtUrls: any[];
|
||||
djId: number;
|
||||
copyright: number;
|
||||
s_id: number;
|
||||
mark: number;
|
||||
originCoverType: number;
|
||||
originSongSimpleData?: any;
|
||||
single: number;
|
||||
noCopyrightRcmd?: any;
|
||||
mst: number;
|
||||
cp: number;
|
||||
mv: number;
|
||||
rtype: number;
|
||||
rurl?: any;
|
||||
publishTime: number;
|
||||
tns?: string[];
|
||||
}
|
||||
|
||||
interface H {
|
||||
br: number;
|
||||
fid: number;
|
||||
size: number;
|
||||
vd: number;
|
||||
}
|
||||
|
||||
interface Al {
|
||||
id: number;
|
||||
name: string;
|
||||
picUrl: string;
|
||||
tns: any[];
|
||||
pic_str?: string;
|
||||
pic: number;
|
||||
}
|
||||
|
||||
interface Ar {
|
||||
id: number;
|
||||
name: string;
|
||||
tns: any[];
|
||||
alias: any[];
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
defaultAvatar: boolean;
|
||||
province: number;
|
||||
authStatus: number;
|
||||
followed: boolean;
|
||||
avatarUrl: string;
|
||||
accountStatus: number;
|
||||
gender: number;
|
||||
city: number;
|
||||
birthday: number;
|
||||
userId: number;
|
||||
userType: number;
|
||||
nickname: string;
|
||||
signature: string;
|
||||
description: string;
|
||||
detailDescription: string;
|
||||
avatarImgId: number;
|
||||
backgroundImgId: number;
|
||||
backgroundUrl: string;
|
||||
authority: number;
|
||||
mutual: boolean;
|
||||
expertTags?: any;
|
||||
experts?: any;
|
||||
djStatus: number;
|
||||
vipType: number;
|
||||
remarkName?: any;
|
||||
authenticationTypes: number;
|
||||
avatarDetail?: any;
|
||||
backgroundImgIdStr: string;
|
||||
anchor: boolean;
|
||||
avatarImgIdStr: string;
|
||||
avatarImgId_str: string;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export interface ILyric {
|
||||
sgc: boolean;
|
||||
sfy: boolean;
|
||||
qfy: boolean;
|
||||
lrc: Lrc;
|
||||
klyric: Lrc;
|
||||
tlyric: Lrc;
|
||||
code: number;
|
||||
}
|
||||
|
||||
interface Lrc {
|
||||
version: number;
|
||||
lyric: string;
|
||||
}
|
||||
export interface ILyric {
|
||||
sgc: boolean;
|
||||
sfy: boolean;
|
||||
qfy: boolean;
|
||||
lrc: Lrc;
|
||||
klyric: Lrc;
|
||||
tlyric: Lrc;
|
||||
code: number;
|
||||
}
|
||||
|
||||
interface Lrc {
|
||||
version: number;
|
||||
lyric: string;
|
||||
}
|
||||
|
||||