46 Commits
1.4.0 ... 2.0.0

Author SHA1 Message Date
alger
06bffe7618 feat: 优化歌词页面样式 添加歌词进度显示 优化歌曲及列表加载方式 大幅提升歌曲歌词播放速度 2024-10-18 18:37:53 +08:00
alger
7abc087d70 feat: 添加播放列表自动滚动到播放的那个 2024-09-18 17:05:36 +08:00
alger
eb2ea1981d feat: 优化歌词背景色 加载问题 2024-09-18 15:11:20 +08:00
alger
6dc14ec51b feat: 优化歌词背景 修改为背景色 以解决卡顿问题 2024-09-14 18:22:56 +08:00
alger
36f8257a3e 🐞 fix: 上一首下一首逻辑错乱问题 2024-09-13 17:23:03 +08:00
alger
c55544df46 feat: 修复排行播放列表问题 优化暂停播放逻辑 2024-09-13 17:07:45 +08:00
alger
008f2183de 🐞 fix: 修复历史播放 不触发播放列表问题 2024-09-13 14:14:32 +08:00
alger
dd3a3c3bbb 🐞 fix: 类型问题修复 2024-09-13 14:11:02 +08:00
alger
941eb2e66e 🐞 fix: 修复作者不显示问题 2024-09-13 09:43:05 +08:00
alger
a98fcb43d6 🐞 fix: 修复播放列表无法显示问题 2024-09-13 09:08:57 +08:00
alger
791121ae06 feat: 优化搜索 2024-09-12 17:28:51 +08:00
alger
0c156e2708 feat: V1.7.0 2024-09-12 16:48:13 +08:00
alger
017b47fded 🐞 fix: 修复各种报错问题 2024-09-12 16:44:42 +08:00
alger
e27ed22c16 feat: 完善搜索歌单列表加载问题 2024-09-12 15:26:07 +08:00
alger
904d8744ef feat: 优化播放栏背景问题 2024-09-12 15:00:00 +08:00
alger
800e0b7360 feat: 完善歌单列表组件 实现滚动加载更多 2024-09-11 16:29:43 +08:00
alger
b6a5461a1d 🎈 perf: 优化加载 升级vue3.5 electron32等多个包 添加v-loading指令 2024-09-04 15:20:43 +08:00
alger
a4eda61a86 🌈 style: 更新版本 1.5.1 2024-06-25 15:22:30 +08:00
alger
a79d0712a4 🌈 style: 修改mv搜索项样式 2024-06-05 17:03:27 +08:00
alger
8f782cdc9d 🌈 style: 修改mv搜索项样式 2024-06-05 15:53:12 +08:00
alger
2f851f3172 🎈 perf: 优化歌曲列表以及图片加载 2024-06-05 15:35:31 +08:00
alger
9fcf455c08 🌈 style: 更新版本 1.5.0 2024-05-31 08:56:16 +08:00
alger
9b14906a46 🌈 style: add LICENSE. 2024-05-27 18:18:02 +08:00
alger
14ce428951 🌈 style: 修改README 2024-05-27 11:52:10 +08:00
alger
8c93124311 🌈 style: 修改README 2024-05-27 11:51:15 +08:00
alger
c09707867b 🦄 refactor: 适配 web移动端 改造 2024-05-23 17:12:35 +08:00
alger
a2af0f3904 feat: 优化搜索功能 2024-05-22 19:20:57 +08:00
alger
73982f0e84 feat: 添加manifest.json 2024-05-22 15:38:43 +08:00
alger
449a6fd335 feat: 登录问题修复 2024-05-22 15:14:26 +08:00
alger
32b39c7927 feat: 添加每日推荐 样式, 请求等大量优化 2024-05-22 12:07:48 +08:00
alger
c6f1e0b233 🌈 style: 修改issue模板 2024-05-21 11:36:06 +08:00
alger
7c1a3ae4bc 🌈 style: 添加issue模板 2024-05-21 11:33:57 +08:00
alger
6bd6622484 🌈 style: 添加gzip压缩配置 2024-05-21 11:24:33 +08:00
alger
433aff385d 🌈 style: 更换ico文件 2024-05-21 11:06:20 +08:00
alger
c37ad07f93 🌈 style: 优化类型 2024-05-21 11:01:23 +08:00
alger
e4c1f855fb 🐞 fix: 修复关闭报错 2024-05-21 10:27:46 +08:00
alger
6978656061 🌈 style: 更新logo.png 2024-05-21 10:24:32 +08:00
alger
973d60c98f 🌈 style: 完善gitgnore 2024-05-21 10:21:51 +08:00
alger
5a43ba2576 🌈 style: 删除无用配置 2024-05-21 10:18:40 +08:00
alger
e52a02cf3c 🌈 style: 去除无用代码 2024-05-21 10:16:30 +08:00
alger
da8216e2ca 🦄 refactor: 重构打包方式 2024-05-21 08:52:34 +08:00
alger
bd0e2ec35c feat: 历史纪录去除按钮 2024-05-20 19:55:52 +08:00
alger
7c8598ffa5 feat: 优化歌词体验 2024-05-20 19:54:00 +08:00
alger
50e594b91d 🐞 fix: 修复歌词界面无法打开问题 2024-05-16 19:57:20 +08:00
alger
a9e5bb33e4 feat: 添加eslint 和 桌面歌词(未完成) 2024-05-16 18:54:30 +08:00
alger
5e8676a039 📃 docs: 完善文档 2024-05-14 18:11:54 +08:00
103 changed files with 4996 additions and 2594 deletions

View File

@@ -1,4 +1,3 @@
VITE_API = /api VITE_API = /api
VITE_API_MT = /mt
VITE_API_MUSIC = /music VITE_API_MUSIC = /music
VITE_API_PROXY = http://110.42.251.190:9856 VITE_API_PROXY = http://110.42.251.190:9856

View File

@@ -1,4 +1,3 @@
VITE_API = http://110.42.251.190:9898 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_MUSIC = http://110.42.251.190:4100
VITE_API_PROXY = http://110.42.251.190:9856 VITE_API_PROXY = http://110.42.251.190:9856

13
.eslintignore Normal file
View File

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

136
.eslintrc Normal file
View File

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

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @algerkong

View 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
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name:
url:
about:

View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
## IssueShoot
- 预估时长: {{ .duration }}
- 期望完成时间: {{ .deadline }}
- 开发难度: {{ .level }}
- 参与人数: 1
- 需求对接人: ivringpeng
- 验收标准: 实现期望改造效果,提 PR 并通过验收无误
- 备注: 最终激励以实际提交 `pull request` 并合并为准

7
.gitignore vendored
View File

@@ -1,11 +1,16 @@
node_modules node_modules
.DS_Store .DS_Store
dist dist
dist-ssr dist-ssr
*.local *.local
dist_electron dist_electron
.idea .idea
# lock
yarn.lock
pnpm-lock.yaml pnpm-lock.yaml
package-lock.json package-lock.json
dist.zip dist.zip
.vscode

39
.prettierrc.js Normal file
View 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',
};

View File

@@ -1,3 +0,0 @@
{
"compile-hero.disable-compile-files-on-did-save-code": true
}

201
LICENSE Normal file
View 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.

View File

@@ -1,5 +1,43 @@
# Vue 3 + Typescript + Vite # 一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
主要功能如下
vue3 + TypeScript + NaiveUI + animateCss + Vuex + VueRouter + Axios等实现音乐桌面web端 - 音乐推荐
实现各项功能 - 音乐播放
网站地址http://mc.myalger.top/ - 网易云登录
- 播放历史
- 桌面歌词
- 歌单 mv 搜索 专辑等功能
## 项目运行
```bash
# 安装依赖
npm install
# 运行项目 web
npm run dev
# 运行项目 electron
npm run start
# 打包项目 web
npm run build
# 打包项目 electron
npm run win ...
# 具体看 package.json
```
## 软件截图
![首页](./docs/img/image.png)
![歌单](./docs/img/image-1.png)
![搜索](./docs/img/image-2.png)
![mv](./docs/img/image-3.png)
![历史](./docs/img/image-4.png)
![我的](./docs/img/image-5.png)
## 欢迎提Issues
## 免责声明
本软件仅用于学习交流,禁止用于商业用途,否则后果自负。

124
app.js
View File

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

31
build/win32.json Normal file
View 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
View 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
View 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"
}
}

4
components.d.ts vendored
View File

@@ -1,10 +1,10 @@
/* eslint-disable */ /* eslint-disable */
/* prettier-ignore */
// @ts-nocheck // @ts-nocheck
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
export {} export {}
/* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
MPop: typeof import('./src/components/common/MPop.vue')['default'] MPop: typeof import('./src/components/common/MPop.vue')['default']
@@ -25,9 +25,11 @@ declare module 'vue' {
NSlider: typeof import('naive-ui')['NSlider'] NSlider: typeof import('naive-ui')['NSlider']
NSwitch: typeof import('naive-ui')['NSwitch'] NSwitch: typeof import('naive-ui')['NSwitch']
NTooltip: typeof import('naive-ui')['NTooltip'] NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default'] PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default']
PlayListsItem: typeof import('./src/components/common/PlayListsItem.vue')['default'] PlayListsItem: typeof import('./src/components/common/PlayListsItem.vue')['default']
PlaylistType: typeof import('./src/components/PlaylistType.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'] RecommendAlbum: typeof import('./src/components/RecommendAlbum.vue')['default']
RecommendSinger: typeof import('./src/components/RecommendSinger.vue')['default'] RecommendSinger: typeof import('./src/components/RecommendSinger.vue')['default']
RecommendSonglist: typeof import('./src/components/RecommendSonglist.vue')['default'] RecommendSonglist: typeof import('./src/components/RecommendSonglist.vue')['default']

BIN
docs/img/image-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 KiB

BIN
docs/img/image-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
docs/img/image-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
docs/img/image-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
docs/img/image-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

BIN
docs/img/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

View File

@@ -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"
}
]
}
}

71
electron/lyric.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "alger-music", "name": "alger-music",
"version": "1.3.0", "version": "2.0.0",
"description": "这是一个用于音乐播放的应用程序。", "description": "这是一个用于音乐播放的应用程序。",
"author": "Alger <algerkc@qq.com>", "author": "Alger <algerkc@qq.com>",
"main": "app.js", "main": "app.js",
@@ -8,40 +8,54 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"es": "vite && electron .",
"start": "set NODE_ENV=development&&electron .", "start": "set NODE_ENV=development&&electron .",
"e:b": "electron-builder --config ./electron.config.json", "lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
"eb": "vite build && e:b" "b:win:x64": "electron-builder --config ./build/win64.json",
"b:win:x86": "electron-builder --config ./build/win32.json",
"b:win:arm": "electron-builder --config ./build/winarm64.json"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/postcss7-compat": "^2.2.4", "electron-store": "^8.1.0"
"@vue/runtime-core": "^3.3.4",
"@vueuse/core": "^10.7.1",
"autoprefixer": "^9.8.6",
"axios": "^0.21.1",
"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"
}, },
"devDependencies": { "devDependencies": {
"@sicons/antd": "^0.10.0", "@tailwindcss/postcss7-compat": "^2.2.4",
"@vicons/antd": "^0.10.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@vitejs/plugin-vue": "^4.2.3", "@typescript-eslint/parser": "^6.21.0",
"@vue/compiler-sfc": "^3.3.4", "@vitejs/plugin-vue": "^5.1.3",
"electron": "^28.0.0", "@vue/compiler-sfc": "^3.5.0",
"electron-builder": "^24.9.1", "@vue/eslint-config-typescript": "^13.0.0",
"typescript": "^4.3.2", "@vue/runtime-core": "^3.5.0",
"unplugin-auto-import": "^0.17.2", "@vueuse/core": "^11.0.3",
"unplugin-vue-components": "^0.26.0", "@vueuse/electron": "^11.0.3",
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
"electron": "^32.0.1",
"electron-builder": "^25.0.5",
"eslint": "^8.56.0",
"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.44",
"prettier": "^3.3.3",
"remixicon": "^4.2.0",
"sass": "^1.78.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^5.5.4",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4",
"vfonts": "^0.1.0", "vfonts": "^0.1.0",
"vite": "^4.4.7", "vite": "^5.4.3",
"vite-plugin-vue-devtools": "1.0.0-beta.5", "vite-plugin-compression": "^0.5.1",
"vue-tsc": "^0.0.24" "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"
} }
} }

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 178 KiB

10
public/manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "Alger Music PWA",
"icons": [
{
"src": "./icon.png",
"type": "image/png",
"sizes": "256x256"
}
]
}

View File

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

View File

@@ -1,9 +1,11 @@
import request from "@/utils/request"; import { IData } from '@/type';
import { IHotSinger } from "@/type/singer"; import { IAlbumNew } from '@/type/album';
import { ISearchKeyword, IHotSearch } from "@/type/search"; import { IDayRecommend } from '@/type/day_recommend';
import { IPlayListSort } from "@/type/playlist"; import { IRecommendMusic } from '@/type/music';
import { IRecommendMusic } from "@/type/music"; import { IPlayListSort } from '@/type/playlist';
import { IAlbumNew } from "@/type/album"; import { IHotSearch, ISearchKeyword } from '@/type/search';
import { IHotSinger } from '@/type/singer';
import request from '@/utils/request';
interface IHotSingerParams { interface IHotSingerParams {
offset: number; offset: number;
@@ -16,30 +18,35 @@ interface IRecommendMusicParams {
// 获取热门歌手 // 获取热门歌手
export const getHotSinger = (params: IHotSingerParams) => { export const getHotSinger = (params: IHotSingerParams) => {
return request.get<IHotSinger>("/top/artists", { params }); return request.get<IHotSinger>('/top/artists', { params });
}; };
// 获取搜索推荐词 // 获取搜索推荐词
export const getSearchKeyword = () => { export const getSearchKeyword = () => {
return request.get<ISearchKeyword>("/search/default"); return request.get<ISearchKeyword>('/search/default');
}; };
// 获取热门搜索 // 获取热门搜索
export const getHotSearch = () => { export const getHotSearch = () => {
return request.get<IHotSearch>("/search/hot/detail"); return request.get<IHotSearch>('/search/hot/detail');
}; };
// 获取歌单分类 // 获取歌单分类
export const getPlaylistCategory = () => { export const getPlaylistCategory = () => {
return request.get<IPlayListSort>("/playlist/catlist"); return request.get<IPlayListSort>('/playlist/catlist');
}; };
// 获取推荐音乐 // 获取推荐音乐
export const getRecommendMusic = (params: IRecommendMusicParams) => { 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 = () => { export const getNewAlbum = () => {
return request.get<IAlbumNew>("/album/newest"); return request.get<IAlbumNew>('/album/newest');
}; };

View File

@@ -1,42 +1,42 @@
import request from "@/utils/request"; import { IList } from '@/type/list';
import { IList } from "@/type/list"; import type { IListDetail } from '@/type/listDetail';
import type { IListDetail } from "@/type/listDetail"; import request from '@/utils/request';
interface IListByTagParams { interface IListByTagParams {
tag: string; tag: string;
before: number; before: number;
limit: number; limit: number;
} }
interface IListByCatParams { interface IListByCatParams {
cat: string; cat: string;
offset: number; offset: number;
limit: number; limit: number;
} }
// 根据tag 获取歌单列表 // 根据tag 获取歌单列表
export function getListByTag(params: IListByTagParams) { export function getListByTag(params: IListByTagParams) {
return request.get<IList>("/top/playlist/highquality", { params: params }); return request.get<IList>('/top/playlist/highquality', { params });
} }
// 根据cat 获取歌单列表 // 根据cat 获取歌单列表
export function getListByCat(params: IListByCatParams) { export function getListByCat(params: IListByCatParams) {
return request.get("/top/playlist", { return request.get('/top/playlist', {
params: params, params,
}); });
} }
// 获取推荐歌单 // 获取推荐歌单
export function getRecommendList(limit: number = 30) { export function getRecommendList(limit: number = 30) {
return request.get("/personalized", { params: { limit } }); return request.get('/personalized', { params: { limit } });
} }
// 获取歌单详情 // 获取歌单详情
export function getListDetail(id: number | string) { export function getListDetail(id: number | string) {
return request.get<IListDetail>("/playlist/detail", { params: { id } }); return request.get<IListDetail>('/playlist/detail', { params: { id } });
} }
// 获取专辑内容 // 获取专辑内容
export function getAlbum(id: number | string) { export function getAlbum(id: number | string) {
return request.get("/album", { params: { id } }); return request.get('/album', { params: { id } });
} }

View File

@@ -1,46 +1,46 @@
import request from "@/utils/request"; import request from '@/utils/request';
// 创建二维码key // 创建二维码key
// /login/qr/key // /login/qr/key
export function getQrKey() { export function getQrKey() {
return request.get("/login/qr/key"); return request.get('/login/qr/key');
} }
// 创建二维码 // 创建二维码
// /login/qr/create // /login/qr/create
export function createQr(key: any) { 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 // /login/qr/check
export function checkQr(key: any) { export function checkQr(key: any) {
return request.get("/login/qr/check", { params: { key: key } }); return request.get('/login/qr/check', { params: { key } });
} }
// 获取登录状态 // 获取登录状态
// /login/status // /login/status
export function getLoginStatus() { export function getLoginStatus() {
return request.get("/login/status"); return request.get('/login/status');
} }
// 获取用户信息 // 获取用户信息
// /user/account // /user/account
export function getUserDetail() { export function getUserDetail() {
return request.get("/user/account"); return request.get('/user/account');
} }
// 退出登录 // 退出登录
// /logout // /logout
export function logout() { export function logout() {
return request.get("/logout"); return request.get('/logout');
} }
// 手机号登录 // 手机号登录
// /login/cellphone // /login/cellphone
export function loginByCellphone(phone: any, password: any) { export function loginByCellphone(phone: string, password: string) {
return request.post("/login/cellphone", { return request.post('/login/cellphone', {
phone: phone, phone,
password: password, password,
}); });
} }

View File

@@ -1,22 +1,22 @@
import { IPlayMusicUrl } from "@/type/music" import { ILyric } from '@/type/lyric';
import { ILyric } from "@/type/lyric" import { IPlayMusicUrl } from '@/type/music';
import request from "@/utils/request" import request from '@/utils/request';
import requestMusic from "@/utils/request_music" import requestMusic from '@/utils/request_music';
// 根据音乐Id获取音乐播放URl // 根据音乐Id获取音乐播放URl
export const getMusicUrl = (id: number) => { export const getMusicUrl = (id: number) => {
return request.get<IPlayMusicUrl>("/song/url", { params: { id: id } }) return request.get<IPlayMusicUrl>('/song/url', { params: { id } });
} };
// 获取歌曲详情 // 获取歌曲详情
export const getMusicDetail = (ids: Array<number>) => { export const getMusicDetail = (ids: Array<number>) => {
return request.get("/song/detail", { params: { ids: ids.join(",")}}) return request.get('/song/detail', { params: { ids: ids.join(',') } });
} };
// 根据音乐Id获取音乐歌词 // 根据音乐Id获取音乐歌词
export const getMusicLrc = (id: number) => { export const getMusicLrc = (id: number) => {
return request.get<ILyric>("/lyric", { params: { id: id } }) return request.get<ILyric>('/lyric', { params: { id } });
} };
export const getParsingMusicUrl = (id: number) => { export const getParsingMusicUrl = (id: number) => {
return requestMusic.get<any>("/music", { params: { id: id } }) return requestMusic.get<any>('/music', { params: { id } });
} };

View File

@@ -1,6 +1,6 @@
import { IData } from '@/type' import { IData } from '@/type';
import { IMvItem, IMvUrlData } from '@/type/mv' import { IMvItem, IMvUrlData } from '@/type/mv';
import request from '@/utils/request' import request from '@/utils/request';
// 获取 mv 排行 // 获取 mv 排行
export const getTopMv = (limit: number) => { export const getTopMv = (limit: number) => {
@@ -8,8 +8,8 @@ export const getTopMv = (limit: number) => {
params: { params: {
limit, limit,
}, },
}) });
} };
// 获取 mv 数据 // 获取 mv 数据
export const getMvDetail = (mvid: string) => { export const getMvDetail = (mvid: string) => {
@@ -17,8 +17,8 @@ export const getMvDetail = (mvid: string) => {
params: { params: {
mvid, mvid,
}, },
}) });
} };
// 获取 mv 地址 // 获取 mv 地址
export const getMvUrl = (id: Number) => { export const getMvUrl = (id: Number) => {
@@ -26,5 +26,5 @@ export const getMvUrl = (id: Number) => {
params: { params: {
id, id,
}, },
}) });
} };

View File

@@ -1,13 +1,12 @@
import request from "@/utils/request" import request from '@/utils/request';
import { ISearchDetail } from "@/type/search"
interface IParams { interface IParams {
keywords: string keywords: string;
type: number type: number;
} }
// 搜索内容 // 搜索内容
export const getSearch = (params: IParams) => { export const getSearch = (params: IParams) => {
return request.get<any>('/cloudsearch', { return request.get<any>('/cloudsearch', {
params, params,
}) });
} };

View File

@@ -1,17 +1,17 @@
import request from "@/utils/request"; import request from '@/utils/request';
// /user/detail // /user/detail
export function getUserDetail(uid: number) { export function getUserDetail(uid: number) {
return request.get("/user/detail", { params: { uid } }); return request.get('/user/detail', { params: { uid } });
} }
// /user/playlist // /user/playlist
export function getUserPlaylist(uid: number) { 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 // /user/record?uid=32953014&type=1
export function getUserRecord(uid: number, type: number = 0) { export function getUserRecord(uid: number, type: number = 0) {
return request.get("/user/record", { params: { uid, type } }); return request.get('/user/record', { params: { uid, type } });
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

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

View File

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

View File

@@ -1,86 +1,86 @@
<template> <template>
<div class="recommend-album"> <div class="recommend-album">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div> <div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div>
<div class="recommend-album-list"> <div class="recommend-album-list">
<template v-for="(item,index) in albumData?.albums" :key="item.id"> <template v-for="(item, index) in albumData?.albums" :key="item.id">
<div <div
v-if="index < 6" v-if="index < 6"
class="recommend-album-list-item" class="recommend-album-list-item"
:class="setAnimationClass('animate__backInUp')" :class="setAnimationClass('animate__backInUp')"
:style="setAnimationDelay(index, 100)" :style="setAnimationDelay(index, 100)"
@click="handleClick(item)" @click="handleClick(item)"
> >
<n-image <n-image
class="recommend-album-list-item-img" class="recommend-album-list-item-img"
:src="getImgUrl( item.blurPicUrl, '200y200')" :src="getImgUrl(item.blurPicUrl, '200y200')"
lazy lazy
preview-disabled preview-disabled
/> />
<div class="recommend-album-list-item-content">{{ item.name }}</div> <div class="recommend-album-list-item-content">{{ item.name }}</div>
</div>
</template>
</div> </div>
<MusicList v-model:show="showMusic" :name="albumName" :song-list="songList" /> </template>
</div> </div>
<MusicList v-model:show="showMusic" :name="albumName" :song-list="songList" />
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { getNewAlbum } from "@/api/home" import { onMounted, ref } from 'vue';
import { ref, onMounted } from "vue";
import type { IAlbumNew } from "@/type/album"
import { setAnimationClass, setAnimationDelay, getImgUrl } from "@/utils";
import { getAlbum } from "@/api/list";
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 loadAlbumList = async () => {
const { data } = await getNewAlbum(); const { data } = await getNewAlbum();
albumData.value = data albumData.value = data;
} };
const showMusic = ref(false) const showMusic = ref(false);
const songList = ref([]) const songList = ref([]);
const albumName = ref('') const albumName = ref('');
const handleClick = async (item:any) => { const handleClick = async (item: any) => {
albumName.value = item.name albumName.value = item.name;
showMusic.value = true showMusic.value = true;
const res = await getAlbum(item.id) const res = await getAlbum(item.id);
songList.value = res.data.songs.map((song:any)=>{ songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || item.picUrl song.al.picUrl = song.al.picUrl || item.picUrl;
return song return song;
}) });
} };
onMounted(() => { onMounted(() => {
loadAlbumList() loadAlbumList();
}) });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.recommend-album { .recommend-album {
@apply flex-1 mx-5; @apply flex-1 mx-5;
.title { .title {
@apply text-lg font-bold mb-4; @apply text-lg font-bold mb-4;
} }
.recommend-album-list { .recommend-album-list {
@apply grid grid-cols-2 grid-rows-3 gap-2; @apply grid grid-cols-2 grid-rows-3 gap-2;
&-item { &-item {
@apply rounded-xl overflow-hidden relative; @apply rounded-xl overflow-hidden relative;
&-img { &-img {
@apply rounded-xl transition w-full h-full; @apply rounded-xl transition w-full h-full;
} }
&:hover img { &:hover img {
filter: brightness(50%); filter: brightness(50%);
} }
&-content { &-content {
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl bg-opacity-60 bg-black; @apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl bg-opacity-60 bg-black;
} }
&-content:hover { &-content:hover {
opacity: 1; opacity: 1;
} }
}
} }
}
} }
</style> </style>

View File

@@ -1,85 +1,158 @@
<template> <template>
<!-- 推荐歌手 --> <!-- 推荐歌手 -->
<n-scrollbar :size="100" :x-scrollable="true">
<div class="recommend-singer"> <div class="recommend-singer">
<div class="recommend-singer-list"> <div class="recommend-singer-list">
<div <div
class="recommend-singer-item relative" v-if="dayRecommendData"
:class="setAnimationClass('animate__backInRight')" class="recommend-singer-item relative"
v-for="(item, index) in hotSingerData?.artists" :class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(index, 100)" :style="setAnimationDelay(0, 100)"
:key="item.id" >
> <div
<div :style="setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '300y300'))"
:style="setBackgroundImg(getImgUrl(item.picUrl,'300y300'))" class="recommend-singer-item-bg"
class="recommend-singer-item-bg" ></div>
></div> <div
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10" @click="showMusic = true"
>{{ item.musicSize }}</div> >
<div class="recommend-singer-item-info z-10"> <div class="font-bold text-xl">每日推荐</div>
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
<i class="iconfont icon-playfill text-xl"></i> <div class="mt-2">
</div> <p v-for="item in dayRecommendData?.dailySongs.slice(0, 5)" :key="item.id" class="text-el">
<div class="ml-4"> {{ item.name }}
<div class="recommend-singer-item-info-name">{{ item.name }}</div> <br />
<div class="recommend-singer-item-info-name">{{ item.name }}</div> </p>
</div>
</div>
</div> </div>
</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"
/>
</div> </div>
</n-scrollbar>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { setBackgroundImg, setAnimationDelay, setAnimationClass,getImgUrl } from "@/utils"; import { onMounted, ref } from 'vue';
import { onMounted, ref } from "vue"; import { useStore } from 'vuex';
import { getHotSinger } from "@/api/home";
import type { IHotSinger } from "@/type/singer"; import { getDayRecommend, getHotSinger } from '@/api/home';
import router from "@/router"; 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 hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
//加载推荐歌手 onMounted(async () => {
const loadSingerList = async () => { await loadData();
const { data } = await getHotSinger({ offset: 0, limit: 5 });
hotSingerData.value = data;
};
// 页面初始化
onMounted(() => {
loadSingerList();
}); });
const loadData = async () => {
try {
// 第一个请求:获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
const toSearchSinger = (keyword: string) => { // 第二个请求:获取每日推荐
router.push({ try {
path: "/search", const {
query: { data: { data: dayRecommend },
keyword: keyword, } = await getDayRecommend();
}, console.log('dayRecommend', dayRecommend);
}); // 处理数据
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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.recommend-singer { .recommend-singer {
&-list { &-list {
@apply flex; @apply flex;
height: 280px; 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 { &-info {
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between; @apply flex items-center p-2;
&-bg { &-play {
@apply bg-gray-900 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0; @apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer;
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;
}
}
} }
}
}
.mobile .recommend-singer {
&-list {
height: 180px;
@apply ml-4;
}
&-item {
@apply p-4 rounded-xl;
&-bg {
@apply rounded-xl;
}
}
} }
</style> </style>

View File

@@ -1,19 +1,15 @@
<template> <template>
<div class="recommend-music"> <div class="recommend-music">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')"> <div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
本周最热音乐
</div>
<div <div
v-show="recommendMusic?.result"
v-loading="loading"
class="recommend-music-list" class="recommend-music-list"
:class="setAnimationClass('animate__bounceInUp')" :class="setAnimationClass('animate__bounceInUp')"
v-show="recommendMusic?.result"
> >
<!-- 推荐音乐列表 --> <!-- 推荐音乐列表 -->
<template v-for="(item, index) in recommendMusic?.result" :key="item.id"> <template v-for="(item, index) in recommendMusic?.result" :key="item.id">
<div <div :class="setAnimationClass('animate__bounceInUp')" :style="setAnimationDelay(index, 100)">
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 100)"
>
<song-item :item="item" @play="handlePlay" /> <song-item :item="item" @play="handlePlay" />
</div> </div>
</template> </template>
@@ -22,30 +18,35 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { getRecommendMusic } from '@/api/home' import { useStore } from 'vuex';
import type { IRecommendMusic } from '@/type/music'
import { setAnimationClass, setAnimationDelay } from '@/utils' import { getRecommendMusic } from '@/api/home';
import SongItem from './common/SongItem.vue' import type { IRecommendMusic } from '@/type/music';
import { useStore } from 'vuex' import { setAnimationClass, setAnimationDelay } from '@/utils';
import SongItem from './common/SongItem.vue';
const store = useStore(); const store = useStore();
// 推荐歌曲 // 推荐歌曲
const recommendMusic = ref<IRecommendMusic>() const recommendMusic = ref<IRecommendMusic>();
const loading = ref(false);
// 加载推荐歌曲 // 加载推荐歌曲
const loadRecommendMusic = async () => { const loadRecommendMusic = async () => {
const { data } = await getRecommendMusic({ limit: 10 }) loading.value = true;
recommendMusic.value = data const { data } = await getRecommendMusic({ limit: 10 });
} recommendMusic.value = data;
loading.value = false;
};
// 页面初始化 // 页面初始化
onMounted(() => { onMounted(() => {
loadRecommendMusic() loadRecommendMusic();
}) });
const handlePlay = (item: any) => { const handlePlay = () => {
store.commit('setPlayList', recommendMusic.value?.result) store.commit('setPlayList', recommendMusic.value?.result);
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,42 +1,40 @@
<script lang="ts" setup> <script lang="ts" setup>
import { setAnimationClass, setAnimationDelay } from "@/utils"; import { setAnimationClass } from '@/utils';
const props = defineProps({ const props = defineProps({
showPop: { showPop: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
showClose: { showClose: {
type: Boolean, type: Boolean,
default: true default: true,
}, },
}) });
const musicFullClass = computed(() => { const musicFullClass = computed(() => {
if (props.showPop) { if (props.showPop) {
return setAnimationClass('animate__fadeInUp') return setAnimationClass('animate__fadeInUp');
} else { }
return setAnimationClass('animate__fadeOutDown') return setAnimationClass('animate__fadeOutDown');
} });
}) </script>
</script> <template>
<div v-show="props.showPop" class="pop-page" :class="musicFullClass">
<template> <i v-if="props.showClose" class="iconfont icon-icon_error close"></i>
<div class="pop-page" v-show="props.showPop" :class="musicFullClass"> <img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
<i class="iconfont icon-icon_error close" v-if="props.showClose"></i> <slot></slot>
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" /> </div>
<slot></slot> </template>
</div>
</template> <style lang="scss" scoped>
.pop-page {
<style lang="scss" scoped> height: 800px;
.pop-page { @apply absolute top-4 left-0 w-full;
height: 800px; background-color: #000000f0;
@apply absolute top-4 left-0 w-full; .close {
background-color: #000000f0; @apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
.close { }
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl; }
} </style>
}
</style>

View File

@@ -1,15 +1,22 @@
<template> <template>
<div class="bottom" v-if="isPlay"></div> <div v-if="isPlay" class="bottom" :style="{ height }"></div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStore } from 'vuex'; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.bottom{ .bottom {
@apply h-28; @apply h-28;
} }
</style> </style>

View File

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

View File

@@ -1,70 +1,110 @@
<template> <template>
<div class="search-item" @click="handleClick"> <div class="search-item" :class="item.type" @click="handleClick">
<div class="search-item-img"> <div class="search-item-img">
<n-image <n-image :src="getImgUrl(item.picUrl, '320y180')" lazy preview-disabled />
:src="getImgUrl(item.picUrl, 'album')" <div v-if="item.type === 'mv'" class="play">
lazy <i class="iconfont icon icon-play"></i>
preview-disabled </div>
/>
</div> </div>
<div class="search-item-info"> <div class="search-item-info">
<div class="search-item-name">{{ item.name }}</div> <p class="search-item-name">{{ item.name }}</p>
<div class="search-item-artist">{{ item.desc}}</div> <p class="search-item-artist">{{ item.desc }}</p>
</div> </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"
/>
<PlayVideo v-if="item.type === 'mv'" v-model:show="showPop" :title="item.name" :url="url" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getImgUrl } from '@/utils' import { getAlbum, getListDetail } from '@/api/list';
import type {Album} from '@/type/album' import { getMvUrl } from '@/api/mv';
import { getAlbum } from '@/api/list'; import { getImgUrl } from '@/utils';
const props = defineProps<{ const props = defineProps<{
item: { item: {
picUrl: string picUrl: string;
name: string name: string;
desc: string desc: string;
type: string type: string;
[key: string]: any [key: string]: any;
} };
}>() }>();
const songList = ref([]) const url = ref('');
const showMusic = ref(false) const songList = ref<any[]>([]);
const showPop = ref(false);
const listInfo = ref<any>(null);
const handleClick = async () => { const handleClick = async () => {
showMusic.value = true listInfo.value = null;
if(props.item.type === '专辑'){ if (props.item.type === '专辑') {
const res = await getAlbum(props.item.id) showPop.value = true;
songList.value = res.data.songs.map((song:any)=>{ const res = await getAlbum(props.item.id);
song.al.picUrl = song.al.picUrl || props.item.picUrl songList.value = res.data.songs.map((song: any) => {
return song 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') {
const res = await getMvUrl(props.item.id);
url.value = res.data.data.url;
showPop.value = true;
}
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.search-item {
.search-item{ @apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition cursor-pointer;
@apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition;
margin: 0 10px; margin: 0 10px;
.search-item-img{ .search-item-img {
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden; @apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
} }
.search-item-info{ .search-item-info {
&-name{ @apply flex-1 overflow-hidden;
&-name {
@apply text-white text-sm text-center; @apply text-white text-sm text-center;
} }
&-artist{ &-artist {
@apply text-gray-400 text-xs text-center; @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>

View File

@@ -1,27 +1,24 @@
<template> <template>
<div class="song-item" :class="{'song-mini': mini}"> <div class="song-item" :class="{ 'song-mini': mini }">
<n-image <n-image
v-if="item.picUrl " v-if="item.picUrl"
:src="getImgUrl( item.picUrl, '40y40')" ref="songImg"
:src="getImgUrl(item.picUrl, '40y40')"
class="song-item-img" class="song-item-img"
lazy
preview-disabled preview-disabled
:img-props="{
crossorigin: 'anonymous',
}"
@load="imageLoad"
/> />
<div class="song-item-content"> <div class="song-item-content">
<div class="song-item-content-title"> <div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ <n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
item.name
}}</n-ellipsis>
</div> </div>
<div class="song-item-content-name"> <div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1"> <n-ellipsis class="text-ellipsis" line-clamp="1">
<span <span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
v-for="(artists, artistsindex) in item.song.artists" >{{ artists.name }}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
:key="artistsindex"
>{{ artists.name
}}{{
artistsindex < item.song.artists.length - 1 ? ' / ' : ''
}}</span
> >
</n-ellipsis> </n-ellipsis>
</div> </div>
@@ -31,8 +28,8 @@
<i class="iconfont icon-likefill"></i> <i class="iconfont icon-likefill"></i>
</div> </div>
<div <div
class="song-item-operating-play bg-black" class="song-item-operating-play bg-black animate__animated"
:class="isPlaying ? 'bg-green-600' : ''" :class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
@click="playMusicEvent(item)" @click="playMusicEvent(item)"
> >
<i v-if="isPlaying && play" class="iconfont icon-stop"></i> <i v-if="isPlaying && play" class="iconfont icon-stop"></i>
@@ -43,40 +40,68 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useStore } from 'vuex' import { useTemplateRef } from 'vue';
import type { SongResult } from '@/type/music' import { useStore } from 'vuex';
import { getImgUrl } from '@/utils'
const props = withDefaults(defineProps<{ import type { SongResult } from '@/type/music';
item: SongResult import { getImgUrl } from '@/utils';
mini?: boolean import { getImageBackground } from '@/utils/linearColor';
}>(), {
mini: false
})
const store = useStore() const props = withDefaults(
defineProps<{
item: SongResult;
mini?: boolean;
}>(),
{
mini: false,
},
);
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(() => { 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) => { const playMusicEvent = async (item: SongResult) => {
store.commit('setPlay', item) if (playMusic.value.id === item.id) {
store.commit('setIsPlay', true) if (play.value) {
emits('play', item) store.commit('setPlayMusic', false);
} } else {
store.commit('setPlayMusic', true);
}
return;
}
await store.commit('setPlay', item);
store.commit('setIsPlay', true);
emits('play', item);
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
// 配置文字不可选中 // 配置文字不可选中
.text-ellipsis { .text-ellipsis {
width: 100%; width: 100%;
@@ -115,35 +140,36 @@ const playMusicEvent = (item: any) => {
} }
&-play { &-play {
@apply cursor-pointer border border-gray-500 rounded-full w-10 h-10 flex justify-center items-center hover:bg-green-600 transition; @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; @apply p-2 rounded-2xl;
.song-item{ .song-item {
@apply p-0; @apply p-0;
&-img{ &-img {
@apply w-10 h-10 mr-2; @apply w-10 h-10 mr-2;
} }
&-content{ &-content {
@apply flex-1; @apply flex-1;
&-title{ &-title {
@apply text-sm; @apply text-sm;
} }
&-name{ &-name {
@apply text-xs; @apply text-xs;
} }
} }
&-operating{ &-operating {
@apply pl-2; @apply pl-2;
.iconfont{ .iconfont {
@apply text-base; @apply text-base;
} }
&-like{ &-like {
@apply mr-1; @apply mr-1;
} }
&-play{ &-play {
@apply w-8 h-8; @apply w-8 h-8;
} }
} }

View File

@@ -19,7 +19,7 @@ export const USER_SET_OPTIONS = [
label: '设置', label: '设置',
key: 'set', key: 'set',
}, },
] ];
export const SEARCH_TYPES = [ export const SEARCH_TYPES = [
{ {
@@ -30,36 +30,36 @@ export const SEARCH_TYPES = [
label: '专辑', label: '专辑',
key: 10, key: 10,
}, },
{ // {
label: '歌手', // label: '歌手',
key: 100, // key: 100,
}, // },
{ {
label: '歌单', label: '歌单',
key: 1000, key: 1000,
}, },
{ // {
label: '用户', // label: '用户',
key: 1002, // key: 1002,
}, // },
{ {
label: 'MV', label: 'MV',
key: 1004, key: 1004,
}, },
{ // {
label: '歌词', // label: '歌词',
key: 1006, // key: 1006,
}, // },
{ // {
label: '电台', // label: '电台',
key: 1009, // key: 1009,
}, // },
{ // {
label: '视频', // label: '视频',
key: 1014, // key: 1014,
}, // },
{ // {
label: '综合', // label: '综合',
key: 1018, // key: 1018,
}, // },
] ];

7
src/directive/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { vLoading } from './loading/index';
const directives = {
loading: vLoading,
};
export default directives;

View 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(''));
}
}

View 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
View File

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

View File

@@ -1,39 +1,39 @@
// musicHistoryHooks // musicHistoryHooks
import { RemovableRef, useLocalStorage } from '@vueuse/core' import { useLocalStorage } from '@vueuse/core';
import type { SongResult } from '@/type/music'
import type { SongResult } from '@/type/music';
export const useMusicHistory = () => { export const useMusicHistory = () => {
const musicHistory = useLocalStorage<SongResult[]>('musicHistory', []) const musicHistory = useLocalStorage<SongResult[]>('musicHistory', []);
const addMusic = (music: SongResult) => { 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) { if (index !== -1) {
musicHistory.value[index].count = musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
(musicHistory.value[index].count || 0) + 1 musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0])
} else { } else {
musicHistory.value.unshift({ ...music, count: 1 }) musicHistory.value.unshift({ ...music, count: 1 });
} }
} };
const delMusic = (music: any) => { const delMusic = (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) { if (index !== -1) {
musicHistory.value.splice(index, 1) musicHistory.value.splice(index, 1);
} }
} };
const musicList = ref(musicHistory.value) const musicList = ref(musicHistory.value);
watch( watch(
() => musicHistory.value, () => musicHistory.value,
() => { () => {
musicList.value = musicHistory.value musicList.value = musicHistory.value;
} },
) );
return { return {
musicHistory, musicHistory,
musicList, musicList,
addMusic, addMusic,
delMusic, delMusic,
} };
} };

View File

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

175
src/hooks/MusicListHook.ts Normal file
View File

@@ -0,0 +1,175 @@
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
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) => {
if (playMusic.playMusicUrl) {
return playMusic;
}
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;
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 audio = new Audio(nextSongUrl);
audio.preload = 'auto'; // 设置预加载
audio.load(); // 手动加载
};
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;
};
return {
handlePlayMusic,
nextPlay,
prevPlay,
};
};

View File

@@ -8,4 +8,8 @@
.n-image img { .n-image img {
background-color: #111111; background-color: #111111;
width: 100%; width: 100%;
} }
.text-el {
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
}

View File

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

View File

@@ -8,13 +8,9 @@
</div> </div>
</div> </div>
<div class="app-menu-list"> <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"> <router-link class="app-menu-item-link" :to="item.path">
<i <i class="iconfont app-menu-item-icon" :style="iconStyle(index)" :class="item.meta.icon"></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> <span v-if="isText" class="app-menu-item-text ml-3">{{ item.meta.title }}</span>
</router-link> </router-link>
</div> </div>
@@ -24,44 +20,47 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router';
const props = defineProps({ const props = defineProps({
isText: { isText: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
size: { size: {
type: String, type: String,
default: '26px' default: '26px',
}, },
color: { color: {
type: String, type: String,
default: '#aaa' default: '#aaa',
}, },
selectColor: { selectColor: {
type: String, type: String,
default: '#10B981' default: '#10B981',
}, },
menus: { menus: {
type: Array as any, type: Array as any,
default: [] default: () => [],
} },
}) });
const route = useRoute(); const route = useRoute();
const path = ref(route.path); const path = ref(route.path);
watch(() => route.path, async newParams => { watch(
path.value = newParams () => route.path,
}) async (newParams) => {
path.value = newParams;
},
);
const iconStyle = (index: any) => { const iconStyle = (index: number) => {
let style = { const style = {
fontSize: props.size, fontSize: props.size,
color: path.value === props.menus[index].path ? props.selectColor : props.color color: path.value === props.menus[index].path ? props.selectColor : props.color,
} };
return style return style;
} };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -83,4 +82,25 @@ const iconStyle = (index: any) => {
transform: scale(1.05); transform: scale(1.05);
transition: 0.2s ease-in-out; 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>

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,6 @@
<button @click="minimize"> <button @click="minimize">
<i class="iconfont icon-minisize"></i> <i class="iconfont icon-minisize"></i>
</button> </button>
<!-- <button @click="maximize">
<i class="iconfont icon-maxsize"></i>
</button> -->
<button @click="close"> <button @click="close">
<i class="iconfont icon-close"></i> <i class="iconfont icon-close"></i>
</button> </button>
@@ -16,18 +13,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useDialog } from 'naive-ui' import { useDialog } from 'naive-ui';
const dialog = useDialog() const dialog = useDialog();
const windowData = window as any const windowData = window as any;
const minimize = () => { const minimize = () => {
windowData.electronAPI.minimize() windowData.electronAPI.minimize();
} };
const maximize = () => {
windowData.electronAPI.maximize()
}
const close = () => { const close = () => {
dialog.warning({ dialog.warning({
@@ -36,17 +29,17 @@ const close = () => {
positiveText: '最小化', positiveText: '最小化',
negativeText: '关闭', negativeText: '关闭',
onPositiveClick: () => { onPositiveClick: () => {
windowData.electronAPI.miniTray() windowData.electronAPI.miniTray();
}, },
onNegativeClick: () => { onNegativeClick: () => {
windowData.electronAPI.close() windowData.electronAPI.close();
} },
}) });
} };
const drag = (event: MouseEvent) => { const drag = (event: MouseEvent) => {
windowData.electronAPI.dragStart(event) windowData.electronAPI.dragStart(event);
} };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,5 +1,5 @@
import AppMenu from "./AppMenu.vue"; import AppMenu from './AppMenu.vue';
import PlayBar from "./PlayBar.vue"; import PlayBar from './PlayBar.vue';
import SearchBar from "./SearchBar.vue"; import SearchBar from './SearchBar.vue';
export { AppMenu, PlayBar, SearchBar }; export { AppMenu, PlayBar, SearchBar };

View File

@@ -5,20 +5,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps({ defineProps({
lrcList: { lrcList: {
type: Array, type: Array,
default: () => [] default: () => [],
}, },
lrcIndex: { lrcIndex: {
type: Number, type: Number,
default: 0 default: 0,
}, },
lrcTime: { lrcTime: {
type: Number, type: Number,
default: 0 default: 0,
}, },
}) });
</script> </script>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,19 +1,22 @@
import { createApp } from "vue"; import 'vfonts/Lato.css';
import App from "./App.vue"; import 'vfonts/FiraCode.css';
// tailwind css
import naive from "naive-ui"; import './index.css';
import "vfonts/Lato.css"; import 'remixicon/fonts/remixicon.css';
import "vfonts/FiraCode.css";
import { createApp } from 'vue';
// tailwind css
import "./index.css"; import router from '@/router';
import store from '@/store';
import router from "@/router";
import App from './App.vue';
import store from "@/store"; import directives from './directive';
const app = createApp(App); const app = createApp(App);
app.use(router);
app.use(store); Object.keys(directives).forEach((key: string) => {
// app.use(naive); app.directive(key, directives[key]);
app.mount("#app"); });
app.use(router);
app.use(store);
app.mount('#app');

View File

@@ -5,6 +5,7 @@ const layoutRouter = [
meta: { meta: {
title: '首页', title: '首页',
icon: 'icon-Home', icon: 'icon-Home',
keepAlive: true,
}, },
component: () => import('@/views/home/index.vue'), component: () => import('@/views/home/index.vue'),
}, },
@@ -14,8 +15,8 @@ const layoutRouter = [
meta: { meta: {
title: '搜索', title: '搜索',
noScroll: true, noScroll: true,
noKeepAlive: true,
icon: 'icon-Search', icon: 'icon-Search',
keepAlive: true,
}, },
component: () => import('@/views/search/index.vue'), component: () => import('@/views/search/index.vue'),
}, },
@@ -25,6 +26,7 @@ const layoutRouter = [
meta: { meta: {
title: '歌单', title: '歌单',
icon: 'icon-Paper', icon: 'icon-Paper',
keepAlive: true,
}, },
component: () => import('@/views/list/index.vue'), component: () => import('@/views/list/index.vue'),
}, },
@@ -34,6 +36,7 @@ const layoutRouter = [
meta: { meta: {
title: 'MV', title: 'MV',
icon: 'icon-recordfill', icon: 'icon-recordfill',
keepAlive: true,
}, },
component: () => import('@/views/mv/index.vue'), component: () => import('@/views/mv/index.vue'),
}, },
@@ -43,6 +46,7 @@ const layoutRouter = [
meta: { meta: {
title: '历史', title: '历史',
icon: 'icon-a-TicketStar', icon: 'icon-a-TicketStar',
keepAlive: true,
}, },
component: () => import('@/views/history/index.vue'), component: () => import('@/views/history/index.vue'),
}, },
@@ -51,11 +55,11 @@ const layoutRouter = [
name: 'user', name: 'user',
meta: { meta: {
title: '用户', title: '用户',
noKeepAlive: true,
icon: 'icon-Profile', icon: 'icon-Profile',
keepAlive: true,
noScroll: true, noScroll: true,
}, },
component: () => import('@/views/user/index.vue'), component: () => import('@/views/user/index.vue'),
}, },
] ];
export default layoutRouter; export default layoutRouter;

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router';
import AppLayout from '@/layout/AppLayout.vue'
import homeRouter from '@/router/home' import AppLayout from '@/layout/AppLayout.vue';
import homeRouter from '@/router/home';
const loginRouter = { const loginRouter = {
path: '/login', path: '/login',
@@ -11,7 +12,7 @@ const loginRouter = {
icon: 'icon-Home', icon: 'icon-Home',
}, },
component: () => import('@/views/login/index.vue'), component: () => import('@/views/login/index.vue'),
} };
const setRouter = { const setRouter = {
path: '/set', path: '/set',
@@ -22,7 +23,7 @@ const setRouter = {
icon: 'icon-Home', icon: 'icon-Home',
}, },
component: () => import('@/views/set/index.vue'), component: () => import('@/views/set/index.vue'),
} };
const routes = [ const routes = [
{ {
@@ -30,9 +31,13 @@ const routes = [
component: AppLayout, component: AppLayout,
children: [...homeRouter, loginRouter, setRouter], children: [...homeRouter, loginRouter, setRouter],
}, },
] {
path: '/lyric',
component: () => import('@/views/lyric/index.vue'),
},
];
export default createRouter({ export default createRouter({
routes: routes, routes,
history: createWebHashHistory(), history: createWebHashHistory(),
}) });

11
src/shims-vue.d.ts vendored
View File

@@ -1,5 +1,6 @@
declare module '*.vue' { declare module '*.vue' {
import { DefineComponent } from 'vue' import { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>
export default component const component: DefineComponent<{}, {}, any>;
} export default component;
}

View File

@@ -1,20 +1,23 @@
import { createStore } from 'vuex' import { createStore } from 'vuex';
import { SongResult } from '@/type/music'
import { getMusicUrl, getParsingMusicUrl } from '@/api/music' import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home' import homeRouter from '@/router/home';
import { getMusicProxyUrl } from '@/utils' import type { SongResult } from '@/type/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook'
interface State { interface State {
menus: any[] menus: any[];
play: boolean play: boolean;
isPlay: boolean isPlay: boolean;
playMusic: SongResult playMusic: SongResult;
playMusicUrl: string playMusicUrl: string;
user: any user: any;
playList: SongResult[] playList: SongResult[];
playListIndex: number playListIndex: number;
setData: any setData: any;
lyric: any;
isMobile: boolean;
searchValue: string;
searchType: number;
} }
const state: State = { const state: State = {
@@ -23,89 +26,51 @@ const state: State = {
isPlay: false, isPlay: false,
playMusic: {} as SongResult, playMusic: {} as SongResult,
playMusicUrl: '', playMusicUrl: '',
user: null, user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') as string) : null,
playList: [], playList: [],
playListIndex: 0, playListIndex: 0,
setData: null, setData: null,
} lyric: {},
isMobile: false,
searchValue: '',
searchType: 1,
};
const windowData = window as any;
const windowData = window as any const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
const musicHistory = useMusicHistory()
const mutations = { const mutations = {
setMenus(state: State, menus: any[]) { setMenus(state: State, menus: any[]) {
state.menus = menus state.menus = menus;
}, },
async setPlay(state: State, playMusic: SongResult) { async setPlay(state: State, playMusic: SongResult) {
state.playMusic = playMusic await handlePlayMusic(state, playMusic);
state.playMusicUrl = await getSongUrl(playMusic.id)
state.play = true
musicHistory.addMusic(playMusic)
}, },
setIsPlay(state: State, isPlay: boolean) { setIsPlay(state: State, isPlay: boolean) {
state.isPlay = isPlay state.isPlay = isPlay;
}, },
setPlayMusic(state: State, play: boolean) { setPlayMusic(state: State, play: boolean) {
state.play = play state.play = play;
}, },
setPlayList(state: State, playList: SongResult[]) { setPlayList(state: State, playList: SongResult[]) {
state.playListIndex = playList.findIndex( state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
(item) => item.id === state.playMusic.id state.playList = playList;
)
state.playList = playList
}, },
async nextPlay(state: State) { async nextPlay(state: State) {
if (state.playList.length === 0) { await nextPlay(state);
state.play = true
return
}
state.playListIndex = (state.playListIndex + 1) % state.playList.length
await updatePlayMusic(state)
}, },
async prevPlay(state: State) { async prevPlay(state: State) {
if (state.playList.length === 0) { await prevPlay(state);
state.play = true
return
}
state.playListIndex =
(state.playListIndex - 1 + state.playList.length) % state.playList.length
await updatePlayMusic(state)
}, },
async setSetData(state: State, setData: any) { async setSetData(state: State, setData: any) {
state.setData = setData state.setData = setData;
windowData.electron.ipcRenderer.setStoreValue( window.electron && window.electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
'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
}
} catch (error) {
console.error('error', error)
}
url = url ? url : data.data[0].url
return getMusicProxyUrl(url)
}
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 store = createStore({ const store = createStore({
state: state, state,
mutations: mutations, mutations,
}) });
export default store export default store;

View File

@@ -4,30 +4,30 @@ export interface IAlbumNew {
} }
export interface Album { export interface Album {
name: string name: string;
id: number id: number;
type: string type: string;
size: number size: number;
picId: number picId: number;
blurPicUrl: string blurPicUrl: string;
companyId: number companyId: number;
pic: number pic: number;
picUrl: string picUrl: string;
publishTime: number publishTime: number;
description: string description: string;
tags: string tags: string;
company: string company: string;
briefDesc: string briefDesc: string;
artist: Artist artist: Artist;
songs?: any songs?: any;
alias: string[] alias: string[];
status: number status: number;
copyrightId: number copyrightId: number;
commentThreadId: string commentThreadId: string;
artists: Artist2[] artists: Artist2[];
paid: boolean paid: boolean;
onSale: boolean onSale: boolean;
picId_str: string picId_str: string;
} }
interface Artist2 { interface Artist2 {

168
src/type/day_recommend.ts Normal file
View 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[];
}

View File

@@ -1,4 +1,5 @@
export interface IData<T> { export interface IData<T> {
code: number code: number;
data: T data: T;
result: T;
} }

View File

@@ -7,42 +7,42 @@ export interface IList {
} }
export interface Playlist { export interface Playlist {
name: string name: string;
id: number id: number;
trackNumberUpdateTime: number trackNumberUpdateTime: number;
status: number status: number;
userId: number userId: number;
createTime: number createTime: number;
updateTime: number updateTime: number;
subscribedCount: number subscribedCount: number;
trackCount: number trackCount: number;
cloudTrackCount: number cloudTrackCount: number;
coverImgUrl: string coverImgUrl: string;
coverImgId: number coverImgId: number;
description: string description: string;
tags: string[] tags: string[];
playCount: number playCount: number;
trackUpdateTime: number trackUpdateTime: number;
specialType: number specialType: number;
totalDuration: number totalDuration: number;
creator: Creator creator: Creator;
tracks?: any tracks?: any;
subscribers: Subscriber[] subscribers: Subscriber[];
subscribed: boolean subscribed: boolean;
commentThreadId: string commentThreadId: string;
newImported: boolean newImported: boolean;
adType: number adType: number;
highQuality: boolean highQuality: boolean;
privacy: number privacy: number;
ordered: boolean ordered: boolean;
anonimous: boolean anonimous: boolean;
coverStatus: number coverStatus: number;
recommendInfo?: any recommendInfo?: any;
shareCount: number shareCount: number;
coverImgId_str?: string coverImgId_str?: string;
commentCount: number commentCount: number;
copywriter: string copywriter: string;
tag: string tag: string;
} }
interface Subscriber { interface Subscriber {
@@ -120,8 +120,8 @@ interface AvatarDetail {
} }
interface Expert { interface Expert {
"2": string; '2': string;
"1"?: string; '1'?: string;
} }
// 推荐歌单 // 推荐歌单

View File

@@ -1,203 +1,203 @@
export interface IListDetail { export interface IListDetail {
code: number; code: number;
relatedVideos?: any; relatedVideos?: any;
playlist: Playlist; playlist: Playlist;
urls?: any; urls?: any;
privileges: Privilege[]; privileges: Privilege[];
sharedPrivilege?: any; sharedPrivilege?: any;
resEntrance?: any; resEntrance?: any;
} }
interface Privilege { interface Privilege {
id: number; id: number;
fee: number; fee: number;
payed: number; payed: number;
realPayed: number; realPayed: number;
st: number; st: number;
pl: number; pl: number;
dl: number; dl: number;
sp: number; sp: number;
cp: number; cp: number;
subp: number; subp: number;
cs: boolean; cs: boolean;
maxbr: number; maxbr: number;
fl: number; fl: number;
pc?: any; pc?: any;
toast: boolean; toast: boolean;
flag: number; flag: number;
paidBigBang: boolean; paidBigBang: boolean;
preSell: boolean; preSell: boolean;
playMaxbr: number; playMaxbr: number;
downloadMaxbr: number; downloadMaxbr: number;
rscl?: any; rscl?: any;
freeTrialPrivilege: FreeTrialPrivilege; freeTrialPrivilege: FreeTrialPrivilege;
chargeInfoList: ChargeInfoList[]; chargeInfoList: ChargeInfoList[];
} }
interface ChargeInfoList { interface ChargeInfoList {
rate: number; rate: number;
chargeUrl?: any; chargeUrl?: any;
chargeMessage?: any; chargeMessage?: any;
chargeType: number; chargeType: number;
} }
interface FreeTrialPrivilege { interface FreeTrialPrivilege {
resConsumable: boolean; resConsumable: boolean;
userConsumable: boolean; userConsumable: boolean;
} }
export interface Playlist { export interface Playlist {
id: number id: number;
name: string name: string;
coverImgId: number coverImgId: number;
coverImgUrl: string coverImgUrl: string;
coverImgId_str: string coverImgId_str: string;
adType: number adType: number;
userId: number userId: number;
createTime: number createTime: number;
status: number status: number;
opRecommend: boolean opRecommend: boolean;
highQuality: boolean highQuality: boolean;
newImported: boolean newImported: boolean;
updateTime: number updateTime: number;
trackCount: number trackCount: number;
specialType: number specialType: number;
privacy: number privacy: number;
trackUpdateTime: number trackUpdateTime: number;
commentThreadId: string commentThreadId: string;
playCount: number playCount: number;
trackNumberUpdateTime: number trackNumberUpdateTime: number;
subscribedCount: number subscribedCount: number;
cloudTrackCount: number cloudTrackCount: number;
ordered: boolean ordered: boolean;
description: string description: string;
tags: string[] tags: string[];
updateFrequency?: any updateFrequency?: any;
backgroundCoverId: number backgroundCoverId: number;
backgroundCoverUrl?: any backgroundCoverUrl?: any;
titleImage: number titleImage: number;
titleImageUrl?: any titleImageUrl?: any;
englishTitle?: any englishTitle?: any;
officialPlaylistType?: any officialPlaylistType?: any;
subscribers: Subscriber[] subscribers: Subscriber[];
subscribed: boolean subscribed: boolean;
creator: Subscriber creator: Subscriber;
tracks: Track[] tracks: Track[];
videoIds?: any videoIds?: any;
videos?: any videos?: any;
trackIds: TrackId[] trackIds: TrackId[];
shareCount: number shareCount: number;
commentCount: number commentCount: number;
remixVideo?: any remixVideo?: any;
sharedUsers?: any sharedUsers?: any;
historySharedUsers?: any historySharedUsers?: any;
} }
interface TrackId { interface TrackId {
id: number; id: number;
v: number; v: number;
t: number; t: number;
at: number; at: number;
alg?: any; alg?: any;
uid: number; uid: number;
rcmdReason: string; rcmdReason: string;
} }
interface Track { interface Track {
name: string; name: string;
id: number; id: number;
pst: number; pst: number;
t: number; t: number;
ar: Ar[]; ar: Ar[];
alia: string[]; alia: string[];
pop: number; pop: number;
st: number; st: number;
rt?: string; rt?: string;
fee: number; fee: number;
v: number; v: number;
crbt?: any; crbt?: any;
cf: string; cf: string;
al: Al; al: Al;
dt: number; dt: number;
h: H; h: H;
m: H; m: H;
l?: H; l?: H;
a?: any; a?: any;
cd: string; cd: string;
no: number; no: number;
rtUrl?: any; rtUrl?: any;
ftype: number; ftype: number;
rtUrls: any[]; rtUrls: any[];
djId: number; djId: number;
copyright: number; copyright: number;
s_id: number; s_id: number;
mark: number; mark: number;
originCoverType: number; originCoverType: number;
originSongSimpleData?: any; originSongSimpleData?: any;
single: number; single: number;
noCopyrightRcmd?: any; noCopyrightRcmd?: any;
mst: number; mst: number;
cp: number; cp: number;
mv: number; mv: number;
rtype: number; rtype: number;
rurl?: any; rurl?: any;
publishTime: number; publishTime: number;
tns?: string[]; tns?: string[];
} }
interface H { interface H {
br: number; br: number;
fid: number; fid: number;
size: number; size: number;
vd: number; vd: number;
} }
interface Al { interface Al {
id: number; id: number;
name: string; name: string;
picUrl: string; picUrl: string;
tns: any[]; tns: any[];
pic_str?: string; pic_str?: string;
pic: number; pic: number;
} }
interface Ar { interface Ar {
id: number; id: number;
name: string; name: string;
tns: any[]; tns: any[];
alias: any[]; alias: any[];
} }
interface Subscriber { interface Subscriber {
defaultAvatar: boolean; defaultAvatar: boolean;
province: number; province: number;
authStatus: number; authStatus: number;
followed: boolean; followed: boolean;
avatarUrl: string; avatarUrl: string;
accountStatus: number; accountStatus: number;
gender: number; gender: number;
city: number; city: number;
birthday: number; birthday: number;
userId: number; userId: number;
userType: number; userType: number;
nickname: string; nickname: string;
signature: string; signature: string;
description: string; description: string;
detailDescription: string; detailDescription: string;
avatarImgId: number; avatarImgId: number;
backgroundImgId: number; backgroundImgId: number;
backgroundUrl: string; backgroundUrl: string;
authority: number; authority: number;
mutual: boolean; mutual: boolean;
expertTags?: any; expertTags?: any;
experts?: any; experts?: any;
djStatus: number; djStatus: number;
vipType: number; vipType: number;
remarkName?: any; remarkName?: any;
authenticationTypes: number; authenticationTypes: number;
avatarDetail?: any; avatarDetail?: any;
backgroundImgIdStr: string; backgroundImgIdStr: string;
anchor: boolean; anchor: boolean;
avatarImgIdStr: string; avatarImgIdStr: string;
avatarImgId_str: string; avatarImgId_str: string;
} }

View File

@@ -1,14 +1,14 @@
export interface ILyric { export interface ILyric {
sgc: boolean; sgc: boolean;
sfy: boolean; sfy: boolean;
qfy: boolean; qfy: boolean;
lrc: Lrc; lrc: Lrc;
klyric: Lrc; klyric: Lrc;
tlyric: Lrc; tlyric: Lrc;
code: number; code: number;
} }
interface Lrc { interface Lrc {
version: number; version: number;
lyric: string; lyric: string;
} }

View File

@@ -1,197 +1,215 @@
export interface IRecommendMusic { export interface IRecommendMusic {
code: number; code: number;
category: number; category: number;
result: SongResult[]; result: SongResult[];
} }
export interface ILyricText {
export interface SongResult { text: string;
id: number trText: string;
type: number }
name: string export interface ILyric {
copywriter?: any lrcTimeArray: number[];
picUrl: string lrcArray: ILyricText[];
canDislike: boolean }
trackNumberUpdateTime?: any
song: Song export interface SongResult {
alg: string id: number;
count?: number type: number;
} name: string;
copywriter?: any;
interface Song { picUrl: string;
name: string; canDislike: boolean;
id: number; trackNumberUpdateTime?: any;
position: number; song: Song;
alias: string[]; alg: string;
status: number; count?: number;
fee: number; playLoading?: boolean;
copyrightId: number; ar?: Artist[];
disc: string; al?: Album;
no: number; backgroundColor?: string;
artists: Artist[]; primaryColor?: string;
album: Album; playMusicUrl?: string;
starred: boolean; lyric?: ILyric;
popularity: number; }
score: number;
starredNum: number; export interface Song {
duration: number; name: string;
playedNum: number; id: number;
dayPlays: number; position: number;
hearTime: number; alias: string[];
ringtone: string; status: number;
crbt?: any; fee: number;
audition?: any; copyrightId: number;
copyFrom: string; disc: string;
commentThreadId: string; no: number;
rtUrl?: any; artists: Artist[];
ftype: number; album: Album;
rtUrls: any[]; starred: boolean;
copyright: number; popularity: number;
transName?: any; score: number;
sign?: any; starredNum: number;
mark: number; duration: number;
originCoverType: number; playedNum: number;
originSongSimpleData?: any; dayPlays: number;
single: number; hearTime: number;
noCopyrightRcmd?: any; ringtone: string;
rtype: number; crbt?: any;
rurl?: any; audition?: any;
mvid: number; copyFrom: string;
bMusic: BMusic; commentThreadId: string;
mp3Url?: any; rtUrl?: any;
hMusic: BMusic; ftype: number;
mMusic: BMusic; rtUrls: any[];
lMusic: BMusic; copyright: number;
exclusive: boolean; transName?: any;
privilege: Privilege; sign?: any;
} mark: number;
originCoverType: number;
interface Privilege { originSongSimpleData?: any;
id: number; single: number;
fee: number; noCopyrightRcmd?: any;
payed: number; rtype: number;
st: number; rurl?: any;
pl: number; mvid: number;
dl: number; bMusic: BMusic;
sp: number; mp3Url?: any;
cp: number; hMusic: BMusic;
subp: number; mMusic: BMusic;
cs: boolean; lMusic: BMusic;
maxbr: number; exclusive: boolean;
fl: number; privilege: Privilege;
toast: boolean; count?: number;
flag: number; playLoading?: boolean;
preSell: boolean; picUrl?: string;
playMaxbr: number; }
downloadMaxbr: number;
rscl?: any; interface Privilege {
freeTrialPrivilege: FreeTrialPrivilege; id: number;
chargeInfoList: ChargeInfoList[]; fee: number;
} payed: number;
st: number;
interface ChargeInfoList { pl: number;
rate: number; dl: number;
chargeUrl?: any; sp: number;
chargeMessage?: any; cp: number;
chargeType: number; subp: number;
} cs: boolean;
maxbr: number;
interface FreeTrialPrivilege { fl: number;
resConsumable: boolean; toast: boolean;
userConsumable: boolean; flag: number;
} preSell: boolean;
playMaxbr: number;
interface BMusic { downloadMaxbr: number;
name?: any; rscl?: any;
id: number; freeTrialPrivilege: FreeTrialPrivilege;
size: number; chargeInfoList: ChargeInfoList[];
extension: string; }
sr: number;
dfsId: number; interface ChargeInfoList {
bitrate: number; rate: number;
playTime: number; chargeUrl?: any;
volumeDelta: number; chargeMessage?: any;
} chargeType: number;
}
interface Album {
name: string; interface FreeTrialPrivilege {
id: number; resConsumable: boolean;
type: string; userConsumable: boolean;
size: number; }
picId: number;
blurPicUrl: string; interface BMusic {
companyId: number; name?: any;
pic: number; id: number;
picUrl: string; size: number;
publishTime: number; extension: string;
description: string; sr: number;
tags: string; dfsId: number;
company: string; bitrate: number;
briefDesc: string; playTime: number;
artist: Artist; volumeDelta: number;
songs: any[]; }
alias: string[];
status: number; interface Album {
copyrightId: number; name: string;
commentThreadId: string; id: number;
artists: Artist[]; type: string;
subType: string; size: number;
transName?: any; picId: number;
onSale: boolean; blurPicUrl: string;
mark: number; companyId: number;
picId_str: string; pic: number;
} picUrl: string;
publishTime: number;
interface Artist { description: string;
name: string; tags: string;
id: number; company: string;
picId: number; briefDesc: string;
img1v1Id: number; artist: Artist;
briefDesc: string; songs: any[];
picUrl: string; alias: string[];
img1v1Url: string; status: number;
albumSize: number; copyrightId: number;
alias: any[]; commentThreadId: string;
trans: string; artists: Artist[];
musicSize: number; subType: string;
topicPerson: number; transName?: any;
} onSale: boolean;
mark: number;
export interface IPlayMusicUrl { picId_str: string;
data: Datum[]; }
code: number;
} interface Artist {
name: string;
interface Datum { id: number;
id: number; picId: number;
url: string; img1v1Id: number;
br: number; briefDesc: string;
size: number; picUrl: string;
md5: string; img1v1Url: string;
code: number; albumSize: number;
expi: number; alias: any[];
type: string; trans: string;
gain: number; musicSize: number;
fee: number; topicPerson: number;
uf?: any; }
payed: number;
flag: number; export interface IPlayMusicUrl {
canExtend: boolean; data: Datum[];
freeTrialInfo?: any; code: number;
level: string; }
encodeType: string;
freeTrialPrivilege: FreeTrialPrivilege; interface Datum {
freeTimeTrialPrivilege: FreeTimeTrialPrivilege; id: number;
urlSource: number; url: string;
} br: number;
size: number;
interface FreeTimeTrialPrivilege { md5: string;
resConsumable: boolean; code: number;
userConsumable: boolean; expi: number;
type: number; type: string;
remainTime: number; gain: number;
} fee: number;
uf?: any;
interface FreeTrialPrivilege { payed: number;
resConsumable: boolean; flag: number;
userConsumable: boolean; canExtend: boolean;
} freeTrialInfo?: any;
level: string;
encodeType: string;
freeTrialPrivilege: FreeTrialPrivilege;
freeTimeTrialPrivilege: FreeTimeTrialPrivilege;
urlSource: number;
}
interface FreeTimeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
type: number;
remainTime: number;
}
interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
}

View File

@@ -1,84 +1,84 @@
export interface IMvItem { export interface IMvItem {
id: number id: number;
cover: string cover: string;
name: string name: string;
playCount: number playCount: number;
briefDesc?: any briefDesc?: any;
desc?: any desc?: any;
artistName: string artistName: string;
artistId: number artistId: number;
duration: number duration: number;
mark: number mark: number;
mv: IMvData mv: IMvData;
lastRank: number lastRank: number;
score: number score: number;
subed: boolean subed: boolean;
artists: Artist[] artists: Artist[];
transNames?: string[] transNames?: string[];
alias?: string[] alias?: string[];
} }
export interface IMvData { export interface IMvData {
authId: number authId: number;
status: number status: number;
id: number id: number;
title: string title: string;
subTitle: string subTitle: string;
appTitle: string appTitle: string;
aliaName: string aliaName: string;
transName: string transName: string;
pic4v3: number pic4v3: number;
pic16v9: number pic16v9: number;
caption: number caption: number;
captionLanguage: string captionLanguage: string;
style?: any style?: any;
mottos: string mottos: string;
oneword?: any oneword?: any;
appword: string appword: string;
stars?: any stars?: any;
desc: string desc: string;
area: string area: string;
type: string type: string;
subType: string subType: string;
neteaseonly: number neteaseonly: number;
upban: number upban: number;
topWeeks: string topWeeks: string;
publishTime: string publishTime: string;
online: number online: number;
score: number score: number;
plays: number plays: number;
monthplays: number monthplays: number;
weekplays: number weekplays: number;
dayplays: number dayplays: number;
fee: number fee: number;
artists: Artist[] artists: Artist[];
videos: Video[] videos: Video[];
} }
interface Video { interface Video {
tagSign: TagSign tagSign: TagSign;
tag: string tag: string;
url: string url: string;
duration: number duration: number;
size: number size: number;
width: number width: number;
height: number height: number;
container: string container: string;
md5: string md5: string;
check: boolean check: boolean;
} }
interface TagSign { interface TagSign {
br: number br: number;
type: string type: string;
tagSign: string tagSign: string;
resolution: number resolution: number;
mvtype: string mvtype: string;
} }
interface Artist { interface Artist {
id: number id: number;
name: string name: string;
} }
// { // {
@@ -97,16 +97,16 @@ interface Artist {
// } // }
export interface IMvUrlData { export interface IMvUrlData {
id: number id: number;
url: string url: string;
r: number r: number;
size: number size: number;
md5: string md5: string;
code: number code: number;
expi: number expi: number;
fee: number fee: number;
mvFee: number mvFee: number;
st: number st: number;
promotionVo: null | any promotionVo: null | any;
msg: string msg: string;
} }

View File

@@ -6,11 +6,11 @@ export interface IPlayListSort {
} }
interface SortCategories { interface SortCategories {
"0": string; '0': string;
"1": string; '1': string;
"2": string; '2': string;
"3": string; '3': string;
"4": string; '4': string;
} }
interface SortAll { interface SortAll {

View File

@@ -540,7 +540,7 @@ interface Song2 {
} }
interface KsongInfos { interface KsongInfos {
"347230": _347230; '347230': _347230;
} }
interface _347230 { interface _347230 {

View File

@@ -1,64 +1,86 @@
import { computed } from 'vue';
import store from '@/store';
// 设置歌手背景图片 // 设置歌手背景图片
export const setBackgroundImg = (url: String) => { export const setBackgroundImg = (url: String) => {
return 'background-image:' + 'url(' + url + ')' return `background-image:url(${url})`;
} };
// 设置动画类型 // 设置动画类型
export const setAnimationClass = (type: String) => { export const setAnimationClass = (type: String) => {
return 'animate__animated ' + type return `animate__animated ${type}`;
} };
// 设置动画延时 // 设置动画延时
export const setAnimationDelay = (index: number = 6, time: number = 50) => { export const setAnimationDelay = (index: number = 6, time: number = 50) => {
return 'animation-delay:' + index * time + 'ms' return `animation-delay:${index * time}ms`;
} };
//将秒转换为分钟和秒 // 将秒转换为分钟和秒
export const secondToMinute = (s: number) => { export const secondToMinute = (s: number) => {
if (!s) { if (!s) {
return '00:00' return '00:00';
} }
let minute: number = Math.floor(s / 60) const minute: number = Math.floor(s / 60);
let second: number = Math.floor(s % 60) const second: number = Math.floor(s % 60);
let minuteStr: string = const minuteStr: string = minute > 9 ? minute.toString() : `0${minute.toString()}`;
minute > 9 ? minute.toString() : '0' + minute.toString() const secondStr: string = second > 9 ? second.toString() : `0${second.toString()}`;
let secondStr: string = return `${minuteStr}:${secondStr}`;
second > 9 ? second.toString() : '0' + second.toString() };
return minuteStr + ':' + secondStr
}
// 格式化数字 千,万, 百万, 千万,亿 // 格式化数字 千,万, 百万, 千万,亿
export const formatNumber = (num: any) => { const units = [
num = num * 1 { value: 1e8, symbol: '亿' },
if (num < 10000) { { value: 1e4, symbol: '万' },
return num ];
export const formatNumber = (num: string | number) => {
num = Number(num);
for (let i = 0; i < units.length; i++) {
if (num >= units[i].value) {
return `${(num / units[i].value).toFixed(1)}${units[i].symbol}`;
}
} }
if (num < 100000000) { return num.toString();
return (num / 10000).toFixed(1) + '万' };
}
return (num / 100000000).toFixed(1) + '亿' const windowData = window as any;
}
const windowData = window as any
export const getIsMc = () => { export const getIsMc = () => {
if (!windowData.electron) { if (!windowData.electron) {
return false return false;
} }
if (windowData.electron.ipcRenderer.getStoreValue('set').isProxy) { if (windowData.electron.ipcRenderer.getStoreValue('set').isProxy) {
return true return true;
} }
return false if(window.location.origin.includes('localhost')){}
} return false;
const ProxyUrl = };
import.meta.env.VITE_API_PROXY + '' || 'http://110.42.251.190:9856' const ProxyUrl = import.meta.env.VITE_API_PROXY || 'http://110.42.251.190:9856';
export const getMusicProxyUrl = (url: string) => { export const getMusicProxyUrl = (url: string) => {
if (!getIsMc()) { if (!getIsMc()) {
return url return url;
} }
const PUrl = url.split('').join('+') const PUrl = url.split('').join('+');
return `${ProxyUrl}/mc?url=${PUrl}` return `${ProxyUrl}/mc?url=${PUrl}`;
} };
export const getImgUrl = computed(() => (url: string, size: string = '') => { export const getImgUrl = (url: string | undefined, size: string = '') => {
const bdUrl = 'https://image.baidu.com/search/down?url=' const bdUrl = 'https://image.baidu.com/search/down?url=';
const imgUrl = encodeURIComponent(`${url}?param=${size}`) const imgUrl = `${url}?param=${size}`;
return `${bdUrl}${imgUrl}` if (!getIsMc()) {
}) return imgUrl;
}
return `${bdUrl}${encodeURIComponent(imgUrl)}`;
};
export const isMobile = computed(() => {
const flag = navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i,
);
store.state.isMobile = !!flag;
// 给html标签 添加mobile
if (flag) document.documentElement.classList.add('mobile');
return !!flag;
});

183
src/utils/linearColor.ts Normal file
View File

@@ -0,0 +1,183 @@
interface IColor {
backgroundColor: string;
primaryColor: string;
}
export const getImageLinearBackground = async (imageSrc: string): Promise<IColor> => {
try {
const primaryColor = await getImagePrimaryColor(imageSrc);
return {
backgroundColor: generateGradientBackground(primaryColor),
primaryColor,
};
} catch (error) {
console.error('error', error);
return {
backgroundColor: '',
primaryColor: '',
};
}
};
export const getImageBackground = async (img: HTMLImageElement): Promise<IColor> => {
try {
const primaryColor = await getImageColor(img);
return {
backgroundColor: generateGradientBackground(primaryColor),
primaryColor,
};
} catch (error) {
console.error('error', error);
return {
backgroundColor: '',
primaryColor: '',
};
}
};
const getImageColor = (img: HTMLImageElement): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const color = getAverageColor(imageData.data);
resolve(`rgb(${color.join(',')})`);
});
};
const getImagePrimaryColor = (imageSrc: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = imageSrc;
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const color = getAverageColor(imageData.data);
resolve(`rgb(${color.join(',')})`);
};
img.onerror = () => reject(new Error('图片加载失败'));
});
};
const getAverageColor = (data: Uint8ClampedArray): number[] => {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
};
const generateGradientBackground = (color: string): string => {
const [r, g, b] = color.match(/\d+/g)?.map(Number) || [0, 0, 0];
const [h, s, l] = rgbToHsl(r, g, b);
// 增加亮度和暗度的差异
const lightL = Math.min(l + 0.2, 0.95);
const darkL = Math.max(l - 0.3, 0.05);
const midL = (lightL + darkL) / 2;
// 调整饱和度以增强效果
const lightS = Math.min(s * 0.8, 1);
const darkS = Math.min(s * 1.2, 1);
const [lightR, lightG, lightB] = hslToRgb(h, lightS, lightL);
const [midR, midG, midB] = hslToRgb(h, s, midL);
const [darkR, darkG, darkB] = hslToRgb(h, darkS, darkL);
const lightColor = `rgb(${lightR}, ${lightG}, ${lightB})`;
const midColor = `rgb(${midR}, ${midG}, ${midB})`;
const darkColor = `rgb(${darkR}, ${darkG}, ${darkB})`;
// 使用三个颜色点创建更丰富的渐变
return `linear-gradient(to bottom, ${lightColor} 0%, ${midColor} 50%, ${darkColor} 100%)`;
};
// Helper functions (unchanged)
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s;
const l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
break;
}
h /= 6;
}
return [h, s, l];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r;
let g;
let b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}

View File

@@ -1,8 +1,9 @@
import axios from "axios"; import axios from 'axios';
let baseURL = import.meta.env.VITE_API + "";
const baseURL = `${import.meta.env.VITE_API}`;
const request = axios.create({ const request = axios.create({
baseURL: baseURL, baseURL,
timeout: 10000, timeout: 10000,
}); });
@@ -11,12 +12,12 @@ request.interceptors.request.use(
(config) => { (config) => {
// 在请求发送之前做一些处理 // 在请求发送之前做一些处理
// 在get请求params中添加timestamp // 在get请求params中添加timestamp
if (config.method === "get") { if (config.method === 'get') {
config.params = { config.params = {
...config.params, ...config.params,
timestamp: Date.now(), timestamp: Date.now(),
}; };
let token = localStorage.getItem("token"); const token = localStorage.getItem('token');
if (token) { if (token) {
config.params.cookie = token; config.params.cookie = token;
} }
@@ -27,7 +28,7 @@ request.interceptors.request.use(
(error) => { (error) => {
// 当请求异常时做一些处理 // 当请求异常时做一些处理
return Promise.reject(error); return Promise.reject(error);
} },
); );
export default request; export default request;

View File

@@ -1,19 +0,0 @@
import axios from "axios";
let baseURL = import.meta.env.VITE_API_MT + "";
const request = axios.create({
baseURL: baseURL,
timeout: 10000,
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
return config;
},
(error) => {
// 当请求异常时做一些处理
return Promise.reject(error);
}
);
export default request;

View File

@@ -1,19 +1,20 @@
import axios from "axios" import axios from 'axios';
let baseURL = import.meta.env.VITE_API_MUSIC + ""
const baseURL = `${import.meta.env.VITE_API_MUSIC}`;
const request = axios.create({ const request = axios.create({
baseURL: baseURL, baseURL,
timeout: 10000, timeout: 10000,
}) });
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
return config return config;
}, },
(error) => { (error) => {
// 当请求异常时做一些处理 // 当请求异常时做一些处理
return Promise.reject(error) return Promise.reject(error);
} },
) );
export default request export default request;

View File

@@ -3,9 +3,14 @@
<div class="title">播放历史</div> <div class="title">播放历史</div>
<n-scrollbar :size="100"> <n-scrollbar :size="100">
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')"> <div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div class="history-item" v-for="(item, index) in musicList" :key="item.id" <div
:class="setAnimationClass('animate__bounceIn')" :style="setAnimationDelay(index, 30)"> v-for="(item, index) in musicList"
<song-item class="history-item-content" :item="item" /> :key="item.id"
class="history-item"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index, 30)"
>
<song-item class="history-item-content" :item="item" @play="handlePlay" />
<div class="history-item-count"> <div class="history-item-count">
{{ item.count }} {{ item.count }}
</div> </div>
@@ -19,33 +24,44 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMusicHistory } from '@/hooks/MusicHistoryHook' import { useStore } from 'vuex';
import { setAnimationClass, setAnimationDelay } from "@/utils";
const {delMusic, musicList} = useMusicHistory(); import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'History',
});
const store = useStore();
const { delMusic, musicList } = useMusicHistory();
const handlePlay = () => {
store.commit('setPlayList', musicList.value);
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.history-page{ .history-page {
@apply h-full w-full pt-2; @apply h-full w-full pt-2;
.title{ .title {
@apply text-xl font-bold; @apply pl-4 text-xl font-bold;
} }
.history-list-content{ .history-list-content {
@apply px-4 mt-2; @apply px-4 mt-2 pb-28;
.history-item{ .history-item {
@apply flex items-center justify-between; @apply flex items-center justify-between;
&-content{ &-content {
@apply flex-1; @apply flex-1;
} }
&-count{ &-count {
@apply px-4 text-lg; @apply px-4 text-lg;
} }
&-delete{ &-delete {
@apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center; @apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center;
} }
} }
} }
} }
</style> </style>

View File

@@ -1,30 +1,41 @@
<template> <template>
<div class="main-page"> <n-scrollbar :size="100" :x-scrollable="false">
<div class="main-page">
<!-- 推荐歌手 --> <!-- 推荐歌手 -->
<recommend-singer /> <recommend-singer />
<div class="main-content"> <div class="main-content">
<!-- 歌单分类列表 --> <!-- 歌单分类列表 -->
<playlist-type /> <playlist-type v-if="!isMobile" />
<!-- 本周最热音乐 --> <!-- 本周最热音乐 -->
<recommend-songlist /> <recommend-songlist />
<!-- 推荐最新专辑 --> <!-- 推荐最新专辑 -->
<recommend-album /> <recommend-album />
</div> </div>
</div> </div>
</n-scrollbar>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const RecommendSinger = defineAsyncComponent(() => import("@/components/RecommendSinger.vue")); import { isMobile } from '@/utils';
const PlaylistType = defineAsyncComponent(() => import("@/components/PlaylistType.vue"));
const RecommendSonglist = defineAsyncComponent(() => import("@/components/RecommendSonglist.vue")); const RecommendSinger = defineAsyncComponent(() => import('@/components/RecommendSinger.vue'));
const RecommendAlbum = defineAsyncComponent(() => import("@/components/RecommendAlbum.vue")); const PlaylistType = defineAsyncComponent(() => import('@/components/PlaylistType.vue'));
const RecommendSonglist = defineAsyncComponent(() => import('@/components/RecommendSonglist.vue'));
const RecommendAlbum = defineAsyncComponent(() => import('@/components/RecommendAlbum.vue'));
defineOptions({
name: 'Home',
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.main-page{ .main-page {
@apply h-full w-full; @apply h-full w-full overflow-hidden;
} }
.main-content { .main-content {
@apply mt-6 flex pb-28; @apply mt-6 flex mb-28;
}
.mobile .main-content {
@apply flex-col mx-4;
} }
</style> </style>

View File

@@ -1,75 +1,79 @@
<script lang="ts" setup> <script lang="ts" setup>
import { getRecommendList, getListDetail, getListByCat } from '@/api/list'
import type { IRecommendItem } from "@/type/list";
import type { IListDetail } from "@/type/listDetail";
import { setAnimationClass, setAnimationDelay, getImgUrl, formatNumber } from "@/utils";
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import MusicList from "@/components/MusicList.vue";
import PlayBottom from '@/components/common/PlayBottom.vue';
const recommendList = ref() import { getListByCat, getListDetail, getRecommendList } from '@/api/list';
const showMusic = ref(false) import MusicList from '@/components/MusicList.vue';
import type { IRecommendItem } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
const recommendItem = ref<IRecommendItem>() defineOptions({
const listDetail = ref<IListDetail>() name: 'List',
});
const recommendList = ref();
const showMusic = ref(false);
const recommendItem = ref<IRecommendItem | null>();
const listDetail = ref<IListDetail | null>();
const listLoading = ref(true);
const selectRecommendItem = async (item: IRecommendItem) => { const selectRecommendItem = async (item: IRecommendItem) => {
showMusic.value = true listLoading.value = true;
const { data } = await getListDetail(item.id) recommendItem.value = null;
recommendItem.value = item listDetail.value = null;
listDetail.value = data showMusic.value = true;
} recommendItem.value = item;
const { data } = await getListDetail(item.id);
listDetail.value = data;
listLoading.value = false;
};
const route = useRoute(); const route = useRoute();
const listTitle = ref(route.query.type || "歌单列表"); const listTitle = ref(route.query.type || '歌单列表');
const loadList = async (type: any) => { const loading = ref(false);
const loadList = async (type: string) => {
loading.value = true;
const params = { const params = {
cat: type || '', cat: type || '',
limit: 30, limit: 30,
offset: 0 offset: 0,
} };
const { data } = await getListByCat(params); const { data } = await getListByCat(params);
recommendList.value = data.playlists recommendList.value = data.playlists;
} loading.value = false;
};
if (route.query.type) { if (route.query.type) {
loadList(route.query.type) loadList(route.query.type as string);
} else { } else {
getRecommendList().then((res: { data: { result: any; }; }) => { getRecommendList().then((res: { data: { result: any } }) => {
recommendList.value = res.data.result recommendList.value = res.data.result;
}) });
} }
watch( watch(
() => route.query, () => route.query,
async newParams => { async (newParams) => {
if(newParams.type){ if (newParams.type) {
const params = { recommendList.value = null;
tag: newParams.type || '', listTitle.value = newParams.type || '歌单列表';
limit: 30, loadList(newParams.type as string);
before: 0
}
loadList(newParams.type);
} }
} },
) );
</script> </script>
<template> <template>
<div class="list-page"> <div class="list-page">
<div <div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
class="recommend-title"
:class="setAnimationClass('animate__bounceInLeft')"
>{{ listTitle }}</div>
<!-- 歌单列表 --> <!-- 歌单列表 -->
<n-scrollbar class="recommend" @click="showMusic = false" :size="100"> <n-scrollbar class="recommend" :size="100" @click="showMusic = false">
<div class="recommend-list" v-if="recommendList"> <div v-loading="loading" class="recommend-list">
<div <div
v-for="(item, index) in recommendList"
:key="item.id"
class="recommend-item" class="recommend-item"
v-for="(item,index) in recommendList"
:class="setAnimationClass('animate__bounceIn')" :class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index, 30)" :style="setAnimationDelay(index, 30)"
@click.stop="selectRecommendItem(item)" @click.stop="selectRecommendItem(item)"
@@ -77,7 +81,7 @@ watch(
<div class="recommend-item-img"> <div class="recommend-item-img">
<n-image <n-image
class="recommend-item-img-img" class="recommend-item-img-img"
:src="getImgUrl( (item.picUrl || item.coverImgUrl), '200y200')" :src="getImgUrl(item.picUrl || item.coverImgUrl, '200y200')"
width="200" width="200"
height="200" height="200"
lazy lazy
@@ -91,15 +95,20 @@ watch(
<div class="recommend-item-title">{{ item.name }}</div> <div class="recommend-item-title">{{ item.name }}</div>
</div> </div>
</div> </div>
<PlayBottom/>
</n-scrollbar> </n-scrollbar>
<MusicList v-if="listDetail?.playlist" v-model:show="showMusic" :name="listDetail?.playlist.name" :song-list="listDetail?.playlist.tracks" /> <music-list
v-model:show="showMusic"
v-model:loading="listLoading"
:name="recommendItem?.name || ''"
:song-list="listDetail?.playlist.tracks || []"
:list-info="listDetail?.playlist"
/>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.list-page { .list-page {
@apply relative h-full w-full; @apply relative h-full w-full px-4;
} }
.recommend { .recommend {
@@ -151,4 +160,9 @@ watch(
} }
} }
</style> .mobile {
.recommend-list {
grid-template-columns: repeat(auto-fill, minmax(25%, 1fr));
}
}
</style>

View File

@@ -1,170 +1,194 @@
<script lang="ts" setup> <script lang="ts" setup>
import { getQrKey, createQr, checkQr, getLoginStatus } from '@/api/login' import { useMessage } from 'naive-ui';
import { onMounted } from '@vue/runtime-core'; import { onMounted } from 'vue';
import { getUserDetail, loginByCellphone } from '@/api/login'; import { useRouter } from 'vue-router';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useMessage } from 'naive-ui'
import { setAnimationClass, setAnimationDelay } from "@/utils"; import { checkQr, createQr, getQrKey, getUserDetail, loginByCellphone } from '@/api/login';
import { useRouter } from 'vue-router'; import { isMobile, setAnimationClass } from '@/utils';
defineOptions({
const message = useMessage() name: 'Login',
const store = useStore(); });
const router = useRouter()
const message = useMessage();
const qrUrl = ref<string>() const store = useStore();
onMounted(() => { const router = useRouter();
loadLogin()
}) const qrUrl = ref<string>();
onMounted(() => {
const loadLogin = async () => { loadLogin();
const qrKey = await getQrKey() });
const key = qrKey.data.data.unikey
const { data } = await createQr(key) const timerRef = ref(null);
qrUrl.value = data.data.qrimg
timerIsQr(key) const loadLogin = async () => {
} try {
const qrKey = await getQrKey();
const key = qrKey.data.data.unikey;
const { data } = await createQr(key);
const timerIsQr = (key: string) => { qrUrl.value = data.data.qrimg;
const timer = setInterval(async () => {
const { data } = await checkQr(key) const timer = timerIsQr(key);
// 添加对定时器的引用,以便在出现错误时可以清除
if (data.code === 800) { timerRef.value = timer as any;
clearInterval(timer) } catch (error) {
} console.error('加载登录信息时出错:', error);
if (data.code === 803) { }
// 将token存入localStorage };
localStorage.setItem('token', data.cookie)
const user = await getUserDetail() // 使用 ref 来保存定时器,便于在任何地方清除它
store.state.user = user.data.profile
message.success('登录成功') const timerIsQr = (key: string) => {
const timer = setInterval(async () => {
await getLoginStatus().then(res => { try {
console.log(res); const { data } = await checkQr(key);
})
clearInterval(timer) if (data.code === 800) {
setTimeout(() => { clearInterval(timer);
router.push('/user') timerRef.value = null;
}, 1000); }
} if (data.code === 803) {
}, 5000); localStorage.setItem('token', data.cookie);
} const user = await getUserDetail();
store.state.user = user.data.profile;
localStorage.setItem('user', JSON.stringify(store.state.user));
// 是否扫码登陆 message.success('登录成功');
const isQr = ref(true)
const chooseQr = () => { clearInterval(timer);
isQr.value = !isQr.value timerRef.value = null;
} router.push('/user');
}
// 手机号登录 } catch (error) {
const phone = ref('') console.error('检查二维码状态时出错:', error);
const password = ref('') // 在出现错误时清除定时器
const loginPhone = async () => { clearInterval(timer);
const { data } = await loginByCellphone(phone.value, password.value) timerRef.value = null;
if (data.code === 200) { }
message.success('登录成功') }, 2000);
store.state.user = data.profile
localStorage.setItem('token', data.cookie) return timer;
setTimeout(() => { };
router.push('/user')
}, 1000); // 离开页面时
} onBeforeUnmount(() => {
} if (timerRef.value) {
clearInterval(timerRef.value);
</script> timerRef.value = null;
}
<template> });
<div class="login-page">
<div class="phone-login"> // 是否扫码登陆
<div class="bg"></div> const isQr = ref(!isMobile.value);
<div class="content"> const chooseQr = () => {
<div class="phone" v-if="isQr" :class="setAnimationClass('animate__fadeInUp')"> isQr.value = !isQr.value;
<div class="login-title">扫码登陆</div> };
<img class="qr-img" :src="qrUrl" />
<div class="text">使用网易云APP扫码登录</div> // 手机号登录
</div> const phone = ref('');
<div class="phone" v-else :class="setAnimationClass('animate__fadeInUp')"> const password = ref('');
<div class="login-title">手机号登录</div> const loginPhone = async () => {
<div class="phone-page"> const { data } = await loginByCellphone(phone.value, password.value);
<input v-model="phone" class="phone-input" type="text" placeholder="手机号" /> if (data.code === 200) {
<input v-model="password" class="phone-input" type="password" placeholder="密码" /> message.success('登录成功');
</div> store.state.user = data.profile;
<n-button class="btn-login" @click="loginPhone()">登录</n-button> localStorage.setItem('token', data.cookie);
</div> setTimeout(() => {
</div> router.push('/user');
<div class="bottom"> }, 1000);
<div class="title" @click="chooseQr()">{{ isQr ? '手机号登录' : '扫码登录' }}</div> }
</div> };
</div> </script>
</div>
</template> <template>
<div class="login-page">
<style lang="scss" scoped> <div class="phone-login">
.login-page { <div class="bg"></div>
@apply flex flex-col items-center justify-center p-20 pt-20; <div class="content">
} <div v-if="isQr" class="phone" :class="setAnimationClass('animate__fadeInUp')">
<div class="login-title">扫码登陆</div>
.login-title { <img class="qr-img" :src="qrUrl" />
@apply text-2xl font-bold mb-6; <div class="text">使用网易云APP扫码登录</div>
} </div>
<div v-else class="phone" :class="setAnimationClass('animate__fadeInUp')">
.text { <div class="login-title">手机号登录</div>
@apply mt-4 text-green-500 text-xs; <div class="phone-page">
} <input v-model="phone" class="phone-input" type="text" placeholder="手机号" />
<input v-model="password" class="phone-input" type="password" placeholder="密码" />
.phone-login { </div>
width: 350px; <n-button class="btn-login" @click="loginPhone()">登录</n-button>
height: 550px; </div>
@apply rounded-2xl rounded-b-none bg-cover bg-no-repeat relative overflow-hidden; </div>
background-image: url(http://tva4.sinaimg.cn/large/006opRgRgy1gw8nf6no7uj30rs15n0x7.jpg); <div class="bottom">
background-color: #383838; <div class="title" @click="chooseQr()">{{ isQr ? '手机号登录' : '扫码登录' }}</div>
box-shadow: inset 0px 0px 20px 5px #0000005e; </div>
</div>
.bg { </div>
@apply absolute w-full h-full bg-black opacity-30; </template>
}
<style lang="scss" scoped>
.bottom { .login-page {
width: 200%; @apply flex flex-col items-center justify-center p-20 pt-20;
height: 250px; }
bottom: -180px;
border-radius: 50%; .login-title {
left: 50%; @apply text-2xl font-bold mb-6;
padding: 10px; }
transform: translateX(-50%);
color: #ffffff99; .text {
@apply absolute bg-black flex justify-center text-lg font-bold cursor-pointer; @apply mt-4 text-green-500 text-xs;
box-shadow: 10px 0px 20px #000000a9; }
}
.phone-login {
.content { width: 350px;
@apply absolute w-full h-full p-4 flex flex-col items-center justify-center pb-20 text-center; height: 550px;
.qr-img { @apply rounded-2xl rounded-b-none bg-cover bg-no-repeat relative overflow-hidden;
@apply opacity-80 rounded-2xl cursor-pointer; background-image: url(http://tva4.sinaimg.cn/large/006opRgRgy1gw8nf6no7uj30rs15n0x7.jpg);
} background-color: #383838;
box-shadow: inset 0px 0px 20px 5px #0000005e;
.phone {
animation-duration: 0.5s; .bg {
&-page { @apply absolute w-full h-full bg-black opacity-30;
background-color: #ffffffdd; }
width: 250px;
@apply rounded-2xl overflow-hidden; .bottom {
} width: 200%;
height: 250px;
&-input { bottom: -180px;
height: 40px; border-radius: 50%;
border-bottom: 1px solid #e5e5e5; left: 50%;
@apply w-full text-black px-4 outline-none; padding: 10px;
} transform: translateX(-50%);
} color: #ffffff99;
.btn-login { @apply absolute bg-black flex justify-center text-lg font-bold cursor-pointer;
width: 250px; box-shadow: 10px 0px 20px #000000a9;
height: 40px; }
@apply mt-10 text-white rounded-xl bg-black opacity-60;
} .content {
} @apply absolute w-full h-full p-4 flex flex-col items-center justify-center pb-20 text-center;
} .qr-img {
</style> @apply opacity-80 rounded-2xl cursor-pointer;
}
.phone {
animation-duration: 0.5s;
&-page {
background-color: #ffffffdd;
width: 250px;
@apply rounded-2xl overflow-hidden;
}
&-input {
height: 40px;
border-bottom: 1px solid #e5e5e5;
@apply w-full text-black px-4 outline-none;
}
}
.btn-login {
width: 250px;
height: 40px;
@apply mt-10 text-white rounded-xl bg-black opacity-60;
}
}
}
</style>

274
src/views/lyric/index.vue Normal file
View File

@@ -0,0 +1,274 @@
<template>
<div class="lyric-window" :class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]">
<div class="drag-bar"></div>
<div class="lyric-bar" :class="{ 'lyric-bar-hover': isDrag }">
<div class="buttons">
<!-- <div class="music-buttons">
<div @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">
<i class="iconfont icon-next"></i>
</div>
</div> -->
<div class="button check-theme" @click="checkTheme">
<i v-if="lyricSetting.theme === 'light'" class="icon ri-sun-line"></i>
<i v-else class="icon ri-moon-line"></i>
</div>
<div class="button">
<i class="icon ri-share-2-line" :class="{ checked: lyricSetting.isTop }" @click="handleTop"></i>
</div>
<div class="button button-lock" @click="handleLock">
<i v-if="lyricSetting.isLock" class="icon ri-lock-line"></i>
<i v-else class="icon ri-lock-unlock-line"></i>
</div>
<div class="button">
<i class="icon ri-close-circle-line" @click="handleClose"></i>
</div>
</div>
</div>
<div id="clickThroughElement" class="lyric-box">
<template v-if="lyricData.lrcArray[lyricData.nowIndex]">
<h2 class="lyric lyric-current">{{ lyricData.lrcArray[lyricData.nowIndex].text }}</h2>
<p class="lyric-current">{{ lyricData.currentLrc.trText }}</p>
<template v-if="lyricData.lrcArray[lyricData.nowIndex + 1]">
<h2 class="lyric lyric-next">
{{ lyricData.lrcArray[lyricData.nowIndex + 1].text }}
</h2>
<p class="lyric-next">{{ lyricData.nextLrc.trText }}</p>
</template>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { useIpcRenderer } from '@vueuse/electron';
defineOptions({
name: 'Lyric',
});
const ipcRenderer = useIpcRenderer();
const lyricData = ref({
currentLrc: {
text: '',
trText: '',
},
nextLrc: {
text: '',
trText: '',
},
currentTime: 0,
nextTime: 0,
nowTime: 0,
allTime: 0,
startCurrentTime: 0,
lrcArray: [] as any,
lrcTimeArray: [] as any,
nowIndex: 0,
});
const lyricSetting = ref({
...(localStorage.getItem('lyricData')
? JSON.parse(localStorage.getItem('lyricData') || '')
: {
isTop: false,
theme: 'dark',
isLock: false,
}),
});
onMounted(() => {
ipcRenderer.on('receive-lyric', (event, data) => {
try {
lyricData.value = JSON.parse(data);
} catch (error) {
console.error('error', error);
}
});
});
const checkTheme = () => {
if (lyricSetting.value.theme === 'light') {
lyricSetting.value.theme = 'dark';
} else {
lyricSetting.value.theme = 'light';
}
};
const handleTop = () => {
lyricSetting.value.isTop = !lyricSetting.value.isTop;
ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
};
const handleLock = () => {
lyricSetting.value.isLock = !lyricSetting.value.isLock;
};
const handleClose = () => {
ipcRenderer.send('close-lyric');
};
watch(
() => lyricSetting.value,
(newValue) => {
localStorage.setItem('lyricData', JSON.stringify(newValue));
},
{ deep: true },
);
// onMounted(() => {
// const el = document.getElementById('clickThroughElement') as HTMLElement;
// el.addEventListener('mouseenter', () => {
// if (lyricSetting.value.isLock) ipcRenderer.send('mouseenter-lyric');
// });
// el.addEventListener('mouseleave', () => {
// if (lyricSetting.value.isLock) ipcRenderer.send('mouseleave-lyric');
// });
// });
</script>
<style>
body {
background-color: transparent !important;
}
</style>
<style lang="scss" scoped>
.lyric-window {
width: 100vw;
height: 100vh;
@apply overflow-hidden text-gray-600 rounded-xl box-border;
// border: 4px solid transparent;
&:hover .lyric-bar {
opacity: 1;
}
&:hover .drag-bar {
opacity: 1;
}
&:hover {
box-shadow: inset 0 0 10px 0 rgba(255, 255, 255, 0.5);
}
}
.lyric_lock {
&:hover {
box-shadow: none;
}
&:hover .lyric-bar {
background-color: transparent;
.button {
opacity: 0;
}
.button-lock {
opacity: 1;
color: #d6d6d6;
}
}
&:hover .drag-bar {
opacity: 0;
}
}
.icon {
@apply text-xl hover:text-white;
}
.lyric-bar {
background-color: #b1b1b1;
@apply flex flex-col justify-center items-center;
width: 100vw;
height: 40px;
opacity: 0;
&:hover {
opacity: 1;
}
}
.lyric-bar-hover {
opacity: 1;
}
.drag-bar {
-webkit-app-region: drag;
height: 20px;
cursor: move;
background-color: #383838;
opacity: 0;
}
.buttons {
width: 100vw;
height: 100px;
@apply flex justify-center items-center gap-4;
}
.button {
@apply cursor-pointer text-center;
}
.checked {
color: #fff !important;
}
.button-move {
-webkit-app-region: drag;
cursor: move;
}
.music-buttons {
@apply mx-6;
-webkit-app-region: no-drag;
.iconfont {
@apply text-2xl hover:text-green-500 transition;
}
@apply flex items-center;
> div {
@apply cursor-pointer;
}
&-play {
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition;
background: #383838;
}
}
.check-theme {
font-size: 26px;
cursor: pointer;
opacity: 1;
}
.lyric {
text-shadow: 0 0 1vw #2c2c2c;
font-size: 4vw;
@apply font-bold m-0 p-0 select-none pointer-events-none;
}
.lyric-current {
color: #333;
}
.lyric-next {
color: #999;
margin: 10px;
}
.lyric-window.dark {
.lyric {
text-shadow: none;
text-shadow: 0 0 1vw #000000;
}
.lyric-current {
color: #fff;
}
.lyric-next {
color: #cecece;
}
}
.lyric-box {
// writing-mode: vertical-rl;
padding: 10px;
}
</style>

View File

@@ -4,11 +4,23 @@
<h2>推荐MV</h2> <h2>推荐MV</h2>
</div> </div>
<n-scrollbar :size="100"> <n-scrollbar :size="100">
<div class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')"> <div v-loading="loading" class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div class="mv-item" v-for="(item, index) in mvList" :key="item.id" <div
:class="setAnimationClass('animate__bounceIn')" :style="setAnimationDelay(index, 30)"> v-for="(item, index) in mvList"
:key="item.id"
class="mv-item"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index, 30)"
>
<div class="mv-item-img" @click="handleShowMv(item)"> <div class="mv-item-img" @click="handleShowMv(item)">
<n-image class="mv-item-img-img" :src="getImgUrl((item.cover), '200y112')" lazy preview-disabled width="200" height="112" /> <n-image
class="mv-item-img-img"
:src="getImgUrl(item.cover, '200y112')"
lazy
preview-disabled
width="200"
height="112"
/>
<div class="top"> <div class="top">
<div class="play-count">{{ formatNumber(item.playCount) }}</div> <div class="play-count">{{ formatNumber(item.playCount) }}</div>
<i class="iconfont icon-videofill"></i> <i class="iconfont icon-videofill"></i>
@@ -20,7 +32,7 @@
</n-scrollbar> </n-scrollbar>
<n-drawer :show="showMv" height="100vh" placement="bottom" :z-index="999999999"> <n-drawer :show="showMv" height="100vh" placement="bottom" :z-index="999999999">
<div class="mv-detail"> <div v-loading="mvLoading" class="mv-detail">
<video :src="playMvUrl" controls autoplay></video> <video :src="playMvUrl" controls autoplay></video>
<div class="mv-detail-title"> <div class="mv-detail-title">
<div class="title">{{ playMvItem?.name }}</div> <div class="title">{{ playMvItem?.name }}</div>
@@ -35,51 +47,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { getTopMv, getMvUrl } from '@/api/mv';
import { IMvItem } from '@/type/mv';
import { setAnimationClass, setAnimationDelay, getImgUrl, formatNumber } from "@/utils";
import { useStore } from 'vuex'; import { useStore } from 'vuex';
const showMv = ref(false) import { getMvUrl, getTopMv } from '@/api/mv';
const mvList = ref<Array<IMvItem>>([]) import { IMvItem } from '@/type/mv';
const playMvItem = ref<IMvItem>() import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
const playMvUrl = ref<string>()
const store = useStore() defineOptions({
name: 'Mv',
});
const showMv = ref(false);
const mvList = ref<Array<IMvItem>>([]);
const playMvItem = ref<IMvItem>();
const playMvUrl = ref<string>();
const store = useStore();
const loading = ref(false);
onMounted(async () => { onMounted(async () => {
const res = await getTopMv(30) loading.value = true;
mvList.value = res.data.data const res = await getTopMv(30);
console.log('mvList.value', mvList.value) mvList.value = res.data.data;
}) loading.value = false;
});
const mvLoading = ref(false);
const handleShowMv = async (item: IMvItem) => { const handleShowMv = async (item: IMvItem) => {
store.commit('setIsPlay', false) mvLoading.value = true;
store.commit('setPlayMusic', false) store.commit('setIsPlay', false);
showMv.value = true store.commit('setPlayMusic', false);
const res = await getMvUrl(item.id) showMv.value = true;
const res = await getMvUrl(item.id);
playMvItem.value = item; playMvItem.value = item;
playMvUrl.value = res.data.data.url playMvUrl.value = res.data.data.url;
} mvLoading.value = false;
};
const close = () => { const close = () => {
showMv.value = false showMv.value = false;
if (store.state.playMusicUrl) { if (store.state.playMusicUrl) {
store.commit('setIsPlay', true) store.commit('setIsPlay', true);
} }
} };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.mv-list { .mv-list {
@apply relative h-full w-full; @apply relative h-full w-full px-4;
&-title { &-title {
@apply text-xl font-bold; @apply text-xl font-bold;
} }
&-content { &-content {
@apply grid gap-6 pb-4 mt-2; @apply grid gap-6 pb-28 mt-2;
grid-template-columns: repeat(auto-fill, minmax(14%, 1fr)); grid-template-columns: repeat(auto-fill, minmax(14%, 1fr));
} }
@@ -157,4 +178,11 @@ const close = () => {
.mv-detail-title:hover { .mv-detail-title:hover {
@apply top-0; @apply top-0;
} }
}</style> }
.mobile {
.mv-list-content {
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
}
}
</style>

View File

@@ -1,182 +1,206 @@
<template> <template>
<div class="search-page"> <div class="search-page">
<n-layout <n-layout
class="hot-search" v-if="isMobile ? !searchDetail : true"
:class="setAnimationClass('animate__fadeInDown')" class="hot-search"
:native-scrollbar="false" :class="setAnimationClass('animate__fadeInDown')"
> :native-scrollbar="false"
<div class="title">热搜列表</div> >
<div class="hot-search-list"> <div class="title">热搜列表</div>
<template v-for="(item, index) in hotSearchData?.data"> <div class="hot-search-list">
<div <template v-for="(item, index) in hotSearchData?.data" :key="index">
:class="setAnimationClass('animate__bounceInLeft')" <div
:style="setAnimationDelay(index, 10)" :class="setAnimationClass('animate__bounceInLeft')"
class="hot-search-item" :style="setAnimationDelay(index, 10)"
@click.stop="clickHotKeyword(item.searchWord)" class="hot-search-item"
> @click.stop="loadSearch(item.searchWord, 1)"
<span >
class="hot-search-item-count" <span class="hot-search-item-count" :class="{ 'hot-search-item-count-3': index < 3 }">{{ index + 1 }}</span>
:class="{ 'hot-search-item-count-3': index < 3 }" {{ item.searchWord }}
>{{ index + 1 }}</span> </div>
{{ item.searchWord }} </template>
</div> </div>
</n-layout>
<!-- 搜索到的歌曲列表 -->
<n-layout
v-if="isMobile ? searchDetail : true"
class="search-list"
:class="setAnimationClass('animate__fadeInUp')"
:native-scrollbar="false"
>
<div class="title">{{ hotKeyword }}</div>
<div v-loading="searchDetailLoading" class="search-list-box">
<template v-if="searchDetail">
<div
v-for="(item, index) in searchDetail?.songs"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<song-item :item="item" @play="handlePlay" />
</div>
<template v-for="(list, key) in searchDetail">
<template v-if="key.toString() !== 'songs'">
<div
v-for="(item, index) in list"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<SearchItem :item="item" />
</div>
</template> </template>
</div> </template>
</n-layout> </template>
<!-- 搜索到的歌曲列表 --> </div>
<n-layout </n-layout>
class="search-list" </div>
:class="setAnimationClass('animate__fadeInUp')"
:native-scrollbar="false"
>
<div class="title">{{ hotKeyword }}</div>
<div class="search-list-box">
<template v-if="searchDetail">
<div
v-for="(item, index) in searchDetail?.songs"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<SongItem :item="item" @play="handlePlay"/>
</div>
<template v-for="(list, key) in searchDetail">
<template v-if="key.toString() !== 'songs'">
<div
v-for="(item, index) in list"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<SearchItem :item="item"/>
</div>
</template>
</template>
</template>
</div>
</n-layout>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { getSearch } from "@/api/search"; import { useDateFormat } from '@vueuse/core';
import type { IHotSearch } from "@/type/search"; import { onMounted, ref, watch } from 'vue';
import { getHotSearch } from "@/api/home"; import { useRoute } from 'vue-router';
import { useRoute, useRouter } from "vue-router"; import { useStore } from 'vuex';
import { setAnimationClass, setAnimationDelay } from "@/utils";
import { onMounted, ref, watch } from "vue"; import { getHotSearch } from '@/api/home';
import SongItem from "@/components/common/SongItem.vue"; import { getSearch } from '@/api/search';
import { useStore } from "vuex"; import SongItem from '@/components/common/SongItem.vue';
import { useDateFormat } from '@vueuse/core' import type { IHotSearch } from '@/type/search';
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'Search',
});
const route = useRoute(); const route = useRoute();
const router = useRouter(); const store = useStore();
const searchDetail = ref<any>(); const searchDetail = ref<any>();
const searchType = ref(Number(route.query.type) || 1); const searchType = computed(() => store.state.searchType as number);
const searchDetailLoading = ref(false);
// 热搜列表 // 热搜列表
const hotSearchData = ref<IHotSearch>(); const hotSearchData = ref<IHotSearch>();
const loadHotSearch = async () => { const loadHotSearch = async () => {
const { data } = await getHotSearch(); const { data } = await getHotSearch();
hotSearchData.value = data; hotSearchData.value = data;
}; };
onMounted(() => { onMounted(() => {
loadHotSearch(); loadHotSearch();
loadSearch(route.query.keyword);
}); });
const hotKeyword = ref(route.query.keyword || "搜索列表"); const hotKeyword = ref(route.query.keyword || '搜索列表');
const clickHotKeyword = (keyword: string) => {
hotKeyword.value = keyword;
router.push({
path: "/search",
query: {
keyword: keyword,
type: 1
},
});
// isHotSearchList.value = false;
};
const dateFormat = (time:any) => useDateFormat(time, 'YYYY.MM.DD').value
const loadSearch = async (keywords: any) => {
hotKeyword.value = keywords;
searchDetail.value = undefined;
if (!keywords) return;
const { data } = await getSearch({keywords, type:searchType.value});
const songs = data.result.songs || [];
const albums = data.result.albums || [];
// songs map 替换属性
songs.map((item: any) => {
item.picUrl = item.al.picUrl;
item.song = item;
item.artists = item.ar;
});
albums.map((item: any) => {
item.desc = `${item.artist.name } ${ item.company } ${dateFormat(item.publishTime)}`;
});
searchDetail.value = {
songs,
albums
}
};
loadSearch(route.query.keyword);
watch( watch(
() => route.query, () => store.state.searchValue,
async newParams => { (value) => {
searchType.value = Number(newParams.type || 1) loadSearch(value);
loadSearch(newParams.keyword); },
);
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
const loadSearch = async (keywords: any, type: any = null) => {
hotKeyword.value = keywords;
searchDetail.value = undefined;
if (!keywords) return;
searchDetailLoading.value = true;
const { data } = await getSearch({ keywords, type: type || searchType.value });
const songs = data.result.songs || [];
const albums = data.result.albums || [];
const mvs = (data.result.mvs || []).map((item: any) => ({
...item,
picUrl: item.cover,
playCount: item.playCount,
desc: item.artists.map((artist: any) => artist.name).join('/'),
type: 'mv',
}));
const playlists = (data.result.playlists || []).map((item: any) => ({
...item,
picUrl: item.coverImgUrl,
playCount: item.playCount,
desc: item.creator.nickname,
type: 'playlist',
}));
// songs map 替换属性
songs.forEach((item: any) => {
item.picUrl = item.al.picUrl;
item.artists = item.ar;
});
albums.forEach((item: any) => {
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
});
searchDetail.value = {
songs,
albums,
mvs,
playlists,
};
searchDetailLoading.value = false;
};
watch(
() => route.path,
async (path) => {
if (path === '/search') {
store.state.searchValue = route.query.keyword;
} }
) },
);
const store = useStore() const handlePlay = () => {
const tracks = searchDetail.value?.songs || [];
const handlePlay = (item: any) => { store.commit('setPlayList', tracks);
const tracks = searchDetail.value?.songs || [] };
store.commit('setPlayList', tracks)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.search-page { .search-page {
@apply flex h-full; @apply flex h-full;
} }
.hot-search { .hot-search {
@apply mr-4 rounded-xl flex-1 overflow-hidden; @apply mr-4 rounded-xl flex-1 overflow-hidden;
background-color: #0d0d0d; background-color: #0d0d0d;
animation-duration: 0.2s; animation-duration: 0.2s;
min-width: 400px; min-width: 400px;
height: 100%; height: 100%;
&-list{ &-list {
@apply pb-28; @apply pb-28;
} }
&-item { &-item {
@apply px-4 py-3 text-lg hover:bg-gray-700 rounded-xl cursor-pointer; @apply px-4 py-3 text-lg hover:bg-gray-700 rounded-xl cursor-pointer;
&-count { &-count {
@apply text-green-500 inline-block ml-3 w-8; @apply text-green-500 inline-block ml-3 w-8;
&-3 { &-3 {
@apply text-red-600 font-bold inline-block ml-3 w-8; @apply text-red-600 font-bold inline-block ml-3 w-8;
} }
}
} }
}
} }
.search-list { .search-list {
@apply flex-1 rounded-xl; @apply flex-1 rounded-xl;
background-color: #0d0d0d; background-color: #0d0d0d;
height: 100%; height: 100%;
&-box{ &-box {
@apply pb-28; @apply pb-28;
} }
} }
.title { .title {
@apply text-gray-200 text-xl font-bold my-2 mx-4; @apply text-gray-200 text-xl font-bold my-2 mx-4;
}
.mobile {
.hot-search {
@apply mr-0 w-full;
}
} }
</style> </style>

View File

@@ -5,7 +5,7 @@
<div class="set-item-title">代理</div> <div class="set-item-title">代理</div>
<div class="set-item-content">无法听音乐时打开</div> <div class="set-item-content">无法听音乐时打开</div>
</div> </div>
<n-switch v-model:value="setData.isProxy"/> <n-switch v-model:value="setData.isProxy" />
</div> </div>
<div class="set-item"> <div class="set-item">
<div> <div>
@@ -30,35 +30,42 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import store from '@/store'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const setData = ref(store.state.setData) import store from '@/store';
const router = useRouter()
defineOptions({
name: 'Setting',
});
const setData = ref(store.state.setData);
const router = useRouter();
const handelCancel = () => { const handelCancel = () => {
router.back() router.back();
} };
const windowData = window as any const windowData = window as any;
const handleSave = () => { const handleSave = () => {
store.commit('setSetData', setData.value) store.commit('setSetData', setData.value);
windowData.electronAPI.restart() if (windowData.electronAPI) {
} windowData.electronAPI.restart();
}
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.set-page{ .set-page {
@apply flex flex-col justify-center items-center pt-8; @apply flex flex-col justify-center items-center pt-8;
} }
.set-item{ .set-item {
@apply w-3/5 flex justify-between items-center mb-4; @apply w-3/5 flex justify-between items-center mb-4;
.set-item-title{ .set-item-title {
@apply text-gray-200 text-base; @apply text-gray-200 text-base;
} }
.set-item-content{ .set-item-content {
@apply text-gray-400 text-sm; @apply text-gray-400 text-sm;
} }
} }
</style> </style>

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