Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53d9197196 | ||
|
|
7a349272b2 | ||
|
|
29dfa45791 | ||
|
|
c2fcb25686 | ||
|
|
3bb23e4765 | ||
|
|
5af7df60e4 | ||
|
|
4280cab090 | ||
|
|
7e1111cd33 | ||
|
|
194b88519f | ||
|
|
7d20b23fb0 | ||
|
|
909547ad2e | ||
|
|
9779e38140 | ||
|
|
0021f32a19 | ||
|
|
4784f63ca0 | ||
|
|
fb007eaa9c | ||
|
|
790530b71f | ||
|
|
49bd1c66c0 | ||
|
|
1f6dd81e78 | ||
|
|
faaf904e9f | ||
|
|
faac6273bb | ||
|
|
07a9c515a2 | ||
|
|
179ff34063 | ||
|
|
3aff332758 |
66
.github/workflows/deploydocs.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程
|
||||
#
|
||||
name: Deploy VitePress site to Pages
|
||||
|
||||
on:
|
||||
# 在针对 `main` 分支的推送上运行。如果你
|
||||
# 使用 `master` 分支作为默认分支,请将其更改为 `master`
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# 允许你从 Actions 选项卡手动运行此工作流程
|
||||
workflow_dispatch:
|
||||
|
||||
# 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列
|
||||
# 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# 构建工作
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 如果未启用 lastUpdated,则不需要
|
||||
- uses: pnpm/action-setup@v3 # 如果使用 pnpm,请取消此区域注释
|
||||
with:
|
||||
version: 9
|
||||
# - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm # 或 pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm add -D vitepress@next # 或 pnpm install / yarn install / bun install
|
||||
- name: Build with VitePress
|
||||
run: pnpm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
# 部署工作
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
4
.gitignore
vendored
@@ -15,4 +15,6 @@ temp/log.txt
|
||||
/.kiro/
|
||||
/.vscode/
|
||||
/.codebuddy/
|
||||
/.idea/
|
||||
/.idea/
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
217
LICENSE
@@ -1,21 +1,204 @@
|
||||
MIT License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright (c) 2025 时迁酱
|
||||
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 (c) 2025 时迁酱
|
||||
|
||||
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.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
122
README.md
@@ -1,10 +1,14 @@
|
||||
# Ceru Music
|
||||
# Ceru Music(澜音)
|
||||
|
||||
一个跨平台的音乐播放器应用,支持多来源音乐数据获取与播放。
|
||||
|
||||
|
||||
一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。
|
||||
|
||||
## 项目简介
|
||||
|
||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器,支持从多个音乐平台获取歌曲信息并播放。该项目结合了现代前端技术和桌面应用开发,提供了流畅的用户体验和灵活的音乐数据源支持。
|
||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
||||
|
||||
<img src="assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -13,26 +17,35 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器,
|
||||
- **TypeScript**:增强代码可维护性和类型安全
|
||||
- **Pinia**:状态管理工具
|
||||
- **Vite**:快速的前端构建工具
|
||||
- **Meting API**:作为备用音乐数据源
|
||||
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
|
||||
- **AMLL**:音乐生态辅助模块(仅提供功能接口,不关联具体音乐数据源)
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 支持从多个音乐平台搜索和播放歌曲
|
||||
- 获取歌词和专辑信息
|
||||
|
||||
|
||||
- 提供插件加载与管理功能,支持通过合规插件获取公开音乐信息
|
||||
- 支持通过插件获取歌词、专辑封面等公开元数据
|
||||
- 支持虚拟滚动列表,优化大量数据渲染性能
|
||||
- 本地数据存储与播放列表管理
|
||||
- 本地播放列表管理(仅存储用户手动创建的列表结构,不包含音乐文件)
|
||||
- **提示**:本地数据仅保存在用户设备本地,未进行云端备份,用户需自行备份以防止数据丢失
|
||||
- 精美的用户界面与动画效果
|
||||
- **插件生态框架**(插件需用户自行获取并确保合规性)
|
||||
|
||||
## 安装与使用
|
||||
|
||||
### 推荐开发环境
|
||||
|
||||
|
||||
|
||||
- **IDE**: VS Code 或 WebStorm
|
||||
- **Node.js 版本**: 推荐使用最新稳定版
|
||||
- **包管理器**: yarn
|
||||
- **Node.js 版本**: 22 及以上
|
||||
- **包管理器**: **yarn**
|
||||
|
||||
### 项目设置
|
||||
|
||||
|
||||
|
||||
1. 安装依赖:
|
||||
|
||||
```bash
|
||||
@@ -46,46 +59,115 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器,
|
||||
```
|
||||
|
||||
3. 构建应用:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 平台构建指令
|
||||
|
||||
- **Windows**:
|
||||
|
||||
|
||||
- Windows
|
||||
|
||||
```bash
|
||||
yarn build:win
|
||||
```
|
||||
|
||||
- **macOS**:
|
||||
- macOS
|
||||
|
||||
```bash
|
||||
yarn build:mac
|
||||
```
|
||||
|
||||
- **Linux**:
|
||||
- Linux
|
||||
|
||||
```bash
|
||||
yarn build:linux
|
||||
```
|
||||
|
||||
|
||||
|
||||
> 提示:构建后的应用仅包含播放器框架,需用户自行配置合规插件方可获取音乐数据。
|
||||
|
||||
## 文档与资源
|
||||
|
||||
- [API 接口文档](docs/api.md):详细说明了支持的音乐平台和请求格式。
|
||||
- [产品设计文档](docs/design.md):涵盖项目架构、核心功能设计和开发规范。
|
||||
- [产品设计文档](https://www.doubao.com/thread/docs/design.md):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
|
||||
- [插件开发文档](https://www.doubao.com/thread/docs/CeruMusic插件开发文档.md):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
|
||||
|
||||
## 开源许可
|
||||
|
||||
本项目遵循 MIT 许可协议。详情请参阅 [LICENSE](LICENSE) 文件。
|
||||
本项目源代码遵循 **Apache License 2.0**,仅授权用户对项目框架进行学习、修改与二次开发,不包含任何音乐数据相关授权。详情请参阅 [LICENSE](./LICENSE) 文件,使用前请务必阅读许可条款。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎贡献代码和反馈建议!请遵循 [Git 提交规范](docs/design.md#git提交规范) 并确保代码符合项目风格指南。
|
||||
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
|
||||
|
||||
## 更新日志
|
||||
|
||||
请参阅 [更新日志](docs/api.md#更新日志) 了解最新功能和改进。
|
||||
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
|
||||
2. 遵循 [Git 提交规范](https://www.doubao.com/thread/docs/design.md#git提交规范) 并确保代码符合项目风格指南。
|
||||
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或合作意向,请通过 Gitee 私信联系项目维护者。
|
||||
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
|
||||
|
||||
## 项目开发者
|
||||
|
||||
- **时迁酱**:产品总体设计与开发
|
||||
|
||||
<img src="assets/head.jpg" alt="head.jpg (940×940)" style="zoom:15%;" />
|
||||
|
||||
- **无聊的霜霜**:首页设计&Ai助手
|
||||
|
||||
<img src="assets/image-20250827181604432.png" alt="image-20250827181604432" style="zoom:25%;" />
|
||||
|
||||
- **Star**:**插件管理**相关功能&部分接口封装
|
||||
|
||||
<img src="assets/image-20250827181535681.png" alt="image-20250827181535681" style="zoom:25%;" />
|
||||
|
||||
**Tips**: 排名不分先后
|
||||
|
||||
# 法律声明与免责条款
|
||||
|
||||
**重要提示:使用本项目前,请务必仔细阅读本条款,使用本项目即视为你已充分理解并同意本条款全部内容。**
|
||||
|
||||
### 一、定义约定
|
||||
|
||||
- “Apache License 2.0”:指 Ceru Music(澜音)桌面播放器框架及源代码,不包含任何第三方插件或音乐数据。
|
||||
- “**用户**”:指下载、安装、使用本项目的个人或组织。
|
||||
- “**合规插件**”:指符合数据来源平台用户协议、不侵犯第三方版权、不获取非公开数据的插件。
|
||||
- “**版权内容**”:指包括但不限于音乐文件、歌词、专辑封面、艺人信息等受著作权法保护的内容。
|
||||
|
||||
### 二、数据与内容责任
|
||||
|
||||
1. 本项目**不直接获取、存储、传输任何音乐数据或版权内容**,仅提供插件运行框架。用户通过插件获取的所有数据,其合法性、准确性由插件提供者及用户**自行负责**,本项目不承担任何责任。
|
||||
2. 若用户使用的插件存在获取非公开数据、侵犯第三方版权等违规行为,相关法律责任由用户及插件提供者承担,与本项目无关。
|
||||
3. 本项目使用的字体、图片等素材,均来自开源社区或已获得合法授权,若存在侵权请联系项目维护者立即移除,本项目将积极配合处理。
|
||||
|
||||
### 三、版权合规要求
|
||||
|
||||
1. 用户承诺:使用本项目时,仅通过合规插件获取音乐相关信息,且获取、使用版权内容的行为符合**《中华人民共和国著作权法》**及相关法律法规,不侵犯**任何第三方**合法权益。
|
||||
2. 用户需知晓:任何未经授权下载、传播、使用受版权保护的音乐文件的行为,均可能构成侵权,需自行承担法律后果。
|
||||
3. 本项目倡导 “尊重版权、支持正版”,提醒用户通过官方音乐平台获取授权音乐服务。
|
||||
|
||||
### 四、免责声明
|
||||
|
||||
1. 因用户使用非合规插件、违反法律法规或第三方协议导致的任何法律责任(包括但不限于侵权赔偿、行政处罚),均由用户自行承担,本项目不承担任何直接、间接、连带或衍生责任。
|
||||
2. 因本项目框架本身的 **bug** 导致的用户设备故障、数据丢失,本项目仅承担在合理范围内的技术修复责任,不承担由此产生的间接损失(如商誉损失、业务中断损失等)。
|
||||
3. 本项目为开源学习项目,不提供商业服务,对用户使用本项目的效果不做任何明示或暗示的保证。
|
||||
|
||||
### 五、使用限制
|
||||
|
||||
1. 本项目仅允许用于**非商业、纯技术学习目的**,禁止用于任何商业运营、盈利活动,禁止修改后用于侵犯第三方权益的场景。
|
||||
2. 禁止在违反当地法律法规、本声明或第三方协议的前提下使用本项目,若用户所在地区禁止此类工具的使用,应立即停止使用。
|
||||
3. 禁止将本项目源代码或构建后的应用,与违规插件捆绑传播,禁止利用本项目从事任何违法违规活动。
|
||||
|
||||
### 六、其他
|
||||
|
||||
1. 本声明的效力、解释及适用,均适用中华人民共和国法律(不含港澳台地区法律)。
|
||||
2. 若用户与本项目维护者就本声明产生争议,应首先通过友好协商解决;协商不成的,任何一方均有权向本项目维护者所在地有管辖权的人民法院提起诉讼。
|
||||
|
||||
## 赞助
|
||||
|
||||
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
||||
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
||||
|
||||
BIN
assets/head.jpg
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
assets/image-20250827175023917.png
Normal file
|
After Width: | Height: | Size: 719 KiB |
BIN
assets/image-20250827175109430.png
Normal file
|
After Width: | Height: | Size: 981 KiB |
BIN
assets/image-20250827175356006.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/image-20250827175547444.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
assets/image-20250827181535681.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
assets/image-20250827181604432.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
assets/image-20250827181634134.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
56
docs/.vitepress/config.mts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
lang: 'zh-CN',
|
||||
title: "Ceru Music",
|
||||
base: process.env.BASE_URL ?? '/CeruMusic/',
|
||||
description: "Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。",
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
logo: '/logo.svg',
|
||||
nav: [
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: '使用文档', link: '/guide/' }
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: 'CeruMusic',
|
||||
items: [
|
||||
{ text: '使用教程', link: '/guide/' },
|
||||
{ text: '软件设计文档', link: '/guide/design' }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '澜音&插件',
|
||||
items: [
|
||||
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
|
||||
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/timeshiftsauce/CeruMusic' },
|
||||
{ icon: 'gitee', link: 'https://gitee.com/sqjcode/CeruMuisc' },
|
||||
{ icon: 'qq', link: 'https://qm.qq.com/q/IDpQnbGd06' },
|
||||
{ icon: 'beatsbydre', link: 'https://shiqianjiang.cn' },
|
||||
{ icon: 'bilibili', link: 'https://space.bilibili.com/696709986' }
|
||||
|
||||
],
|
||||
footer: {
|
||||
message: 'Released under the Apache License 2.0 License.',
|
||||
copyright: `Copyright © 2025-${new Date().getFullYear()} 时迁酱`
|
||||
},
|
||||
editLink: {
|
||||
pattern: 'https://github.com/timeshiftsauce/CeruMusic/edit/main/docs/:path'
|
||||
},
|
||||
search: {
|
||||
provider: 'local'
|
||||
}
|
||||
},
|
||||
lastUpdated: true,
|
||||
head: [['link', { rel: 'icon', href: (process.env.BASE_URL ?? '/CeruMusic/') + 'logo.svg' }]]
|
||||
})
|
||||
// Smooth scrolling functions
|
||||
80
docs/.vitepress/theme/MyLayout.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { useRouter, useData } from 'vitepress'
|
||||
import { toggleDark } from './dark'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import { watch, ref } from 'vue'
|
||||
|
||||
const { route } = useRouter()
|
||||
const isTransitioning = ref(false)
|
||||
const { Layout } = DefaultTheme
|
||||
const { isDark } = useData()
|
||||
|
||||
toggleDark(isDark)
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
isTransitioning.value = true
|
||||
// 动画结束后重置状态
|
||||
setTimeout(() => {
|
||||
isTransitioning.value = false
|
||||
}, 500) // 500ms 要和 CSS 动画时间匹配
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout> </Layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* .shade {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: rgb(255, 255, 255);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.shade-active {
|
||||
opacity: 0;
|
||||
animation: shadeAnimation 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shadeAnimation {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(100vh);
|
||||
}
|
||||
} */
|
||||
#VPContent.vp-doc > div {
|
||||
animation:
|
||||
rises 1s,
|
||||
looming 0.6s;
|
||||
}
|
||||
@keyframes rises {
|
||||
0% {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes looming {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
docs/.vitepress/theme/dark.css
Normal file
@@ -0,0 +1,21 @@
|
||||
::view-transition-old(*) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-new(*) {
|
||||
animation: globalDark .5s ease-in;
|
||||
}
|
||||
|
||||
@keyframes globalDark {
|
||||
from {
|
||||
clip-path: circle(0% at var(--darkX) var(--darkY));
|
||||
}
|
||||
|
||||
to {
|
||||
clip-path: circle(100% at var(--darkX) var(--darkY));
|
||||
}
|
||||
}
|
||||
|
||||
.dark img {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
23
docs/.vitepress/theme/dark.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { nextTick, provide } from 'vue'
|
||||
// 判断是否能使用 startViewTransition
|
||||
const enableTransitions = () => {
|
||||
return 'startViewTransition' in document && window.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
||||
}
|
||||
// 切换动画
|
||||
export const toggleDark = (isDark: any) => {
|
||||
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||
//如果不支持动效直接切换
|
||||
if (!enableTransitions()) {
|
||||
isDark.value = !isDark.value
|
||||
return
|
||||
}
|
||||
document.documentElement.style.setProperty('--darkX', x + 'px')
|
||||
document.documentElement.style.setProperty('--darkY', y + 'px')
|
||||
// 原生的视图转换动画 https://developer.mozilla.org/zh-CN/docs/Web/API/Document/startViewTransition
|
||||
// pnpm add -D @types/dom-view-transitions 解决 document.startViewTransition 类型错误的问题
|
||||
await document.startViewTransition(async () => {
|
||||
isDark.value = !isDark.value
|
||||
await nextTick()
|
||||
}).ready
|
||||
})
|
||||
}
|
||||
16
docs/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import './style.css'
|
||||
import './dark.css'
|
||||
import MyLayout from './MyLayout.vue';
|
||||
// history.scrollRestoration = 'manual'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: MyLayout,
|
||||
enhanceApp({ app, router, siteData }) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
BIN
docs/.vitepress/theme/phycat/Cascadia-Code-Regular.ttf
Normal file
BIN
docs/.vitepress/theme/phycat/HarmonyOS_Sans_SC_Bold.woff
Normal file
BIN
docs/.vitepress/theme/phycat/HarmonyOS_Sans_SC_Regular.woff
Normal file
1176
docs/.vitepress/theme/phycat/phycat.dark.css
Normal file
1213
docs/.vitepress/theme/phycat/phycat.light.css
Normal file
266
docs/.vitepress/theme/style.css
Normal file
@@ -0,0 +1,266 @@
|
||||
@import url(./phycat/phycat.light.css);
|
||||
@import url(./phycat/phycat.dark.css);
|
||||
/**
|
||||
* Customize default theme styling by overriding CSS variables:
|
||||
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
|
||||
*/
|
||||
|
||||
/**
|
||||
* Colors
|
||||
*
|
||||
* Each colors have exact same color scale system with 3 levels of solid
|
||||
* colors with different brightness, and 1 soft color.
|
||||
*
|
||||
* - `XXX-1`: The most solid color used mainly for colored text. It must
|
||||
* satisfy the contrast ratio against when used on top of `XXX-soft`.
|
||||
*
|
||||
* - `XXX-2`: The color used mainly for hover state of the button.
|
||||
*
|
||||
* - `XXX-3`: The color for solid background, such as bg color of the button.
|
||||
* It must satisfy the contrast ratio with pure white (#ffffff) text on
|
||||
* top of it.
|
||||
*
|
||||
* - `XXX-soft`: The color used for subtle background such as custom container
|
||||
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
|
||||
* on top of it.
|
||||
*
|
||||
* The soft color must be semi transparent alpha channel. This is crucial
|
||||
* because it allows adding multiple "soft" colors on top of each other
|
||||
* to create an accent, such as when having inline code block inside
|
||||
* custom containers.
|
||||
*
|
||||
* - `default`: The color used purely for subtle indication without any
|
||||
* special meanings attached to it such as bg color for menu hover state.
|
||||
*
|
||||
* - `brand`: Used for primary brand colors, such as link text, button with
|
||||
* brand theme, etc.
|
||||
*
|
||||
* - `tip`: Used to indicate useful information. The default theme uses the
|
||||
* brand color for this by default.
|
||||
*
|
||||
* - `warning`: Used to indicate warning to the users. Used in custom
|
||||
* container, badges, etc.
|
||||
*
|
||||
* - `danger`: Used to show error, or dangerous message to the users. Used
|
||||
* in custom container, badges, etc.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
html.dark #app {
|
||||
--vp-nav-bg-color: #000000a7 !important;
|
||||
}
|
||||
.VPNavBar:not(.VPNavBar.top) {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-c-indigo-1: #14a5c2;
|
||||
--vp-c-indigo-2: #21b1ce;
|
||||
--vp-c-indigo-3: #4fcbe4;
|
||||
--vp-nav-bg-color: #ffffffa7;
|
||||
--vp-c-default-1: var(--vp-c-gray-1);
|
||||
--vp-c-default-2: var(--vp-c-gray-2);
|
||||
--vp-c-default-3: var(--vp-c-gray-3);
|
||||
--vp-c-default-soft: var(--vp-c-gray-soft);
|
||||
|
||||
--vp-c-brand-1: var(--vp-c-indigo-1);
|
||||
--vp-c-brand-2: var(--vp-c-indigo-2);
|
||||
--vp-c-brand-3: var(--vp-c-indigo-3);
|
||||
--vp-c-brand-soft: var(--vp-c-indigo-soft);
|
||||
|
||||
--vp-c-tip-1: var(--vp-c-brand-1);
|
||||
--vp-c-tip-2: var(--vp-c-brand-2);
|
||||
--vp-c-tip-3: var(--vp-c-brand-3);
|
||||
--vp-c-tip-soft: var(--vp-c-brand-soft);
|
||||
|
||||
--vp-c-warning-1: var(--vp-c-yellow-1);
|
||||
--vp-c-warning-2: var(--vp-c-yellow-2);
|
||||
--vp-c-warning-3: var(--vp-c-yellow-3);
|
||||
--vp-c-warning-soft: var(--vp-c-yellow-soft);
|
||||
|
||||
--vp-c-danger-1: var(--vp-c-red-1);
|
||||
--vp-c-danger-2: var(--vp-c-red-2);
|
||||
--vp-c-danger-3: var(--vp-c-red-3);
|
||||
--vp-c-danger-soft: var(--vp-c-red-soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Button
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-button-brand-border: transparent;
|
||||
--vp-button-brand-text: var(--vp-c-white);
|
||||
--vp-button-brand-bg: var(--vp-c-brand-3);
|
||||
--vp-button-brand-hover-border: transparent;
|
||||
--vp-button-brand-hover-text: var(--vp-c-white);
|
||||
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
|
||||
--vp-button-brand-active-border: transparent;
|
||||
--vp-button-brand-active-text: var(--vp-c-white);
|
||||
--vp-button-brand-active-bg: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Home
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, #5dd6cc 30%, #b8f1cc);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(-45deg, #b8f1cf 50%, #47caff 50%);
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(68px);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Custom Block
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-custom-block-tip-border: transparent;
|
||||
--vp-custom-block-tip-text: var(--vp-c-text-1);
|
||||
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
|
||||
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
|
||||
}
|
||||
/**
|
||||
* Component: Algolia
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
.DocSearch {
|
||||
--docsearch-primary-color: var(--vp-c-brand-1) !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 标题后小图标,借鉴自思源笔记主题——Savor */
|
||||
--h1-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.8 29.714v0c-1.371 0-2.514-1.143-2.514-2.514v0c0-1.371 1.143-2.514 2.514-2.514v0c1.371 0 2.514 1.143 2.514 2.514v0c0.114 1.371-1.029 2.514-2.514 2.514z'/></svg>")
|
||||
no-repeat center;
|
||||
--h2-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h3-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='28' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h4-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 22.857c1.257 0 2.286-1.029 2.286-2.286s-1.029-2.286-2.286-2.286-2.286 1.029-2.286 2.286 1.029 2.286 2.286 2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h5-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 22.857c1.257 0 2.286-1.029 2.286-2.286s-1.029-2.286-2.286-2.286-2.286 1.029-2.286 2.286 1.029 2.286 2.286 2.286zM4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 11.429c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
--h6-r-graphic: url("data:image/svg+xml;utf8,<svg fill='rgba(74, 200, 141, 0.5)' height='24' viewBox='0 0 32 32' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M4.571 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM4.571 11.429c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 18.286c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 25.143c-1.257 0-2.286 1.029-2.286 2.286s1.029 2.286 2.286 2.286 2.286-1.029 2.286-2.286-1.029-2.286-2.286-2.286zM11.429 16c1.257 0 2.286-1.029 2.286-2.286s-1.029-2.286-2.286-2.286-2.286 1.029-2.286 2.286 1.029 2.286 2.286 2.286z'/></svg>")
|
||||
no-repeat center;
|
||||
|
||||
/* 是否开启网格背景?1 是;0 否 */
|
||||
--bg-grid: 0;
|
||||
|
||||
/* 已完成的代办事项是否显示删除线?1 是;0 否 */
|
||||
--check-line: 1;
|
||||
|
||||
/* 自动编号格式设置 无需自动编号可全部注释掉或部分注释掉*/
|
||||
/* --autonum-h1: counter(h1) ". ";
|
||||
--autonum-h2: counter(h1) "." counter(h2) ". ";
|
||||
--autonum-h3: counter(h1) "." counter(h2) "." counter(h3) ". ";
|
||||
--autonum-h4: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) ". ";
|
||||
--autonum-h5: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". ";
|
||||
--autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". "; */
|
||||
|
||||
/* 下面是文章内Toc目录自动编号,与上面一样即可 */
|
||||
/* --autonum-h1toc: counter(h1toc) ". ";
|
||||
--autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
|
||||
--autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
|
||||
--autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
|
||||
--autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
|
||||
--autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". "; */
|
||||
|
||||
/* 主题颜色 */
|
||||
|
||||
--head-title-color: #3db8bf;
|
||||
/* 标题主色 */
|
||||
--head-title-h2-color: #fff;
|
||||
--head-title-h2-background: linear-gradient(to right, #3db8d3, #80f7c4);
|
||||
/* 二级标题主色,因为二级标题是背景色的,所以单独设置 */
|
||||
|
||||
--element-color: #3db8bf;
|
||||
/* 元素主色 */
|
||||
--element-color-deep: #089ba3;
|
||||
/* 元素深色 */
|
||||
--element-color-shallow: #7aeaf0;
|
||||
/* 元素浅色 */
|
||||
--element-color-so-shallow: #7aeaf077;
|
||||
/* 元素很浅色 */
|
||||
--element-color-soo-shallow: #7aeaf018;
|
||||
/* 元素非常浅色 */
|
||||
|
||||
--element-color-linecode: #089ba3;
|
||||
/* 行内代码文字色 */
|
||||
--element-color-linecode-background: #7aeaf018;
|
||||
/* 行内代码背景色 */
|
||||
|
||||
/* 程序本体UI */
|
||||
--appui-color: #3db8bf;
|
||||
/* 程序UI主题色 */
|
||||
--appui-color-icon: #3db8bf;
|
||||
/* 程序UI图标颜色 */
|
||||
--appui-color-text: #333;
|
||||
/* 程序UI文字色 */
|
||||
--primary-color: #3db8bf;
|
||||
}
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
/**
|
||||
* 黑暗模式切换动画
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
#VPContent .vp-doc > div {
|
||||
animation:
|
||||
rises 1s,
|
||||
looming 1s;
|
||||
}
|
||||
|
||||
@keyframes rises {
|
||||
0% {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes looming {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
.vp-doc li div[class*='language-'] {
|
||||
margin: 12px;
|
||||
}
|
||||
html.dark .vp-doc div[class*='language-'],
|
||||
html.dark .vp-doc div[class*='language-'] pre {
|
||||
background-color: #222222;
|
||||
}
|
||||
html .vp-doc div[class*='language-'],
|
||||
html .vp-doc div[class*='language-'] pre {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
#app div[class^='language'] {
|
||||
border-radius: 1em;
|
||||
overflow: hidden;
|
||||
padding: 0.4em;
|
||||
}
|
||||
BIN
docs/assets/head.jpg
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
docs/assets/image-20250827175023917.png
Normal file
|
After Width: | Height: | Size: 719 KiB |
BIN
docs/assets/image-20250827175109430.png
Normal file
|
After Width: | Height: | Size: 981 KiB |
BIN
docs/assets/image-20250827175356006.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/assets/image-20250827175547444.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
docs/assets/image-20250827181535681.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/assets/image-20250827181604432.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
docs/assets/image-20250827181634134.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
10
docs/assets/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="1200" rx="379" fill="white"/>
|
||||
<path d="M957.362 204.197C728.535 260.695 763.039 192.264 634.41 175.368C451.817 151.501 504.125 315.925 504.125 315.925L630.545 673.497C591.211 654.805 544.287 643.928 494.188 643.928C353.275 643.928 239 729.467 239 834.964C239 940.567 353.137 1026 494.188 1026C635.1 1026 749.375 940.461 749.375 834.964C749.375 832.218 749.237 829.473 749.099 826.727C749.513 825.988 749.789 825.143 750.065 824.087C757.932 789.449 634.272 348.345 634.272 348.345C634.272 348.345 764.971 401.886 860.89 351.936C971.163 294.699 964.953 202.402 957.362 204.197Z" fill="url(#paint0_linear_4_16)" stroke="#29293A" stroke-opacity="0.23"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4_16" x1="678.412" y1="-1151.29" x2="796.511" y2="832.071" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.572115" stop-color="#B8F1ED"/>
|
||||
<stop offset="0.9999" stop-color="#B8F1CC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,225 +0,0 @@
|
||||
# 音频发布-订阅模式使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
这个改进的发布-订阅模式解决了原有实现中无法单个删除订阅者的问题。新的实现提供了以下特性:
|
||||
|
||||
- ✅ 支持单个订阅者的精确取消
|
||||
- ✅ 自动生成唯一订阅ID
|
||||
- ✅ 类型安全的事件系统
|
||||
- ✅ 错误处理和日志记录
|
||||
- ✅ 内存泄漏防护
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 精确的订阅管理
|
||||
|
||||
每个订阅都会返回一个取消订阅函数,调用该函数即可精确取消对应的订阅:
|
||||
|
||||
```typescript
|
||||
// 订阅事件
|
||||
const unsubscribe = audioStore.subscribe('ended', () => {
|
||||
console.log('音频播放结束')
|
||||
})
|
||||
|
||||
// 取消订阅
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
### 2. 支持的事件类型
|
||||
|
||||
- `ended`: 音频播放结束
|
||||
- `seeked`: 音频拖拽完成
|
||||
- `timeupdate`: 音频时间更新
|
||||
- `play`: 音频开始播放
|
||||
- `pause`: 音频暂停播放
|
||||
|
||||
### 3. 类型安全
|
||||
|
||||
所有的事件类型和回调函数都有完整的TypeScript类型定义,确保编译时类型检查。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基础订阅
|
||||
|
||||
```typescript
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
|
||||
const audioStore = ControlAudioStore()
|
||||
|
||||
// 订阅播放结束事件
|
||||
const unsubscribeEnded = audioStore.subscribe('ended', () => {
|
||||
console.log('音频播放结束了')
|
||||
})
|
||||
|
||||
// 订阅时间更新事件
|
||||
const unsubscribeTimeUpdate = audioStore.subscribe('timeupdate', () => {
|
||||
console.log('当前时间:', audioStore.Audio.currentTime)
|
||||
})
|
||||
```
|
||||
|
||||
### 在Vue组件中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, onUnmounted } from 'vue'
|
||||
import type { AudioSubscribeMethod, UnsubscribeFunction } from '@renderer/types/audio'
|
||||
|
||||
// 注入订阅方法
|
||||
const audioSubscribe = inject<AudioSubscribeMethod>('audioSubscribe')
|
||||
|
||||
// 存储取消订阅函数
|
||||
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||
|
||||
onMounted(() => {
|
||||
if (!audioSubscribe) return
|
||||
|
||||
// 订阅多个事件
|
||||
unsubscribeFunctions.push(
|
||||
audioSubscribe('play', () => console.log('开始播放')),
|
||||
audioSubscribe('pause', () => console.log('暂停播放')),
|
||||
audioSubscribe('ended', () => console.log('播放结束'))
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 组件卸载时取消所有订阅
|
||||
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 条件订阅和取消
|
||||
|
||||
```typescript
|
||||
let endedUnsubscribe: UnsubscribeFunction | null = null
|
||||
|
||||
// 条件订阅
|
||||
const subscribeToEnded = () => {
|
||||
if (!endedUnsubscribe) {
|
||||
endedUnsubscribe = audioStore.subscribe('ended', handleAudioEnded)
|
||||
}
|
||||
}
|
||||
|
||||
// 条件取消订阅
|
||||
const unsubscribeFromEnded = () => {
|
||||
if (endedUnsubscribe) {
|
||||
endedUnsubscribe()
|
||||
endedUnsubscribe = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 批量管理订阅
|
||||
|
||||
```typescript
|
||||
// 清空特定事件的所有订阅者
|
||||
audioStore.clearEventSubscribers('ended')
|
||||
|
||||
// 清空所有事件的所有订阅者
|
||||
audioStore.clearAllSubscribers()
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
系统内置了错误处理机制,如果某个回调函数执行出错,不会影响其他订阅者:
|
||||
|
||||
```typescript
|
||||
audioStore.subscribe('ended', () => {
|
||||
throw new Error('这个错误不会影响其他订阅者')
|
||||
})
|
||||
|
||||
audioStore.subscribe('ended', () => {
|
||||
console.log('这个回调仍然会正常执行')
|
||||
})
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 及时清理订阅
|
||||
|
||||
```typescript
|
||||
// ✅ 好的做法:组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
|
||||
})
|
||||
|
||||
// ❌ 不好的做法:忘记清理,可能导致内存泄漏
|
||||
```
|
||||
|
||||
### 2. 使用数组管理多个订阅
|
||||
|
||||
```typescript
|
||||
// ✅ 好的做法:统一管理
|
||||
const unsubscribeFunctions: UnsubscribeFunction[] = []
|
||||
|
||||
unsubscribeFunctions.push(
|
||||
audioStore.subscribe('play', handlePlay),
|
||||
audioStore.subscribe('pause', handlePause)
|
||||
)
|
||||
|
||||
// 统一清理
|
||||
unsubscribeFunctions.forEach((fn) => fn())
|
||||
```
|
||||
|
||||
### 3. 避免在高频事件中执行重操作
|
||||
|
||||
```typescript
|
||||
// ❌ 不好的做法:在timeupdate中执行重操作
|
||||
audioStore.subscribe('timeupdate', () => {
|
||||
// 这会每秒执行多次,影响性能
|
||||
updateComplexUI()
|
||||
})
|
||||
|
||||
// ✅ 好的做法:使用节流或防抖
|
||||
let lastUpdate = 0
|
||||
audioStore.subscribe('timeupdate', () => {
|
||||
const now = Date.now()
|
||||
if (now - lastUpdate > 100) {
|
||||
// 限制更新频率
|
||||
updateUI()
|
||||
lastUpdate = now
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从旧版本迁移
|
||||
|
||||
旧版本:
|
||||
|
||||
```typescript
|
||||
// 旧的实现方式
|
||||
provide('setAudioEnd', setEndCallback)
|
||||
|
||||
function setEndCallback(fn: Function): void {
|
||||
endCallback.push(fn)
|
||||
}
|
||||
```
|
||||
|
||||
新版本:
|
||||
|
||||
```typescript
|
||||
// 新的实现方式
|
||||
provide('audioSubscribe', audioStore.subscribe)
|
||||
|
||||
// 使用时
|
||||
const unsubscribe = audioSubscribe('ended', () => {
|
||||
// 处理播放结束
|
||||
})
|
||||
|
||||
// 可以精确取消
|
||||
unsubscribe()
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
1. **避免重复订阅**:在订阅前检查是否已经订阅
|
||||
2. **及时取消订阅**:组件卸载或不再需要时立即取消
|
||||
3. **合理使用事件**:避免在高频事件中执行重操作
|
||||
4. **批量操作**:需要清理多个订阅时使用批量清理方法
|
||||
|
||||
这个改进的发布-订阅模式为Ceru Music应用提供了更加灵活和可靠的音频事件管理机制。
|
||||
@@ -1,121 +0,0 @@
|
||||
# 自动更新功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本项目集成了完整的自动更新功能,使用 Electron 的 `autoUpdater` 模块和 TDesign 的通知组件,为用户提供友好的更新体验。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 主进程 (Main Process)
|
||||
|
||||
1. **autoUpdate.ts** - 自动更新核心逻辑
|
||||
- 配置更新服务器地址
|
||||
- 监听 autoUpdater 事件
|
||||
- 通过 IPC 向渲染进程发送更新消息
|
||||
|
||||
2. **events/autoUpdate.ts** - IPC 事件处理
|
||||
- 注册检查更新和安装更新的 IPC 处理器
|
||||
|
||||
### 渲染进程 (Renderer Process)
|
||||
|
||||
1. **services/autoUpdateService.ts** - 更新服务
|
||||
- 处理来自主进程的更新消息
|
||||
- 使用 TDesign Notification 显示更新通知
|
||||
- 管理更新状态和用户交互
|
||||
|
||||
2. **composables/useAutoUpdate.ts** - Vue 组合式函数
|
||||
- 封装自动更新功能,便于在组件中使用
|
||||
- 管理监听器的生命周期
|
||||
|
||||
3. **components/Settings/UpdateSettings.vue** - 更新设置组件
|
||||
- 提供手动检查更新的界面
|
||||
- 显示当前版本信息
|
||||
|
||||
## 更新流程
|
||||
|
||||
1. **启动检查**: 应用启动后延迟3秒自动检查更新
|
||||
2. **检查更新**: 向更新服务器发送请求检查新版本
|
||||
3. **下载更新**: 如有新版本,自动下载更新包
|
||||
4. **安装提示**: 下载完成后提示用户重启安装
|
||||
5. **自动安装**: 用户确认后退出应用并安装更新
|
||||
|
||||
## 通知类型
|
||||
|
||||
- **检查更新**: 显示正在检查更新的信息通知
|
||||
- **发现新版本**: 显示发现新版本并开始下载的成功通知
|
||||
- **无需更新**: 显示当前已是最新版本的信息通知
|
||||
- **下载进度**: 实时显示下载进度和速度
|
||||
- **下载完成**: 显示下载完成并提供重启按钮
|
||||
- **更新错误**: 显示更新过程中的错误信息
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 更新服务器配置
|
||||
|
||||
在 `src/main/autoUpdate.ts` 中配置更新服务器地址:
|
||||
|
||||
```typescript
|
||||
const server = 'https://update.ceru.shiqianjiang.cn/';
|
||||
```
|
||||
|
||||
### 版本检查
|
||||
|
||||
更新服务器需要提供以下格式的 API:
|
||||
- URL: `${server}/update/${platform}/${currentVersion}`
|
||||
- 返回: 更新信息 JSON
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 在组件中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAutoUpdate } from '@/composables/useAutoUpdate'
|
||||
|
||||
const { checkForUpdates } = useAutoUpdate()
|
||||
|
||||
// 手动检查更新
|
||||
const handleCheckUpdate = async () => {
|
||||
await checkForUpdates()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 监听更新消息
|
||||
|
||||
```typescript
|
||||
import { autoUpdateService } from '@/services/autoUpdateService'
|
||||
|
||||
// 开始监听
|
||||
autoUpdateService.startListening()
|
||||
|
||||
// 停止监听
|
||||
autoUpdateService.stopListening()
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **权限要求**: 自动更新需要应用具有写入权限
|
||||
2. **网络连接**: 需要稳定的网络连接来下载更新
|
||||
3. **用户体验**: 更新过程中避免强制重启,给用户选择权
|
||||
4. **错误处理**: 妥善处理网络错误和下载失败的情况
|
||||
|
||||
## 开发调试
|
||||
|
||||
在开发环境中,可以通过以下方式测试自动更新:
|
||||
|
||||
1. 修改 `package.json` 中的版本号
|
||||
2. 在更新设置页面手动触发检查更新
|
||||
3. 观察控制台日志和通知显示
|
||||
|
||||
## 构建配置
|
||||
|
||||
确保在 `electron-builder` 配置中启用自动更新:
|
||||
|
||||
```json
|
||||
{
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://update.ceru.shiqianjiang.cn/"
|
||||
}
|
||||
}
|
||||
1587
docs/design.html
@@ -1,3 +1,7 @@
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# CeruMusic 插件开发文档
|
||||
|
||||
## 概述
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
layout: doc
|
||||
---
|
||||
|
||||
# CeruMusicPluginHost 使用文档
|
||||
|
||||
## 概述
|
||||
@@ -109,9 +113,7 @@ try {
|
||||
|
||||
### 构造函数
|
||||
|
||||
```javascript
|
||||
new CeruMusicPluginHost(pluginCode?)
|
||||
```
|
||||
`new CeruMusicPluginHost(pluginCode)`
|
||||
|
||||
**参数:**
|
||||
|
||||
@@ -125,9 +127,9 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
|
||||
**参数:**
|
||||
|
||||
- `pluginPath` (string): 插件文件路径
|
||||
`pluginPath` (string): 插件文件路径
|
||||
|
||||
**返回:** Promise<Object> - 插件导出的对象
|
||||
**返回:** `Promise<Object>` - 插件导出的对象
|
||||
|
||||
#### getPluginInfo()
|
||||
|
||||
@@ -151,7 +153,7 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
- `musicInfo` (Object): 歌曲信息对象
|
||||
- `quality` (string): 音质标识
|
||||
|
||||
**返回:** Promise<string> - 音乐播放链接
|
||||
**返回:** `Promise<string>` - 音乐播放链接
|
||||
|
||||
#### getPic(source, musicInfo)
|
||||
|
||||
@@ -162,7 +164,7 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
- `source` (string): 音源标识
|
||||
- `musicInfo` (Object): 歌曲信息对象
|
||||
|
||||
**返回:** Promise<string> - 封面链接
|
||||
**返回:** `Promise<string>` - 封面链接
|
||||
|
||||
#### getLyric(source, musicInfo)
|
||||
|
||||
@@ -173,7 +175,7 @@ new CeruMusicPluginHost(pluginCode?)
|
||||
- `source` (string): 音源标识
|
||||
- `musicInfo` (Object): 歌曲信息对象
|
||||
|
||||
**返回:** Promise<string> - 歌词内容
|
||||
**返回:** `Promise<string>` - 歌词内容
|
||||
|
||||
## 插件环境
|
||||
|
||||
@@ -11,7 +11,7 @@ Ceru Music 是一个基于 Electron + Vue 3 的跨平台桌面音乐播放器,
|
||||
- **前端框架**: Vue 3 + TypeScript + Composition API
|
||||
- **桌面框架**: Electron (v37.2.3)
|
||||
- **UI组件库**: TDesign Vue Next (v1.15.2)
|
||||
- 
|
||||
- 
|
||||
- **状态管理**: Pinia (v3.0.3)
|
||||
- **路由管理**: Vue Router (v4.5.1)
|
||||
- **构建工具**: Vite + electron-vite
|
||||
@@ -397,7 +397,7 @@ export const useAppStore = defineStore('app', {
|
||||
|
||||
### 欢迎页面设计
|
||||
|
||||

|
||||

|
||||
|
||||
```vue
|
||||
<template>
|
||||
@@ -456,7 +456,7 @@ function skipWelcome() {
|
||||
|
||||
##### 界面UI参考
|
||||
|
||||
下载安装使用
|
||||
|
||||
### Window 安装
|
||||
|
||||
由于没有证书原因 **`Window`** 平台可能会出现安装包体误报**危险**。请放心我们的软件都是**开源**在 `Github` 自动化打包的。**具体安装步骤如下**
|
||||
|
||||
<img src="../assets/image-20250826214921963.png" alt="image-20250826214921963" style="zoom: 50%;" />如果出现类似图例效果请先点击 **右侧 三个点**
|
||||
|
||||
<img src="../assets/image-20250826215101522.png" alt="image-20250826215101522" style="zoom:50%;" />**点击保留**
|
||||
|
||||
<img src="../assets/image-20250826215206862.png" alt="image-20250826215206862" style="zoom:50%;" />**点击下拉按钮**
|
||||
|
||||
<img src="../assets/image-20250826215251525.png" alt="image-20250826215251525" style="zoom:50%;" />**任然保留**就可以双击打开安装到此教程结束
|
||||
|
||||
### Mac OS 系统下载安装
|
||||
|
||||
由于同样没有**签名**的原因mac的护栏也会拦截提示安装包损坏
|
||||
|
||||
<img src="../assets/3f50d3b838287b4bf1523d0f955fdf37.png" alt="3f50d3b838287b4bf1523d0f955fdf37" style="zoom:50%;" />请不用担心这是典型的签名问题
|
||||
|
||||
适用于 macOS 14 Sonoma 及以上版本。
|
||||
|
||||
注意:由于我们不提供经过签名的程序包体,因此在安装后首次运行可能会出现 “**澜音** 已损坏” 之类的提示,此时只需打开终端,输入命令
|
||||
|
||||
```bash
|
||||
sudo xattr -r -d com.apple.quarantine /Applications/澜音.app
|
||||
```
|
||||
|
||||
并回车,输入密码再次回车,重新尝试启动程序即可
|
||||
|
||||
_要是还有问题可自行在搜索引擎查询由于 。`apple`官方证书需要99刀的价格实在无能为力见谅_ 如果你有能力成为`澜音`的赞助者可联系
|
||||
|
||||
- QQ:`2115295703`
|
||||
- 微信:`cl_wj0623`
|
||||
|
||||
### 插件安装
|
||||
|
||||
首次进入应用需要在软件右上角设置导入**音源**才能使用可查询`Ceru插件`**(目前生态欠缺)** 或现成的**落雪**插件导入使用
|
||||
|
||||
###### 导入完成点击使用
|
||||
203
docs/index.md
Normal file
@@ -0,0 +1,203 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: 'Ceru Music'
|
||||
text: '澜音 播放器'
|
||||
tagline: 澜音是一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。
|
||||
image:
|
||||
src: '/logo.svg'
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 下载应用
|
||||
link: https://ceru.shiqianjiang.cn/#download
|
||||
target: _blank
|
||||
- theme: alt
|
||||
text: 使用文档
|
||||
link: /guide/
|
||||
|
||||
features:
|
||||
- title: 多平台支持
|
||||
icon: 🚀
|
||||
details: 支持网易云音乐、QQ音乐等多个平台,搜索
|
||||
- title: 跨平台支持
|
||||
icon: 🪟
|
||||
details: 原生桌面应用,支持 Windows、macOS、Linux 三大操作系统
|
||||
- title: 歌词显示
|
||||
icon: 🎼
|
||||
details: 实时歌词显示,支持专辑信息获取,让音乐体验更丰富
|
||||
- title: 优雅界面
|
||||
icon: 💻
|
||||
details: 现代化设计语言,流畅动画效果,为你带来愉悦的视觉体验
|
||||
- title: 代码开源
|
||||
details: 代码完全开源,供给大家使用开发
|
||||
icon: 👐
|
||||
link: https://github.com/timeshiftsauce/CeruMusic
|
||||
---
|
||||
|
||||
<div style="margin-top:4rem"></div>
|
||||
|
||||
# Ceru Music(澜音)
|
||||
|
||||
一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。
|
||||
|
||||
## 项目简介
|
||||
|
||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
||||
|
||||
<img src="./assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="./assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Electron**:用于构建跨平台桌面应用
|
||||
- **Vue 3**:前端框架,提供响应式 UI
|
||||
- **TypeScript**:增强代码可维护性和类型安全
|
||||
- **Pinia**:状态管理工具
|
||||
- **Vite**:快速的前端构建工具
|
||||
- **CeruPlugins**:音乐插件运行环境(仅提供框架,不包含默认插件)
|
||||
- **AMLL**:音乐生态辅助模块
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 提供插件加载与管理功能,支持通过合规插件获取公开音乐信息
|
||||
- 支持通过插件获取歌词、专辑封面等公开元数据
|
||||
- 支持虚拟滚动列表,优化大量数据渲染性能
|
||||
- 本地播放列表管理(仅存储用户手动创建的列表结构,不包含音乐文件)
|
||||
- **提示**:本地数据仅保存在用户设备本地,未进行云端备份,用户需自行备份以防止数据丢失
|
||||
- 精美的用户界面与动画效果
|
||||
- **插件生态框架**(插件需用户自行获取并确保合规性)
|
||||
|
||||
## 安装与使用
|
||||
|
||||
### 推荐开发环境
|
||||
|
||||
- **IDE**: VS Code 或 WebStorm
|
||||
- **Node.js 版本**: 22 及以上
|
||||
- **包管理器**: **yarn**
|
||||
|
||||
### 项目设置
|
||||
|
||||
1. 安装依赖:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
2. 启动开发服务器:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
3. 构建应用:
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 平台构建指令
|
||||
|
||||
- Windows
|
||||
|
||||
```bash
|
||||
yarn build:win
|
||||
```
|
||||
|
||||
- macOS
|
||||
|
||||
```bash
|
||||
yarn build:mac
|
||||
```
|
||||
|
||||
- Linux
|
||||
|
||||
```bash
|
||||
yarn build:linux
|
||||
```
|
||||
|
||||
> 提示:构建后的应用仅包含播放器框架,需用户自行配置合规插件方可获取音乐数据。
|
||||
|
||||
## 文档与资源
|
||||
|
||||
- [产品设计文档](./guide/design):涵盖项目架构、核心功能设计和开发规范(不含任何音乐数据源信息)。
|
||||
- [插件开发文档](./guide/CeruMusicPluginDev):仅提供插件开发技术规范,**明确要求插件开发者需遵守数据来源平台的用户协议与版权法**,禁止开发、传播获取非公开数据的插件。
|
||||
|
||||
## 开源许可
|
||||
|
||||
本项目源代码遵循 **Apache License 2.0**,仅授权用户对项目框架进行学习、修改与二次开发,不包含任何音乐数据相关授权。详情请参阅 [LICENSE](https://github.com/timeshiftsauce/CeruMusic/blob/main/LICENSE) 文件,使用前请务必阅读许可条款。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎开发者贡献代码与反馈建议,贡献内容需符合以下要求:
|
||||
|
||||
1. 仅涉及播放器框架功能优化、bug 修复、文档完善,不包含任何音乐数据源相关代码。
|
||||
2. 遵循 [Git 提交规范](./guide/design#git提交规范) 并确保代码符合项目风格指南。
|
||||
3. 贡献的代码需无第三方版权纠纷,且不违反开源许可协议。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
|
||||
|
||||
## 项目开发者
|
||||
|
||||
- **时迁酱**:产品总体设计与开发
|
||||
|
||||
<img src="./assets/head.jpg" alt="head.jpg (940×940)" style="zoom:15%;" />
|
||||
|
||||
- **无聊的霜霜**:首页设计&Ai助手
|
||||
|
||||
<img src="./assets/image-20250827181604432.png" alt="image-20250827181604432" style="zoom:25%;" />
|
||||
|
||||
- **Star**:**插件管理**相关功能&部分接口封装
|
||||
|
||||
<img src="./assets/image-20250827181535681.png" alt="image-20250827181535681" style="zoom:25%;" />
|
||||
|
||||
**Tips**: 排名不分先后
|
||||
|
||||
# 法律声明与免责条款
|
||||
|
||||
**重要提示:使用本项目前,请务必仔细阅读本条款,使用本项目即视为你已充分理解并同意本条款全部内容。**
|
||||
|
||||
### 一、定义约定
|
||||
|
||||
- “Apache License 2.0”:指 Ceru Music(澜音)桌面播放器框架及源代码,不包含任何第三方插件或音乐数据。
|
||||
- “**用户**”:指下载、安装、使用本项目的个人或组织。
|
||||
- “**合规插件**”:指符合数据来源平台用户协议、不侵犯第三方版权、不获取非公开数据的插件。
|
||||
- “**版权内容**”:指包括但不限于音乐文件、歌词、专辑封面、艺人信息等受著作权法保护的内容。
|
||||
|
||||
### 二、数据与内容责任
|
||||
|
||||
1. 本项目**不直接获取、存储、传输任何音乐数据或版权内容**,仅提供插件运行框架。用户通过插件获取的所有数据,其合法性、准确性由插件提供者及用户**自行负责**,本项目不承担任何责任。
|
||||
2. 若用户使用的插件存在获取非公开数据、侵犯第三方版权等违规行为,相关法律责任由用户及插件提供者承担,与本项目无关。
|
||||
3. 本项目使用的字体、图片等素材,均来自开源社区或已获得合法授权,若存在侵权请联系项目维护者立即移除,本项目将积极配合处理。
|
||||
|
||||
### 三、版权合规要求
|
||||
|
||||
1. 用户承诺:使用本项目时,仅通过合规插件获取音乐相关信息,且获取、使用版权内容的行为符合**《中华人民共和国著作权法》**及相关法律法规,不侵犯**任何第三方**合法权益。
|
||||
2. 用户需知晓:任何未经授权下载、传播、使用受版权保护的音乐文件的行为,均可能构成侵权,需自行承担法律后果。
|
||||
3. 本项目倡导 “尊重版权、支持正版”,提醒用户通过官方音乐平台获取授权音乐服务。
|
||||
|
||||
### 四、免责声明
|
||||
|
||||
1. 因用户使用非合规插件、违反法律法规或第三方协议导致的任何法律责任(包括但不限于侵权赔偿、行政处罚),均由用户自行承担,本项目不承担任何直接、间接、连带或衍生责任。
|
||||
2. 因本项目框架本身的 **bug** 导致的用户设备故障、数据丢失,本项目仅承担在合理范围内的技术修复责任,不承担由此产生的间接损失(如商誉损失、业务中断损失等)。
|
||||
3. 本项目为开源学习项目,不提供商业服务,对用户使用本项目的效果不做任何明示或暗示的保证。
|
||||
|
||||
### 五、使用限制
|
||||
|
||||
1. 本项目仅允许用于**非商业、纯技术学习目的**,禁止用于任何商业运营、盈利活动,禁止修改后用于侵犯第三方权益的场景。
|
||||
2. 禁止在违反当地法律法规、本声明或第三方协议的前提下使用本项目,若用户所在地区禁止此类工具的使用,应立即停止使用。
|
||||
3. 禁止将本项目源代码或构建后的应用,与违规插件捆绑传播,禁止利用本项目从事任何违法违规活动。
|
||||
|
||||
### 六、其他
|
||||
|
||||
1. 本声明的效力、解释及适用,均适用中华人民共和国法律(不含港澳台地区法律)。
|
||||
2. 若用户与本项目维护者就本声明产生争议,应首先通过友好协商解决;协商不成的,任何一方均有权向本项目维护者所在地有管辖权的人民法院提起诉讼。
|
||||
|
||||
## 赞助
|
||||
|
||||
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
||||
<img src="./assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="./assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
||||
|
||||
---
|
||||
10
docs/public/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="1200" rx="379" fill="white"/>
|
||||
<path d="M957.362 204.197C728.535 260.695 763.039 192.264 634.41 175.368C451.817 151.501 504.125 315.925 504.125 315.925L630.545 673.497C591.211 654.805 544.287 643.928 494.188 643.928C353.275 643.928 239 729.467 239 834.964C239 940.567 353.137 1026 494.188 1026C635.1 1026 749.375 940.461 749.375 834.964C749.375 832.218 749.237 829.473 749.099 826.727C749.513 825.988 749.789 825.143 750.065 824.087C757.932 789.449 634.272 348.345 634.272 348.345C634.272 348.345 764.971 401.886 860.89 351.936C971.163 294.699 964.953 202.402 957.362 204.197Z" fill="url(#paint0_linear_4_16)" stroke="#29293A" stroke-opacity="0.23"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_4_16" x1="678.412" y1="-1151.29" x2="796.511" y2="832.071" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0.572115" stop-color="#B8F1ED"/>
|
||||
<stop offset="0.9999" stop-color="#B8F1CC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
51
docs/使用文档.md
@@ -1,51 +0,0 @@
|
||||
# CeruMusic 使用教程
|
||||
|
||||
## 1. 软件下载
|
||||
|
||||
由于我们团段都是个人开发者原因 暂时无能力部署到 `OSS` 承担高下载量的能力,供大家下载只能通过[Github](https://github.com/timeshiftsauce/CeruMusic)下载安装使用
|
||||
|
||||
### Window 安装
|
||||
|
||||
由于没有证书原因 **`Window`** 平台可能会出现安装包体误报**危险**。请放心我们的软件都是**开源**在 `Github` 自动化打包的。**具体安装步骤如下**
|
||||
|
||||
<img src="assets/image-20250826214921963.png" alt="image-20250826214921963" style="zoom: 50%;" />如果出现类似图例效果请先点击 **右侧 三个点**
|
||||
|
||||
|
||||
|
||||
<img src="assets/image-20250826215101522.png" alt="image-20250826215101522" style="zoom:50%;" />**点击保留**
|
||||
|
||||
|
||||
|
||||
<img src="assets/image-20250826215206862.png" alt="image-20250826215206862" style="zoom:50%;" />**点击下拉按钮**
|
||||
|
||||
|
||||
|
||||
<img src="assets/image-20250826215251525.png" alt="image-20250826215251525" style="zoom:50%;" />**任然保留**就可以双击打开安装到此教程结束
|
||||
|
||||
### Mac OS 系统下载安装
|
||||
|
||||
由于同样没有**签名**的原因mac的护栏也会拦截提示安装包损坏
|
||||
|
||||
<img src="assets/3f50d3b838287b4bf1523d0f955fdf37.png" alt="3f50d3b838287b4bf1523d0f955fdf37" style="zoom:50%;" />请不用担心这是典型的签名问题
|
||||
|
||||
适用于 macOS 14 Sonoma 及以上版本。
|
||||
|
||||
注意:由于我们不提供经过签名的程序包体,因此在安装后首次运行可能会出现 “**澜音** 已损坏” 之类的提示,此时只需打开终端,输入命令
|
||||
|
||||
```bash
|
||||
sudo xattr -r -d com.apple.quarantine /Applications/澜音.app
|
||||
```
|
||||
|
||||
并回车,输入密码再次回车,重新尝试启动程序即可
|
||||
|
||||
*要是还有问题可自行在搜索引擎查询由于 。```apple```官方证书需要99刀的价格实在无能为力见谅* 如果你有能力成为`澜音`的赞助者可联系
|
||||
|
||||
- QQ:`2115295703`
|
||||
- 微信:`cl_wj0623`
|
||||
|
||||
### 插件安装
|
||||
|
||||
首次进入应用需要在软件右上角设置导入**音源**才能使用可查询`Ceru插件`**(目前生态欠缺)** 或现成的**落雪**插件导入使用
|
||||
|
||||
###### 导入完成点击使用
|
||||
|
||||
12
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.9",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
"license": "MIT",
|
||||
"homepage": "https://electron-vite.org",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://ceru.docs.shiqianjiang.cn",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache . --fix",
|
||||
@@ -22,7 +22,10 @@
|
||||
"build:mac": "yarn run build && electron-builder --mac --config --publish never",
|
||||
"build:linux": "yarn run build && electron-builder --linux --config --publish never",
|
||||
"build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config",
|
||||
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten"
|
||||
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.4",
|
||||
@@ -42,6 +45,7 @@
|
||||
"@pixi/sprite": "^7.4.3",
|
||||
"@types/needle": "^3.3.0",
|
||||
"NeteaseCloudMusicApi": "^4.27.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.11.0",
|
||||
"color-extraction": "^1.0.8",
|
||||
"crypto-js": "^4.2.0",
|
||||
|
||||
@@ -91,8 +91,10 @@ function createWindow(): void {
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false
|
||||
contextIsolation: false,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
|
||||
})
|
||||
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
||||
|
||||
@@ -190,6 +192,11 @@ ipcMain.handle('service-music-request', async (_, api, args) => {
|
||||
return await musicService.request(api, args)
|
||||
})
|
||||
|
||||
// 获取应用版本号
|
||||
ipcMain.handle('get-app-version', () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
aiEvents(mainWindow)
|
||||
import './events/musicCache'
|
||||
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
|
||||
@@ -197,18 +204,20 @@ import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/auto
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(async () => {
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
|
||||
electronApp.setAppUserModelId('com.cerulean.music')
|
||||
|
||||
// 初始化插件系统
|
||||
try {
|
||||
await pluginService.initializePlugins()
|
||||
console.log('插件系统初始化完成')
|
||||
} catch (error) {
|
||||
console.error('插件系统初始化失败:', error)
|
||||
}
|
||||
setTimeout(async () => {
|
||||
// 初始化插件系统
|
||||
try {
|
||||
await pluginService.initializePlugins()
|
||||
console.log('插件系统初始化完成')
|
||||
} catch (error) {
|
||||
console.error('插件系统初始化失败:', error)
|
||||
}
|
||||
},1000)
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
@@ -217,9 +226,6 @@ app.whenReady().then(async () => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
// 窗口控制 IPC 处理
|
||||
ipcMain.on('window-minimize', () => {
|
||||
const window = BrowserWindow.getFocusedWindow()
|
||||
@@ -277,10 +283,17 @@ app.whenReady().then(async () => {
|
||||
|
||||
createWindow()
|
||||
createTray()
|
||||
|
||||
|
||||
// 注册自动更新事件
|
||||
registerAutoUpdateEvents()
|
||||
|
||||
ipcMain.on('startPing', () => {
|
||||
if (ping) clearInterval(ping)
|
||||
console.log('start-----开始')
|
||||
startPing()
|
||||
})
|
||||
ipcMain.on('stopPing', () => {
|
||||
clearInterval(ping)
|
||||
})
|
||||
// 初始化自动更新器
|
||||
if (mainWindow) {
|
||||
initAutoUpdateForWindow(mainWindow)
|
||||
@@ -306,3 +319,60 @@ app.on('before-quit', () => {
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
|
||||
let ping: NodeJS.Timeout
|
||||
function startPing() {
|
||||
let interval = 3000
|
||||
|
||||
ping = setInterval(() => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents
|
||||
.executeJavaScript(
|
||||
`
|
||||
(function() {
|
||||
const audio = document.getElementById("globaAudio");
|
||||
if(!audio) return { playing:false, ended: false };
|
||||
|
||||
if(audio.ended) return { playing:false, ended: true };
|
||||
|
||||
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
|
||||
})()
|
||||
`
|
||||
)
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
if (res.duration - res.currentTime <= 20) {
|
||||
clearInterval(ping)
|
||||
interval = 500
|
||||
ping = setInterval(() => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents
|
||||
.executeJavaScript(
|
||||
`
|
||||
(function() {
|
||||
const audio = document.getElementById("globaAudio");
|
||||
if(!audio) return { playing:false, ended: false };
|
||||
|
||||
if(audio.ended) return { playing:false, ended: true };
|
||||
|
||||
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
|
||||
})()
|
||||
`
|
||||
)
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
if (res && res.ended) {
|
||||
mainWindow?.webContents.send('song-ended')
|
||||
console.log('next song')
|
||||
clearInterval(ping)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn(err))
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.warn(err))
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
@@ -407,6 +407,7 @@ export default {
|
||||
return result.list[0].global_collection_id
|
||||
},
|
||||
|
||||
|
||||
async getUserListDetailByLink({ info }, link) {
|
||||
let listInfo = info['0']
|
||||
let total = listInfo.count
|
||||
|
||||
6
src/preload/index.d.ts
vendored
@@ -44,7 +44,11 @@ interface CustomAPI {
|
||||
loadAllPlugins: () => Promise<any>
|
||||
getPluginLog: (pluginId: string) => Promise<any>
|
||||
}
|
||||
|
||||
ping: (callback: Function<any>) => undefined
|
||||
pingService: {
|
||||
start: () => undefined,
|
||||
stop: () => undefined
|
||||
}
|
||||
// 用户配置API
|
||||
getUserConfig: () => Promise<any>
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ const api = {
|
||||
checkForUpdates: () => ipcRenderer.invoke('auto-updater:check-for-updates'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('auto-updater:download-update'),
|
||||
quitAndInstall: () => ipcRenderer.invoke('auto-updater:quit-and-install'),
|
||||
|
||||
|
||||
// 监听更新事件
|
||||
onCheckingForUpdate: (callback: () => void) => {
|
||||
ipcRenderer.on('auto-updater:checking-for-update', callback);
|
||||
@@ -97,7 +97,7 @@ const api = {
|
||||
onDownloadStarted: (callback: (updateInfo: any) => void) => {
|
||||
ipcRenderer.on('auto-updater:download-started', (_, updateInfo) => callback(updateInfo));
|
||||
},
|
||||
|
||||
|
||||
// 移除所有监听器
|
||||
removeAllListeners: () => {
|
||||
ipcRenderer.removeAllListeners('auto-updater:checking-for-update');
|
||||
@@ -108,6 +108,11 @@ const api = {
|
||||
ipcRenderer.removeAllListeners('auto-updater:update-downloaded');
|
||||
ipcRenderer.removeAllListeners('auto-updater:error');
|
||||
}
|
||||
},
|
||||
ping: (callbaack: Function) => ipcRenderer.on('song-ended', () => callbaack()),
|
||||
pingService: {
|
||||
start: () => { ipcRenderer.send('startPing'); console.log('eventStart') },
|
||||
stop: () => { ipcRenderer.send('stopPing') }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
src/renderer/components.d.ts
vendored
@@ -8,6 +8,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AIFloatBallSettings: typeof import('./src/components/Settings/AIFloatBallSettings.vue')['default']
|
||||
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
|
||||
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
|
||||
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
|
||||
@@ -20,24 +21,8 @@ declare module 'vue' {
|
||||
SearchComponent: typeof import('./src/components/Search/SearchComponent.vue')['default']
|
||||
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
|
||||
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
|
||||
TAlert: typeof import('tdesign-vue-next')['Alert']
|
||||
TAside: typeof import('tdesign-vue-next')['Aside']
|
||||
TButton: typeof import('tdesign-vue-next')['Button']
|
||||
TCard: typeof import('tdesign-vue-next')['Card']
|
||||
TContent: typeof import('tdesign-vue-next')['Content']
|
||||
TDialog: typeof import('tdesign-vue-next')['Dialog']
|
||||
ThemeDemo: typeof import('./src/components/ThemeDemo.vue')['default']
|
||||
ThemeSelector: typeof import('./src/components/ThemeSelector.vue')['default']
|
||||
TIcon: typeof import('tdesign-vue-next')['Icon']
|
||||
TImage: typeof import('tdesign-vue-next')['Image']
|
||||
TInput: typeof import('tdesign-vue-next')['Input']
|
||||
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
|
||||
TLayout: typeof import('tdesign-vue-next')['Layout']
|
||||
TLoading: typeof import('tdesign-vue-next')['Loading']
|
||||
TRadioButton: typeof import('tdesign-vue-next')['RadioButton']
|
||||
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
|
||||
TSlider: typeof import('tdesign-vue-next')['Slider']
|
||||
TTooltip: typeof import('tdesign-vue-next')['Tooltip']
|
||||
UpdateExample: typeof import('./src/components/UpdateExample.vue')['default']
|
||||
UpdateProgress: typeof import('./src/components/UpdateProgress.vue')['default']
|
||||
UpdateSettings: typeof import('./src/components/Settings/UpdateSettings.vue')['default']
|
||||
|
||||
@@ -17,7 +17,7 @@ import './assets/theme/cyan.css'
|
||||
onMounted(() => {
|
||||
userInfo.init()
|
||||
loadSavedTheme()
|
||||
|
||||
|
||||
// 应用启动后延迟3秒检查更新,避免影响启动速度
|
||||
setTimeout(() => {
|
||||
checkForUpdates()
|
||||
@@ -42,23 +42,36 @@ const loadSavedTheme = () => {
|
||||
|
||||
const applyTheme = (themeName) => {
|
||||
const documentElement = document.documentElement
|
||||
|
||||
|
||||
// 移除之前的主题
|
||||
documentElement.removeAttribute('theme-mode')
|
||||
|
||||
|
||||
// 应用新主题(如果不是默认主题)
|
||||
if (themeName !== 'default') {
|
||||
documentElement.setAttribute('theme-mode', themeName)
|
||||
}
|
||||
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('selected-theme', themeName)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<GlobalAudio />
|
||||
<FloatBall />
|
||||
<UpdateProgress />
|
||||
<div class="page">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition :enter-active-class="`animate__animated animate__fadeIn pagesApp`"
|
||||
:leave-active-class="`animate__animated animate__fadeOut pagesApp`">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</router-view>
|
||||
<GlobalAudio />
|
||||
<FloatBall />
|
||||
<UpdateProgress />
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
.pagesApp {
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
@import './icon_font/iconfont.css';
|
||||
:root {
|
||||
--play-bottom-height: 86px;
|
||||
--play-bottom-height: max(min(10vh, 86px), 70px);
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -16,7 +16,7 @@ ul {
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: min(1.4vw, 18px);
|
||||
font-size: min(max(1.4vw, 15px), 18px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -5,9 +5,12 @@ import DOMPurify from 'dompurify'
|
||||
import { Loading as TLoading } from 'tdesign-vue-next'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSettingsStore } from '@renderer/store/Settings'
|
||||
|
||||
const userStore = LocalUserDetailStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { userInfo } = storeToRefs(userStore)
|
||||
const { settings } = storeToRefs(settingsStore)
|
||||
|
||||
const ball = ref<HTMLElement | null>(null)
|
||||
const ballClass = ref('hidden-right') // 默认半隐藏
|
||||
@@ -30,7 +33,7 @@ const windowSize = ref({ width: 0, height: 0 }) // 窗口尺寸
|
||||
|
||||
// 显示悬浮球
|
||||
// 悬浮球可见性控制
|
||||
const isFloatBallVisible = ref(true)
|
||||
const isFloatBallVisible = ref(settings.value.showFloatBall !== false) // 默认显示,除非明确设置为false
|
||||
const isHovering = ref(false)
|
||||
|
||||
const showBall = () => {
|
||||
@@ -42,6 +45,7 @@ const showBall = () => {
|
||||
const closeBall = (e: MouseEvent) => {
|
||||
e.stopPropagation() // 阻止事件冒泡
|
||||
isFloatBallVisible.value = false
|
||||
settingsStore.updateSettings({ showFloatBall: false })
|
||||
}
|
||||
|
||||
// 鼠标进入悬浮球
|
||||
@@ -60,7 +64,7 @@ const handleMouseLeave = () => {
|
||||
const startAutoHide = () => {
|
||||
clearTimer()
|
||||
timer = window.setTimeout(() => {
|
||||
ballClass.value = 'hidden-right'
|
||||
ballClass.value = isOnLeft.value ? 'hidden-left' : 'hidden-right'
|
||||
}, 3000) // 3 秒没操作缩回去
|
||||
}
|
||||
|
||||
@@ -102,11 +106,12 @@ const handleMouseMove = (e: MouseEvent) => {
|
||||
|
||||
// 限制在屏幕范围内,底部边界为 height - 196,考虑外层容器尺寸120px
|
||||
const maxX = windowSize.value.width - 120
|
||||
const maxY = windowSize.value.height - 196
|
||||
const maxY = windowSize.value.height - 176
|
||||
const minY = 90 // 顶部边界限制,不允许进入顶部90px区域
|
||||
|
||||
ballPosition.value = {
|
||||
x: Math.max(0, Math.min(x, maxX)),
|
||||
y: Math.max(0, Math.min(y, maxY))
|
||||
y: Math.max(minY, Math.min(y, maxY))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +124,8 @@ const handleMouseUp = () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
|
||||
if (hasDragged.value) {
|
||||
// 自动吸边逻辑,考虑外层容器尺寸120px
|
||||
const centerX = ballPosition.value.x + 60 // 外层容器中心点
|
||||
// 自动吸边逻辑
|
||||
const centerX = ballPosition.value.x + 60 // 悬浮球中心点
|
||||
const screenCenter = windowSize.value.width / 2
|
||||
|
||||
if (centerX < screenCenter) {
|
||||
@@ -130,12 +135,13 @@ const handleMouseUp = () => {
|
||||
ballClass.value = 'hidden-left'
|
||||
} else {
|
||||
// 吸附到右边
|
||||
ballPosition.value.x = windowSize.value.width - 126
|
||||
ballPosition.value.x = windowSize.value.width - 106
|
||||
isOnLeft.value = false
|
||||
ballClass.value = 'hidden-right'
|
||||
}
|
||||
|
||||
// 重新开启自动隐藏
|
||||
// 保存位置到本地存储
|
||||
saveBallPosition()
|
||||
clearTimer()
|
||||
startAutoHide()
|
||||
}
|
||||
}
|
||||
@@ -316,6 +322,15 @@ watch(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// 监听设置变化,更新悬浮球可见性
|
||||
watch(
|
||||
() => settings.value.showFloatBall,
|
||||
(newValue) => {
|
||||
isFloatBallVisible.value = newValue
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 更新窗口尺寸
|
||||
const updateWindowSize = () => {
|
||||
windowSize.value = {
|
||||
@@ -324,25 +339,76 @@ const updateWindowSize = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化悬浮球位置
|
||||
const initBallPosition = () => {
|
||||
// 保存悬浮球位置到本地存储
|
||||
const saveBallPosition = () => {
|
||||
const positionData = {
|
||||
x: ballPosition.value.x,
|
||||
y: ballPosition.value.y,
|
||||
isOnLeft: isOnLeft.value
|
||||
}
|
||||
localStorage.setItem('floatBallPosition', JSON.stringify(positionData))
|
||||
}
|
||||
|
||||
// 从本地存储加载悬浮球位置
|
||||
const loadBallPosition = () => {
|
||||
try {
|
||||
const savedPosition = localStorage.getItem('floatBallPosition')
|
||||
if (savedPosition) {
|
||||
const positionData = JSON.parse(savedPosition)
|
||||
ballPosition.value = {
|
||||
x: positionData.x,
|
||||
y: positionData.y
|
||||
}
|
||||
isOnLeft.value = positionData.isOnLeft
|
||||
} else {
|
||||
// 如果没有保存过位置,使用默认位置
|
||||
setDefaultPosition()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载悬浮球位置失败:', error)
|
||||
setDefaultPosition()
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认位置
|
||||
const setDefaultPosition = () => {
|
||||
updateWindowSize()
|
||||
ballPosition.value = {
|
||||
x: windowSize.value.width - 126, // 考虑外层容器尺寸120px
|
||||
y: windowSize.value.height - 196
|
||||
y: windowSize.value.height - 176
|
||||
}
|
||||
isOnLeft.value = false
|
||||
}
|
||||
|
||||
// 初始化悬浮球位置
|
||||
const initBallPosition = () => {
|
||||
updateWindowSize()
|
||||
loadBallPosition()
|
||||
}
|
||||
|
||||
// 定义 handleResize 函数
|
||||
const handleResize = () => {
|
||||
updateWindowSize()
|
||||
initBallPosition()
|
||||
// 保证悬浮球不超出边界
|
||||
const maxX = windowSize.value.width - 120
|
||||
const maxY = windowSize.value.height - 176
|
||||
const minY = 90 // 顶部边界限制
|
||||
|
||||
// 如果悬浮球在右侧,随窗口宽度变化更新位置
|
||||
if (!isOnLeft.value) {
|
||||
// 重新计算右侧位置
|
||||
ballPosition.value.x = windowSize.value.width - 106
|
||||
}
|
||||
|
||||
// 确保位置在有效范围内
|
||||
ballPosition.value.x = Math.max(0, Math.min(ballPosition.value.x, maxX))
|
||||
ballPosition.value.y = Math.max(minY, Math.min(ballPosition.value.y, maxY))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initBallPosition()
|
||||
startAutoHide()
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
@@ -351,6 +417,7 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
saveBallPosition() // 保存位置
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -398,7 +465,7 @@ onBeforeUnmount(() => {
|
||||
:style="{
|
||||
left: isOnLeft ? ballPosition.x + 120 + 'px' : 'auto',
|
||||
right: isOnLeft ? 'auto' : windowSize.width - ballPosition.x + 20 + 'px',
|
||||
bottom: Math.max(20, 196) + 'px'
|
||||
bottom: Math.max(20, 176) + 'px'
|
||||
}"
|
||||
>
|
||||
<div class="ask-header">
|
||||
@@ -452,8 +519,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.float-ball {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
display: flex;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
|
||||
const audioStore = ControlAudioStore()
|
||||
const audioMeta = ref<HTMLAudioElement>()
|
||||
|
||||
// 提供订阅方法给子组件使用
|
||||
provide('audioSubscribe', audioStore.subscribe)
|
||||
|
||||
@@ -15,6 +14,7 @@ let playbackPosition = 0
|
||||
onMounted(() => {
|
||||
audioStore.init(audioMeta.value)
|
||||
console.log('音频组件初始化完成')
|
||||
// window.api.ping(handleEnded)
|
||||
})
|
||||
|
||||
// 组件被激活时(从缓存中恢复)
|
||||
@@ -55,11 +55,9 @@ onDeactivated(() => {
|
||||
// audioStore.Audio.duration = audioMeta.value.duration || 0
|
||||
// }
|
||||
// }
|
||||
|
||||
const handleEnded = (): void => {
|
||||
audioStore.Audio.isPlay = false
|
||||
audioStore.publish('ended')
|
||||
console.log('eddddddddd')
|
||||
}
|
||||
|
||||
const handleSeeked = (): void => {
|
||||
@@ -96,6 +94,7 @@ const handleError = (event: Event): void => {
|
||||
console.error('音频加载错误:', target.error)
|
||||
audioStore.Audio.isPlay = false
|
||||
audioStore.publish('error')
|
||||
// window.api.pingService.stop()
|
||||
}
|
||||
|
||||
const handleLoadedData = (): void => {
|
||||
@@ -108,10 +107,13 @@ const handleLoadedData = (): void => {
|
||||
const handleCanPlay = (): void => {
|
||||
console.log('音频可以开始播放')
|
||||
audioStore.publish('canplay')
|
||||
// window.api.pingService.start()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
// 组件卸载时清空所有订阅者
|
||||
window.api.pingService.stop()
|
||||
|
||||
audioStore.clearAllSubscribers()
|
||||
})
|
||||
</script>
|
||||
@@ -122,13 +124,14 @@ onUnmounted(() => {
|
||||
ref="audioMeta"
|
||||
preload="auto"
|
||||
:src="audioStore.Audio.url"
|
||||
@ended="handleEnded"
|
||||
@seeked="handleSeeked"
|
||||
@play="handlePlay"
|
||||
@pause="handlePause"
|
||||
@error="handleError"
|
||||
@loadeddata="handleLoadedData"
|
||||
@ended="handleEnded"
|
||||
@canplay="handleCanPlay"
|
||||
id="globaAudio"
|
||||
></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -412,7 +412,7 @@ const playNext = async () => {
|
||||
|
||||
// 定期保存当前播放位置
|
||||
let savePositionInterval: number | null = null
|
||||
let unEnded:()=>any = ()=>{}
|
||||
let unEnded: () => any = () => {}
|
||||
// 初始化播放器
|
||||
onMounted(async () => {
|
||||
console.log('加载')
|
||||
@@ -421,7 +421,7 @@ onMounted(async () => {
|
||||
|
||||
// 监听音频结束事件,根据播放模式播放下一首
|
||||
unEnded = controlAudio.subscribe('ended', () => {
|
||||
window.requestAnimationFrame(()=>{
|
||||
window.requestAnimationFrame(() => {
|
||||
playNext()
|
||||
})
|
||||
})
|
||||
@@ -530,6 +530,7 @@ watch(
|
||||
|
||||
// 全屏展示相关
|
||||
const toggleFullPlay = () => {
|
||||
if (!songInfo.value.songmid) return
|
||||
showFullPlay.value = !showFullPlay.value
|
||||
}
|
||||
|
||||
@@ -670,22 +671,24 @@ const handleProgressDragStart = (event: MouseEvent) => {
|
||||
const songInfo = ref<Omit<SongList, 'songmid'> & { songmid: null | number }>({
|
||||
songmid: null,
|
||||
hash: '',
|
||||
singer: 'CeruMusic',
|
||||
name: '未知歌曲名',
|
||||
name: '欢迎使用CeruMusic 🎉',
|
||||
singer: '可以配置音源插件来播放你的歌曲',
|
||||
albumName: '',
|
||||
albumId: 0,
|
||||
source: '',
|
||||
interval: '00:00',
|
||||
img: 'https://oss.shiqianjiang.cn//storage/default/20250723/mmexport1744732a2f8406e483442888d29521de63ca4f98bc085a2.jpeg',
|
||||
img: '',
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
typeUrl: {}
|
||||
})
|
||||
const maincolor = ref('rgba(0, 0, 0, 1)')
|
||||
const maincolor = ref('var(--td-brand-color-5)')
|
||||
const startmaincolor = ref('rgba(0, 0, 0, 1)')
|
||||
const contrastTextColor = ref('rgba(0, 0, 0, .8)')
|
||||
const hoverColor = ref('rgba(0,0,0,1)')
|
||||
const hoverColor = ref('var(--td-brand-color-5)')
|
||||
const playbg = ref('var(--td-brand-color-2)')
|
||||
const playbghover = ref('var(--td-brand-color-3)')
|
||||
async function setColor() {
|
||||
console.log('主题色刷新')
|
||||
const color = await extractDominantColor(songInfo.value.img)
|
||||
@@ -694,8 +697,18 @@ async function setColor() {
|
||||
startmaincolor.value = `rgba(${color.r},${color.g},${color.b},.2)`
|
||||
contrastTextColor.value = await getBestContrastTextColorWithOpacity(songInfo.value.img, 0.6)
|
||||
hoverColor.value = await getBestContrastTextColorWithOpacity(songInfo.value.img, 1)
|
||||
playbg.value = 'rgba(255,255,255,0.2)'
|
||||
playbghover.value = 'rgba(255,255,255,0.33)'
|
||||
}
|
||||
watch(songInfo, setColor, { deep: true, immediate: true })
|
||||
watch(
|
||||
songInfo,
|
||||
async (newVal) => {
|
||||
if (newVal.img) {
|
||||
await setColor()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
// onMounted(setColor)
|
||||
</script>
|
||||
|
||||
@@ -718,7 +731,7 @@ watch(songInfo, setColor, { deep: true, immediate: true })
|
||||
<div class="player-content">
|
||||
<!-- 左侧:封面和歌曲信息 -->
|
||||
<div class="left-section">
|
||||
<div class="album-cover">
|
||||
<div class="album-cover" v-show="songInfo.img">
|
||||
<img :src="songInfo.img" alt="专辑封面" />
|
||||
</div>
|
||||
|
||||
@@ -793,14 +806,16 @@ watch(songInfo, setColor, { deep: true, immediate: true })
|
||||
|
||||
<!-- 播放列表按钮 -->
|
||||
<t-tooltip content="播放列表">
|
||||
<t-button
|
||||
class="control-btn"
|
||||
shape="circle"
|
||||
variant="text"
|
||||
@click.stop="togglePlaylist"
|
||||
>
|
||||
<liebiao style="width: 1.5em; height: 1.5em" />
|
||||
</t-button>
|
||||
<t-badge :count="list.length" :maxCount="99" color="#aaa">
|
||||
<t-button
|
||||
class="control-btn"
|
||||
shape="circle"
|
||||
variant="text"
|
||||
@click.stop="togglePlaylist"
|
||||
>
|
||||
<liebiao style="width: 1.5em; height: 1.5em" />
|
||||
</t-button>
|
||||
</t-badge>
|
||||
</t-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -999,7 +1014,7 @@ watch(songInfo, setColor, { deep: true, immediate: true })
|
||||
|
||||
.song-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
color: v-bind(hoverColor);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -1044,7 +1059,7 @@ watch(songInfo, setColor, { deep: true, immediate: true })
|
||||
}
|
||||
|
||||
&.play-btn {
|
||||
background-color: #ffffff27;
|
||||
background-color: v-bind(playbg);
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
border-radius: 50%;
|
||||
@@ -1061,7 +1076,7 @@ watch(songInfo, setColor, { deep: true, immediate: true })
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #ffffff62;
|
||||
background-color: v-bind(playbghover);
|
||||
color: v-bind(contrastTextColor);
|
||||
}
|
||||
}
|
||||
|
||||
81
src/renderer/src/components/Settings/AIFloatBallSettings.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="float-ball-settings">
|
||||
<t-card hover-shadow title="AI悬浮球设置">
|
||||
<div class="card-body">
|
||||
<div class="setting-item">
|
||||
<span class="setting-label">显示AI悬浮球</span>
|
||||
<t-switch v-model="showFloatBall" @change="handleFloatBallToggle" />
|
||||
</div>
|
||||
<div class="setting-description">
|
||||
<p>开启后,AI悬浮球将显示在应用界面上,您可以随时与AI助手交流</p>
|
||||
<p>关闭后,AI悬浮球将被隐藏,您可以随时在此处重新开启</p>
|
||||
</div>
|
||||
</div>
|
||||
</t-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useSettingsStore } from '@renderer/store/Settings'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const { settings } = storeToRefs(settingsStore)
|
||||
|
||||
// 悬浮球显示状态
|
||||
const showFloatBall = ref(settings.value.showFloatBall !== false)
|
||||
|
||||
// 处理悬浮球开关切换
|
||||
const handleFloatBallToggle = (val: boolean) => {
|
||||
settingsStore.updateSettings({ showFloatBall: val })
|
||||
}
|
||||
|
||||
// 监听设置变化
|
||||
watch(
|
||||
() => settings.value.showFloatBall,
|
||||
(newValue) => {
|
||||
showFloatBall.value = newValue !== false
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// 确保初始值与存储中的值一致
|
||||
showFloatBall.value = settings.value.showFloatBall !== false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.float-ball-settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.setting-description p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div class="theme-demo">
|
||||
<div class="demo-header">
|
||||
<h1 class="demo-title">主题切换演示</h1>
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="demo-card">
|
||||
<h3 class="card-title">主要功能</h3>
|
||||
<p class="card-text">这是一个现代化的主题切换组件,支持多种预设主题色。</p>
|
||||
<button class="btn btn-primary">主要按钮</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-card">
|
||||
<h3 class="card-title">设计特点</h3>
|
||||
<ul class="feature-list">
|
||||
<li>简约美观的界面设计</li>
|
||||
<li>平滑的过渡动画效果</li>
|
||||
<li>响应式布局适配</li>
|
||||
<li>深色模式支持</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="demo-card">
|
||||
<h3 class="card-title">交互元素</h3>
|
||||
<div class="demo-controls">
|
||||
<input type="text" class="form-control" placeholder="输入框示例" />
|
||||
<button class="btn btn-secondary">次要按钮</button>
|
||||
<a href="#" class="demo-link">链接示例</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ThemeSelector from './ThemeSelector.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-demo {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--td-text-color-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: var(--td-radius-large);
|
||||
padding: 24px;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
color: var(--td-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.feature-list li::before {
|
||||
content: '•';
|
||||
color: var(--td-brand-color);
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--td-radius-medium);
|
||||
border: 1px solid transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--td-brand-color);
|
||||
border-color: var(--td-brand-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--td-brand-color-hover);
|
||||
border-color: var(--td-brand-color-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--td-bg-color-component);
|
||||
border-color: var(--td-component-border);
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--td-bg-color-component-hover);
|
||||
border-color: var(--td-component-border);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: var(--td-radius-medium);
|
||||
background-color: var(--td-bg-color-container);
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--td-brand-color);
|
||||
box-shadow: 0 0 0 3px var(--td-brand-color-light);
|
||||
}
|
||||
|
||||
.demo-link {
|
||||
color: var(--td-brand-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.demo-link:hover {
|
||||
color: var(--td-brand-color-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.demo-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,20 +5,18 @@
|
||||
<h3>正在下载更新</h3>
|
||||
<p v-if="downloadState.updateInfo">版本 {{ downloadState.updateInfo.name }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="progress-content">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${downloadState.progress.percent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
{{ Math.round(downloadState.progress.percent) }}%
|
||||
</div>
|
||||
<div class="progress-text">{{ Math.round(downloadState.progress.percent) }}%</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="progress-details">
|
||||
<div class="download-info">
|
||||
<span>已下载: {{ formatBytes(downloadState.progress.transferred) }}</span>
|
||||
@@ -46,46 +44,52 @@ let speedInterval: NodeJS.Timeout | null = null
|
||||
const calculateSpeed = () => {
|
||||
const currentTime = Date.now()
|
||||
const currentTransferred = downloadState.progress.transferred
|
||||
|
||||
|
||||
if (lastTime > 0) {
|
||||
const timeDiff = (currentTime - lastTime) / 1000 // 秒
|
||||
const sizeDiff = currentTransferred - lastTransferred // 字节
|
||||
|
||||
|
||||
if (timeDiff > 0) {
|
||||
downloadSpeed.value = sizeDiff / timeDiff
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
lastTransferred = currentTransferred
|
||||
lastTime = currentTime
|
||||
}
|
||||
|
||||
// 监听下载进度变化
|
||||
watch(() => downloadState.progress.transferred, () => {
|
||||
calculateSpeed()
|
||||
})
|
||||
watch(
|
||||
() => downloadState.progress.transferred,
|
||||
() => {
|
||||
calculateSpeed()
|
||||
}
|
||||
)
|
||||
|
||||
// 开始监听时重置速度计算
|
||||
watch(() => downloadState.isDownloading, (isDownloading) => {
|
||||
if (isDownloading) {
|
||||
lastTransferred = 0
|
||||
lastTime = 0
|
||||
downloadSpeed.value = 0
|
||||
|
||||
// 每秒更新一次速度显示
|
||||
speedInterval = setInterval(() => {
|
||||
if (!downloadState.isDownloading) {
|
||||
downloadSpeed.value = 0
|
||||
watch(
|
||||
() => downloadState.isDownloading,
|
||||
(isDownloading) => {
|
||||
if (isDownloading) {
|
||||
lastTransferred = 0
|
||||
lastTime = 0
|
||||
downloadSpeed.value = 0
|
||||
|
||||
// 每秒更新一次速度显示
|
||||
speedInterval = setInterval(() => {
|
||||
if (!downloadState.isDownloading) {
|
||||
downloadSpeed.value = 0
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
if (speedInterval) {
|
||||
clearInterval(speedInterval)
|
||||
speedInterval = null
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
if (speedInterval) {
|
||||
clearInterval(speedInterval)
|
||||
speedInterval = null
|
||||
downloadSpeed.value = 0
|
||||
}
|
||||
downloadSpeed.value = 0
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (speedInterval) {
|
||||
@@ -96,11 +100,11 @@ onUnmounted(() => {
|
||||
// 格式化字节大小
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
@@ -168,14 +172,14 @@ const formatBytes = (bytes: number): string => {
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #0052d9, #266fe8);
|
||||
background: linear-gradient(90deg, var(--td-brand-color-5), var(--td-brand-color-3));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-weight: 600;
|
||||
color: #0052d9;
|
||||
color: var(--td-brand-color-6);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -195,7 +199,7 @@ const formatBytes = (bytes: number): string => {
|
||||
|
||||
.download-speed {
|
||||
text-align: center;
|
||||
color: #0052d9;
|
||||
color: var(--td-brand-color-4);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -205,21 +209,21 @@ const formatBytes = (bytes: number): string => {
|
||||
background: #2d2d2d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
.progress-header h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
.progress-header p {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
|
||||
.progress-bar {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
|
||||
.progress-details {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './assets/base.css'
|
||||
import 'animate.css';
|
||||
|
||||
// 引入组件库的少量全局样式变量
|
||||
// import 'tdesign-vue-next/es/style/index.css' //tdesign 组件样式
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createWebHashHistory, createRouter, RouteRecordRaw, RouterOptions } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
let routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'welcome',
|
||||
@@ -11,10 +11,6 @@ const routes: RouteRecordRaw[] = [
|
||||
redirect: '/home/find',
|
||||
component: () => import('@renderer/views/home/index.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/home/find'
|
||||
},
|
||||
{
|
||||
path: 'find',
|
||||
name: 'find',
|
||||
@@ -45,6 +41,10 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
meta: {
|
||||
transitionIn: "animate__fadeIn",
|
||||
transitionOut: "animate__fadeOut",
|
||||
},
|
||||
component: () => import('@renderer/views/settings/index.vue')
|
||||
},
|
||||
{
|
||||
@@ -53,9 +53,30 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@renderer/views/settings/plugins.vue')
|
||||
}
|
||||
]
|
||||
function setAnimate(routerObj: RouteRecordRaw[]) {
|
||||
for (let i = 0; i < routerObj.length; i++) {
|
||||
let item = routerObj[i];
|
||||
if (item.children && item.children.length > 0) {
|
||||
setAnimate(item.children);
|
||||
} else {
|
||||
if (item.meta) continue
|
||||
item.meta = item.meta || {};
|
||||
item.meta.transitionIn = 'animate__fadeInRight';
|
||||
item.meta.transitionOut = 'animate__fadeOutLeft';
|
||||
}
|
||||
}
|
||||
}
|
||||
setAnimate(routes)
|
||||
const option: RouterOptions = {
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
routes,
|
||||
scrollBehavior(_to_, _from_, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { top: 0 };
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const router = createRouter(option)
|
||||
|
||||
@@ -198,8 +198,8 @@ export const ControlAudioStore = defineStore('controlAudio', () => {
|
||||
}
|
||||
const stop = () => {
|
||||
if (Audio.audio) {
|
||||
Audio.isPlay = false
|
||||
return transitionVolume(Audio.audio, Audio.volume / 100, false, true).then(() => {
|
||||
Audio.isPlay = false
|
||||
Audio.audio?.pause()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ export const LocalUserDetailStore = defineStore('Local', () => {
|
||||
topBarStyle: false,
|
||||
mainColor: '#00DAC0',
|
||||
volume: 80,
|
||||
currentTime: 0
|
||||
currentTime: 0,
|
||||
selectSources: 'wy'
|
||||
}
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
}
|
||||
|
||||
50
src/renderer/src/store/Settings.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface SettingsState {
|
||||
showFloatBall: boolean
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 从本地存储加载设置,如果没有则使用默认值
|
||||
const loadSettings = (): SettingsState => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('appSettings')
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error)
|
||||
}
|
||||
|
||||
// 默认设置
|
||||
return {
|
||||
showFloatBall: true
|
||||
}
|
||||
}
|
||||
|
||||
const settings = ref<SettingsState>(loadSettings())
|
||||
|
||||
// 保存设置到本地存储
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem('appSettings', JSON.stringify(settings.value))
|
||||
}
|
||||
|
||||
// 更新设置
|
||||
const updateSettings = (newSettings: Partial<SettingsState>) => {
|
||||
settings.value = { ...settings.value, ...newSettings }
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
// 切换悬浮球显示状态
|
||||
const toggleFloatBall = () => {
|
||||
settings.value.showFloatBall = !settings.value.showFloatBall
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
updateSettings,
|
||||
toggleFloatBall
|
||||
}
|
||||
})
|
||||
@@ -12,8 +12,8 @@ type PlaylistEvents = {
|
||||
// 创建全局事件总线
|
||||
const emitter = mitt<PlaylistEvents>()
|
||||
|
||||
// 将事件总线挂载到全局
|
||||
;(window as any).musicEmitter = emitter
|
||||
// 将事件总线挂载到全局
|
||||
; (window as any).musicEmitter = emitter
|
||||
const qualityMap: Record<string, string> = {
|
||||
'128k': '标准音质',
|
||||
'192k': '高品音质',
|
||||
@@ -87,8 +87,12 @@ export async function addToPlaylistAndPlay(
|
||||
}
|
||||
|
||||
await MessagePlugin.success('已添加到播放列表并开始播放')
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('播放失败:', error)
|
||||
if (error.message) {
|
||||
await MessagePlugin.error('播放失败了: ' + error.message)
|
||||
return
|
||||
}
|
||||
await MessagePlugin.error('播放失败了,可能还没有版权')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function transitionVolume(
|
||||
): Promise<undefined> {
|
||||
clearInterval(timer)
|
||||
const playVolume = lengthen ? 40 : 20
|
||||
const pauseVolume = lengthen ? 20 : 10
|
||||
const pauseVolume = lengthen ? 30 : 20
|
||||
return new Promise((resolve) => {
|
||||
if (target) {
|
||||
timer = setInterval(() => {
|
||||
@@ -22,7 +22,7 @@ export function transitionVolume(
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
audio.volume = Math.max(audio.volume - volume / pauseVolume, 0)
|
||||
if (audio.volume <= 20) {
|
||||
if (audio.volume <= 0) {
|
||||
clearInterval(timer)
|
||||
audio.volume = volume
|
||||
resolve(undefined)
|
||||
|
||||
@@ -89,89 +89,86 @@ const handleKeyDown = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<t-layout class="home-container">
|
||||
<!-- sidebar -->
|
||||
<t-aside class="sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="logo-section">
|
||||
<div class="logo-icon">
|
||||
<i class="iconfont icon-music"></i>
|
||||
<div class="home">
|
||||
<t-layout class="home-container">
|
||||
<!-- sidebar -->
|
||||
<t-aside class="sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="logo-section">
|
||||
<div class="logo-icon">
|
||||
<i class="iconfont icon-music"></i>
|
||||
</div>
|
||||
<p class="app-title">
|
||||
<span style="color: #000; font-weight: 800">Ceru Music</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="app-title">
|
||||
<span style="color: #000; font-weight: 800">Ceru Music</span>
|
||||
</p>
|
||||
|
||||
<nav class="nav-section">
|
||||
<t-button v-for="(item, index) in menuList" :key="index" :variant="menuActive == index ? 'base' : 'text'"
|
||||
:class="menuActive == index ? 'nav-button active' : 'nav-button'" block @click="handleClick(index)">
|
||||
<i :class="`iconfont ${item.icon} nav-icon`"></i>
|
||||
{{ item.name }}
|
||||
</t-button>
|
||||
</nav>
|
||||
</div>
|
||||
</t-aside>
|
||||
|
||||
<nav class="nav-section">
|
||||
<t-button
|
||||
v-for="(item, index) in menuList"
|
||||
:key="index"
|
||||
:variant="menuActive == index ? 'base' : 'text'"
|
||||
:class="menuActive == index ? 'nav-button active' : 'nav-button'"
|
||||
block
|
||||
@click="handleClick(index)"
|
||||
>
|
||||
<i :class="`iconfont ${item.icon} nav-icon`"></i>
|
||||
{{ item.name }}
|
||||
</t-button>
|
||||
</nav>
|
||||
</div>
|
||||
</t-aside>
|
||||
<t-layout style="flex: 1">
|
||||
<t-content>
|
||||
<div class="content">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<t-button shape="circle" theme="default" class="nav-btn" @click="goBack">
|
||||
<i class="iconfont icon-xiangzuo"></i>
|
||||
</t-button>
|
||||
<t-button shape="circle" theme="default" class="nav-btn" @click="goForward">
|
||||
<i class="iconfont icon-xiangyou"></i>
|
||||
</t-button>
|
||||
|
||||
<t-layout style="flex: 1">
|
||||
<t-content>
|
||||
<div class="content">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<t-button shape="circle" theme="default" class="nav-btn" @click="goBack">
|
||||
<i class="iconfont icon-xiangzuo"></i>
|
||||
</t-button>
|
||||
<t-button shape="circle" theme="default" class="nav-btn" @click="goForward">
|
||||
<i class="iconfont icon-xiangyou"></i>
|
||||
</t-button>
|
||||
<div class="search-container">
|
||||
<div class="search-input">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${source}`"></use>
|
||||
</svg>
|
||||
<t-input v-model="keyword" placeholder="搜索音乐、歌手" style="width: 100%" @enter="handleKeyDown">
|
||||
<template #suffix>
|
||||
<t-button theme="primary" variant="text" shape="circle"
|
||||
style="display: flex; align-items: center; justify-content: center" @click="handleSearch">
|
||||
<SearchIcon style="font-size: 16px; color: #000" />
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<div class="search-input">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${source}`"></use>
|
||||
</svg>
|
||||
<t-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索音乐、歌手"
|
||||
style="width: 100%"
|
||||
@enter="handleKeyDown"
|
||||
>
|
||||
<template #suffix>
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="text"
|
||||
shape="circle"
|
||||
style="display: flex; align-items: center; justify-content: center"
|
||||
@click="handleSearch"
|
||||
>
|
||||
<SearchIcon style="font-size: 16px; color: #000" />
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
<TitleBarControls :color="'#000'"></TitleBarControls>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TitleBarControls :color="'#000'"></TitleBarControls>
|
||||
<div class="mainContent">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<Transition name="page"
|
||||
:enter-active-class="`animate__animated ${route.meta.transitionIn} animate__fast`"
|
||||
:leave-active-class="`animate__animated ${route.meta.transitionOut} animate__fast`">
|
||||
<KeepAlive exclude="list">
|
||||
<component :is="Component" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mainContent">
|
||||
<keep-alive>
|
||||
<router-view />
|
||||
</keep-alive>
|
||||
</div>
|
||||
</div>
|
||||
</t-content>
|
||||
</t-content>
|
||||
</t-layout>
|
||||
</t-layout>
|
||||
</t-layout>
|
||||
<PlayMusic />
|
||||
<PlayMusic />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animate__animated {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-container {
|
||||
height: calc(100vh - var(--play-bottom-height));
|
||||
overflow-y: hidden;
|
||||
@@ -218,6 +215,7 @@ const handleKeyDown = () => {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: #111827;
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
color: #b8f0cc;
|
||||
@@ -264,10 +262,12 @@ const handleKeyDown = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.t-layout__content) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0;
|
||||
background: #f6f6f6;
|
||||
@@ -288,6 +288,7 @@ const handleKeyDown = () => {
|
||||
&:last-of-type {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 1rem;
|
||||
color: #3d4043;
|
||||
@@ -303,6 +304,7 @@ const handleKeyDown = () => {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
|
||||
.search-input {
|
||||
-webkit-app-region: no-drag;
|
||||
display: flex;
|
||||
@@ -333,11 +335,14 @@ const handleKeyDown = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 0; /* 确保flex子元素能够正确计算高度 */
|
||||
position: relative;
|
||||
height: 0;
|
||||
/* 确保flex子元素能够正确计算高度 */
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watch,WatchHandle, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
// 路由实例
|
||||
@@ -11,11 +11,18 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
// 热门歌曲数据
|
||||
const hotSongs:any = ref([])
|
||||
const hotSongs: any = ref([])
|
||||
|
||||
|
||||
let watchSource:WatchHandle |null = null
|
||||
// 获取热门歌单数据
|
||||
const fetchHotSonglist = async () => {
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
watchSource =watch(LocalUserDetail.userSource,()=>{
|
||||
if(LocalUserDetail.userSource.source){
|
||||
fetchHotSonglist()
|
||||
}
|
||||
},{deep:true})
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -71,6 +78,11 @@ const playSong = (song: any): void => {
|
||||
onMounted(() => {
|
||||
fetchHotSonglist()
|
||||
})
|
||||
onUnmounted(()=>{
|
||||
if(watchSource){
|
||||
watchSource()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -229,6 +241,8 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ onMounted(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-container {
|
||||
box-sizing: border-box;
|
||||
background: #fafafa;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
|
||||
// 本地音乐数据
|
||||
const localSongs = ref([
|
||||
@@ -85,25 +86,303 @@ const deleteSong = (song: any): void => {
|
||||
console.log('删除歌曲:', song.title)
|
||||
// 这里可以添加删除确认和实际删除逻辑
|
||||
}
|
||||
|
||||
// 歌单相关功能
|
||||
interface Playlist {
|
||||
id: number
|
||||
name: string
|
||||
songs: any[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const playlists = ref<Playlist[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: '我喜欢的音乐',
|
||||
songs: [localSongs.value[0], localSongs.value[2]],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '周杰伦精选',
|
||||
songs: [localSongs.value[1], localSongs.value[3], localSongs.value[4]],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
|
||||
// 当前选中的歌单
|
||||
const currentPlaylist = ref<Playlist | null>(null)
|
||||
|
||||
// 显示歌单对话框
|
||||
const showPlaylistDialog = ref(false)
|
||||
const showCreatePlaylistDialog = ref(false)
|
||||
const newPlaylistName = ref('')
|
||||
|
||||
// 创建新歌单
|
||||
const createPlaylist = () => {
|
||||
if (!newPlaylistName.value.trim()) {
|
||||
MessagePlugin.warning('歌单名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
const newPlaylist: Playlist = {
|
||||
id: playlists.value.length + 1,
|
||||
name: newPlaylistName.value,
|
||||
songs: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
playlists.value.push(newPlaylist)
|
||||
newPlaylistName.value = ''
|
||||
showCreatePlaylistDialog.value = false
|
||||
MessagePlugin.success('歌单创建成功')
|
||||
}
|
||||
|
||||
// 将当前播放列表保存为歌单
|
||||
const saveCurrentPlaylistAs = () => {
|
||||
showCreatePlaylistDialog.value = true
|
||||
}
|
||||
|
||||
// 一键播放歌单
|
||||
const playPlaylist = (playlist: Playlist) => {
|
||||
if (playlist.songs.length === 0) {
|
||||
MessagePlugin.warning('歌单中没有歌曲')
|
||||
return
|
||||
}
|
||||
|
||||
// 这里应该调用播放器的方法替换播放列表
|
||||
console.log('播放歌单:', playlist.name, '共', playlist.songs.length, '首歌曲')
|
||||
MessagePlugin.success(`已将播放列表替换为歌单"${playlist.name}"`)
|
||||
}
|
||||
|
||||
// 查看歌单详情
|
||||
const viewPlaylist = (playlist: Playlist) => {
|
||||
currentPlaylist.value = playlist
|
||||
showPlaylistDialog.value = true
|
||||
}
|
||||
|
||||
// 添加歌曲到歌单
|
||||
const addToPlaylist = (song: any, playlist: Playlist) => {
|
||||
// 检查歌曲是否已在歌单中
|
||||
const exists = playlist.songs.some((s) => s.id === song.id)
|
||||
if (exists) {
|
||||
MessagePlugin.warning(`歌曲"${song.title}"已在歌单中`)
|
||||
return
|
||||
}
|
||||
|
||||
playlist.songs.push(song)
|
||||
playlist.updatedAt = new Date().toISOString()
|
||||
MessagePlugin.success(`已将"${song.title}"添加到歌单"${playlist.name}"`)
|
||||
}
|
||||
|
||||
// 从歌单中移除歌曲
|
||||
const removeFromPlaylist = (songId: number, playlist: Playlist) => {
|
||||
const index = playlist.songs.findIndex((s) => s.id === songId)
|
||||
if (index !== -1) {
|
||||
const songTitle = playlist.songs[index].title
|
||||
playlist.songs.splice(index, 1)
|
||||
playlist.updatedAt = new Date().toISOString()
|
||||
MessagePlugin.success(`已从歌单"${playlist.name}"中移除"${songTitle}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除歌单
|
||||
const deletePlaylist = (playlistId: number) => {
|
||||
const index = playlists.value.findIndex((p) => p.id === playlistId)
|
||||
if (index !== -1) {
|
||||
const playlistName = playlists.value[index].name
|
||||
playlists.value.splice(index, 1)
|
||||
|
||||
// 如果正在查看的歌单被删除,关闭对话框
|
||||
if (currentPlaylist.value && currentPlaylist.value.id === playlistId) {
|
||||
currentPlaylist.value = null
|
||||
showPlaylistDialog.value = false
|
||||
}
|
||||
|
||||
MessagePlugin.success(`已删除歌单"${playlistName}"`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="local-container">
|
||||
<!-- 页面标题和操作 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>本地音乐</h2>
|
||||
<div class="stats">
|
||||
<span>{{ stats.totalSongs }} 首歌曲</span>
|
||||
<span>总时长 {{ stats.totalDuration }}</span>
|
||||
<span>总大小 {{ stats.totalSize }}</span>
|
||||
<div>
|
||||
<div class="local-container">
|
||||
<!-- 页面标题和操作 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>本地音乐</h2>
|
||||
<div class="stats">
|
||||
<span>{{ stats.totalSongs }} 首歌曲</span>
|
||||
<span>总时长 {{ stats.totalDuration }}</span>
|
||||
<span>总大小 {{ stats.totalSize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<t-button theme="default" @click="openMusicFolder">
|
||||
<i class="iconfont icon-shouye"></i>
|
||||
打开文件夹
|
||||
</t-button>
|
||||
<t-button theme="primary" @click="importMusic">
|
||||
<i class="iconfont icon-zengjia"></i>
|
||||
导入音乐
|
||||
</t-button>
|
||||
<t-button theme="default" @click="saveCurrentPlaylistAs">
|
||||
<i class="iconfont icon-baocun"></i>
|
||||
保存为歌单
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<t-button theme="default" @click="openMusicFolder">
|
||||
<i class="iconfont icon-shouye"></i>
|
||||
打开文件夹
|
||||
</t-button>
|
||||
|
||||
<!-- 歌单区域 -->
|
||||
<div class="playlists-section">
|
||||
<div class="section-header">
|
||||
<h3>我的歌单</h3>
|
||||
<t-button theme="primary" variant="text" @click="showCreatePlaylistDialog = true">
|
||||
<i class="iconfont icon-zengjia"></i>
|
||||
新建歌单
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<div class="playlists-grid">
|
||||
<div v-for="playlist in playlists" :key="playlist.id" class="playlist-card">
|
||||
<div class="playlist-cover" @click="viewPlaylist(playlist)">
|
||||
<div class="cover-overlay">
|
||||
<i class="iconfont icon-bofang"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-name" @click="viewPlaylist(playlist)">{{ playlist.name }}</div>
|
||||
<div class="playlist-meta">{{ playlist.songs.length }}首歌曲</div>
|
||||
</div>
|
||||
<div class="playlist-actions">
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="播放歌单"
|
||||
@click="playPlaylist(playlist)"
|
||||
>
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</t-button>
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="default"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="查看详情"
|
||||
@click="viewPlaylist(playlist)"
|
||||
>
|
||||
<i class="iconfont icon-liebiao"></i>
|
||||
</t-button>
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="删除歌单"
|
||||
@click="deletePlaylist(playlist.id)"
|
||||
>
|
||||
<i class="iconfont icon-shanchu"></i>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音乐列表 -->
|
||||
<div class="music-list">
|
||||
<div class="list-header">
|
||||
<div class="header-item index">#</div>
|
||||
<div class="header-item title">标题</div>
|
||||
<div class="header-item artist">艺术家</div>
|
||||
<div class="header-item album">专辑</div>
|
||||
<div class="header-item duration">时长</div>
|
||||
<div class="header-item size">大小</div>
|
||||
<div class="header-item format">格式</div>
|
||||
<div class="header-item actions">操作</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body">
|
||||
<div
|
||||
v-for="(song, index) in localSongs"
|
||||
:key="song.id"
|
||||
class="song-row"
|
||||
@dblclick="playSong(song)"
|
||||
>
|
||||
<div class="row-item index">{{ index + 1 }}</div>
|
||||
<div class="row-item title">
|
||||
<div class="song-title">{{ song.title }}</div>
|
||||
</div>
|
||||
<div class="row-item artist">{{ song.artist }}</div>
|
||||
<div class="row-item album">{{ song.album }}</div>
|
||||
<div class="row-item duration">{{ song.duration }}</div>
|
||||
<div class="row-item size">{{ song.size }}</div>
|
||||
<div class="row-item format">
|
||||
<span class="format-badge">{{ song.format }}</span>
|
||||
<span class="bitrate">{{ song.bitrate }}</span>
|
||||
</div>
|
||||
<div class="row-item actions">
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="播放"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</t-button>
|
||||
<t-dropdown
|
||||
:options="[
|
||||
{
|
||||
content: '添加到歌单',
|
||||
value: 'addToPlaylist',
|
||||
children: playlists.map((p) => ({ content: p.name, value: `playlist-${p.id}` }))
|
||||
}
|
||||
]"
|
||||
@click="
|
||||
(data) => {
|
||||
if (data.startsWith('playlist-')) {
|
||||
const playlistId = parseInt(data.split('-')[1])
|
||||
const targetPlaylist = playlists.find((p) => p.id === playlistId)
|
||||
if (targetPlaylist) {
|
||||
addToPlaylist(song, targetPlaylist)
|
||||
}
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<t-button shape="circle" theme="default" variant="text" size="small" title="更多">
|
||||
<i class="iconfont icon-gengduo"></i>
|
||||
</t-button>
|
||||
</t-dropdown>
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="删除"
|
||||
@click="deleteSong(song)"
|
||||
>
|
||||
<i class="iconfont icon-shanchu"></i>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="localSongs.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="iconfont icon-music"></i>
|
||||
</div>
|
||||
<h3>暂无本地音乐</h3>
|
||||
<p>点击"导入音乐"按钮添加您的音乐文件</p>
|
||||
<t-button theme="primary" @click="importMusic">
|
||||
<i class="iconfont icon-zengjia"></i>
|
||||
导入音乐
|
||||
@@ -111,87 +390,110 @@ const deleteSong = (song: any): void => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音乐列表 -->
|
||||
<div class="music-list">
|
||||
<div class="list-header">
|
||||
<div class="header-item index">#</div>
|
||||
<div class="header-item title">标题</div>
|
||||
<div class="header-item artist">艺术家</div>
|
||||
<div class="header-item album">专辑</div>
|
||||
<div class="header-item duration">时长</div>
|
||||
<div class="header-item size">大小</div>
|
||||
<div class="header-item format">格式</div>
|
||||
<div class="header-item actions">操作</div>
|
||||
</div>
|
||||
<!-- 创建歌单对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="showCreatePlaylistDialog"
|
||||
header="创建新歌单"
|
||||
:confirm-btn="{ content: '创建', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消' }"
|
||||
@confirm="createPlaylist"
|
||||
>
|
||||
<t-input
|
||||
v-model="newPlaylistName"
|
||||
placeholder="请输入歌单名称"
|
||||
clearable
|
||||
@keyup.enter="createPlaylist"
|
||||
/>
|
||||
</t-dialog>
|
||||
|
||||
<div class="list-body">
|
||||
<div
|
||||
v-for="(song, index) in localSongs"
|
||||
:key="song.id"
|
||||
class="song-row"
|
||||
@dblclick="playSong(song)"
|
||||
>
|
||||
<div class="row-item index">{{ index + 1 }}</div>
|
||||
<div class="row-item title">
|
||||
<div class="song-title">{{ song.title }}</div>
|
||||
<!-- 歌单详情对话框 -->
|
||||
<t-dialog
|
||||
v-model:visible="showPlaylistDialog"
|
||||
:header="currentPlaylist?.name || '歌单详情'"
|
||||
width="800px"
|
||||
:footer="false"
|
||||
>
|
||||
<template v-if="currentPlaylist">
|
||||
<div class="playlist-header">
|
||||
<div class="playlist-info">
|
||||
<h3>{{ currentPlaylist.name }}</h3>
|
||||
<div class="playlist-meta">
|
||||
<span>{{ currentPlaylist.songs.length }} 首歌曲</span>
|
||||
<span>创建于 {{ new Date(currentPlaylist.createdAt).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-item artist">{{ song.artist }}</div>
|
||||
<div class="row-item album">{{ song.album }}</div>
|
||||
<div class="row-item duration">{{ song.duration }}</div>
|
||||
<div class="row-item size">{{ song.size }}</div>
|
||||
<div class="row-item format">
|
||||
<span class="format-badge">{{ song.format }}</span>
|
||||
<span class="bitrate">{{ song.bitrate }}</span>
|
||||
</div>
|
||||
<div class="row-item actions">
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="播放"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="playlist-actions">
|
||||
<t-button theme="primary" @click="playPlaylist(currentPlaylist)">
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</t-button>
|
||||
<t-button shape="circle" theme="default" variant="text" size="small" title="更多">
|
||||
<i class="iconfont icon-gengduo"></i>
|
||||
</t-button>
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="删除"
|
||||
@click="deleteSong(song)"
|
||||
>
|
||||
<i class="iconfont icon-shanchu"></i>
|
||||
一键播放
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="localSongs.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="iconfont icon-music"></i>
|
||||
</div>
|
||||
<h3>暂无本地音乐</h3>
|
||||
<p>点击"导入音乐"按钮添加您的音乐文件</p>
|
||||
<t-button theme="primary" @click="importMusic">
|
||||
<i class="iconfont icon-zengjia"></i>
|
||||
导入音乐
|
||||
</t-button>
|
||||
</div>
|
||||
<div class="playlist-songs">
|
||||
<div class="list-header">
|
||||
<div class="header-item index">#</div>
|
||||
<div class="header-item title">标题</div>
|
||||
<div class="header-item artist">艺术家</div>
|
||||
<div class="header-item album">专辑</div>
|
||||
<div class="header-item duration">时长</div>
|
||||
<div class="header-item actions">操作</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body">
|
||||
<div
|
||||
v-for="(song, index) in currentPlaylist.songs"
|
||||
:key="song.id"
|
||||
class="song-row"
|
||||
@dblclick="playSong(song)"
|
||||
>
|
||||
<div class="row-item index">{{ index + 1 }}</div>
|
||||
<div class="row-item title">
|
||||
<div class="song-title">{{ song.title }}</div>
|
||||
</div>
|
||||
<div class="row-item artist">{{ song.artist }}</div>
|
||||
<div class="row-item album">{{ song.album }}</div>
|
||||
<div class="row-item duration">{{ song.duration }}</div>
|
||||
<div class="row-item actions">
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="播放"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<i class="iconfont icon-a-tingzhiwukuang"></i>
|
||||
</t-button>
|
||||
<t-button
|
||||
shape="circle"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
title="从歌单中移除"
|
||||
@click="removeFromPlaylist(song.id, currentPlaylist)"
|
||||
>
|
||||
<i class="iconfont icon-shanchu"></i>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentPlaylist.songs.length === 0" class="empty-playlist">
|
||||
<p>歌单中暂无歌曲</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.local-container {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -231,6 +533,7 @@ const deleteSong = (song: any): void => {
|
||||
}
|
||||
|
||||
.music-list {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
@@ -239,7 +542,9 @@ const deleteSong = (song: any): void => {
|
||||
|
||||
.list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 150px 150px 80px 80px 120px 120px;
|
||||
width: 100%;
|
||||
grid-template-columns: 0.5fr 2fr 1fr 3fr 1fr 1fr 1fr 1fr;
|
||||
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f9fafb;
|
||||
@@ -257,7 +562,8 @@ const deleteSong = (song: any): void => {
|
||||
.list-body {
|
||||
.song-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 150px 150px 80px 80px 120px 120px;
|
||||
grid-template-columns: 0.5fr 2fr 1fr 3fr 1fr 1fr 1fr 1fr;
|
||||
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
@@ -365,4 +671,152 @@ const deleteSong = (song: any): void => {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 歌单区域样式 */
|
||||
.playlists-section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlists-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.playlist-cover .cover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-cover {
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
.cover-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.iconfont {
|
||||
font-size: 3rem;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
padding: 1rem;
|
||||
|
||||
.playlist-name {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 歌单详情对话框样式 */
|
||||
.playlist-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.playlist-info {
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
|
||||
span {
|
||||
&:not(:last-child)::after {
|
||||
content: '•';
|
||||
margin-left: 1rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-songs {
|
||||
.list-header {
|
||||
grid-template-columns: 0.5fr 2fr 1fr 3fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.list-body .song-row {
|
||||
grid-template-columns: 0.5fr 2fr 1fr 3fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-playlist {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +53,7 @@ const clearAPIKey = (): void => {
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, watch } from 'vue'
|
||||
import MusicCache from '@renderer/components/Settings/MusicCache.vue'
|
||||
import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettings.vue'
|
||||
const router = useRouter()
|
||||
const goPlugin = () => {
|
||||
router.push('/plugins')
|
||||
@@ -181,7 +182,7 @@ const getCurrentSourceName = () => {
|
||||
<div class="settings-container">
|
||||
<div class="settings-content">
|
||||
<div class="settings-header">
|
||||
<h2>标题栏控制组件演示</h2>
|
||||
<h2>设置你的顶部控制栏</h2>
|
||||
<p>这里展示了两种不同风格的标题栏控制按钮</p>
|
||||
</div>
|
||||
|
||||
@@ -289,6 +290,8 @@ const getCurrentSourceName = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AIFloatBallSettings></AIFloatBallSettings>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 播放列表管理部分 -->
|
||||
@@ -430,7 +433,9 @@ const getCurrentSourceName = () => {
|
||||
<MusicCache></MusicCache>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<Versions></Versions>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>功能说明</h3>
|
||||
@@ -508,7 +513,7 @@ const getCurrentSourceName = () => {
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
max-width: 800px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
@@ -574,7 +579,7 @@ const getCurrentSourceName = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 14px 1rem;
|
||||
background: #f6f6f6;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
@@ -602,7 +607,7 @@ const getCurrentSourceName = () => {
|
||||
|
||||
.iconfont {
|
||||
font-size: 1.25rem;
|
||||
color: #f97316;
|
||||
color: var(--td-brand-color);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
@@ -1015,23 +1020,23 @@ const getCurrentSourceName = () => {
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.music-config-container {
|
||||
.source-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
// @media (max-width: 768px) {
|
||||
// .music-config-container {
|
||||
// .source-cards {
|
||||
// grid-template-columns: 1fr;
|
||||
// }
|
||||
|
||||
.config-status {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
// .config-status {
|
||||
// grid-template-columns: 1fr;
|
||||
// }
|
||||
// }
|
||||
|
||||
.plugin-prompt {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
// .plugin-prompt {
|
||||
// flex-direction: column;
|
||||
// text-align: center;
|
||||
// gap: 1.5rem;
|
||||
// }
|
||||
// }
|
||||
|
||||
// 动画效果
|
||||
@keyframes fadeInUp {
|
||||
|
||||
@@ -1,13 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
router.push('home')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>欢迎来到Ceru Music</h1>
|
||||
<div class="welcome-container">
|
||||
<!-- 左侧Logo区域 -->
|
||||
<div class="logo-section">
|
||||
<div class="image-container">
|
||||
<div class="image-bg"></div>
|
||||
<img class="logo-image" src="/logo.svg" alt="Ceru Music Logo">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
<div class="content-section">
|
||||
<div class="brand-content">
|
||||
<h1 class="brand-title">Cerulean Music</h1>
|
||||
<p class="brand-subtitle">澜音-纯净音乐,极致音乐体验</p>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<!-- <div class="loading-area">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
<p class="loading-text">{{ loadingText }}</p>
|
||||
</div> -->
|
||||
|
||||
<!-- 特性标签 -->
|
||||
<div class="feature-tags">
|
||||
<span class="tag" v-for="(feature, index) in features" :key="index">
|
||||
{{ feature }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 版本信息 -->
|
||||
<div class="version-info">v{{ version }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const version = ref('1.0.0') // 默认版本号
|
||||
|
||||
// 特性列表
|
||||
const features = ['高品质音乐', '简约风', '离线播放', '丰富的插件支持']
|
||||
|
||||
onMounted(async () => {
|
||||
// 通过IPC获取版本号
|
||||
try {
|
||||
const appVersion = await window.electron.ipcRenderer.invoke('get-app-version')
|
||||
if (appVersion) {
|
||||
version.value = appVersion
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get app version via IPC:', error)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/home')
|
||||
}, 2000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 左侧Logo区域 */
|
||||
.logo-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-bg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-radius: 50%;
|
||||
width: min(30vw, 70vh);
|
||||
height: min(30vw, 70vh);
|
||||
background-image: linear-gradient(-45deg, #b8f1cf 50%, #47caff 50%);
|
||||
filter: blur(56px);
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: min(30vw, 70vh);
|
||||
height: min(30vw, 70vh);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 4px 20px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* 右侧内容区域 */
|
||||
.content-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 2rem 4rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
/* max-width: 400px; */
|
||||
animation: slideInRight 1s ease-out;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
background: -webkit-linear-gradient(120deg, #5dd6cc 30%, #b8f1cc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #666666;
|
||||
margin: 1rem 0 5rem 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 加载区域 */
|
||||
.loading-area {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(-45deg, #b8f1cf 0%, #47caff 100%);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.9rem;
|
||||
color: #888888;
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 特性标签 */
|
||||
.feature-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: #B8F1CE;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
color: #333333;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
background: linear-gradient(-45deg, #25ff7c 0%, #47caff 100%);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(71, 202, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 版本信息 */
|
||||
.version-info {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9e9e9e;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* 动画定义 */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计
|
||||
@media (max-width: 1024px) {
|
||||
.welcome-container {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
flex: none;
|
||||
padding: 2rem 2rem 1rem 2rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
padding: 1rem 2rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 2.8rem;
|
||||
}
|
||||
|
||||
.image-bg {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
filter: blur(40px);
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brand-title {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.image-bg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
filter: blur(30px);
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
} */
|
||||
|
||||
/* 暗色主题适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.welcome-container {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: #333333;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -53,7 +53,7 @@ async function downloadApp(platform) {
|
||||
showNotification(`正在下载 ${getPlatformName(platform)} 版本 v${release.tag_name}...`, 'success');
|
||||
|
||||
// Start download
|
||||
window.open(downloadUrl, '_blank');
|
||||
window.open('https://gh.bugdey.us.kg/'+downloadUrl, '_blank');
|
||||
|
||||
// Track download
|
||||
trackDownload(platform, release.tag_name);
|
||||
|
||||
12
yarn.lock
@@ -2285,6 +2285,11 @@ alien-signals@^2.0.5:
|
||||
resolved "https://registry.npmmirror.com/alien-signals/-/alien-signals-2.0.6.tgz"
|
||||
integrity sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==
|
||||
|
||||
animate.css@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmmirror.com/animate.css/-/animate.css-4.1.1.tgz#614ec5a81131d7e4dc362a58143f7406abd68075"
|
||||
integrity sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==
|
||||
|
||||
ansi-regex@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-2.1.1.tgz"
|
||||
@@ -5303,12 +5308,7 @@ lodash.merge@^4.6.2:
|
||||
resolved "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||
|
||||
lodash@^4.17.15:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
lodash@^4.17.21:
|
||||
lodash@^4.17.15, lodash@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||