Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9deb6a937b | ||
|
|
1259a6d70d | ||
|
|
14496d5d0b | ||
|
|
5f042e15eb | ||
|
|
6945c733e9 | ||
|
|
eea157b8d6 | ||
|
|
0c22eaa212 |
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"title": "音频播放器控件功能完善",
|
||||
"features": [
|
||||
"播放列表管理(添加/删除/排序)",
|
||||
"音量调节控件",
|
||||
"多种播放模式(顺序/随机/单曲循环)",
|
||||
"播放列表导出/导入功能",
|
||||
"播放列表一键清空功能"
|
||||
],
|
||||
"tech": { "Web": { "arch": "vue", "component": "tdesign" } },
|
||||
"design": "现代简约风格,深色背景配合高对比度控件,主色调为TDesign主题蓝(#0052D9),包含固定底部播放控制区、垂直弹出式音量控制、图标式播放模式选择器和可拖拽排序的播放列表侧边栏。新增导出/导入功能使用TDesign对话框组件,提供文件导出/导入和内容复制/粘贴两种方式,所有导出内容进行加密处理。清空功能添加二次确认对话框防止误操作。",
|
||||
"plan": {
|
||||
"扩展Pinia状态管理,在ControlAudioStore中添加音量控制、播放模式和播放列表相关状态": "holding",
|
||||
"创建VolumeControl.vue组件,实现音量调节滑动条和静音按钮功能": "holding",
|
||||
"创建PlayModeSelector.vue组件,实现三种播放模式的切换功能": "holding",
|
||||
"创建PlaylistManager.vue组件,实现播放列表的基本显示功能": "holding",
|
||||
"在PlaylistManager组件中实现添加和删除曲目功能": "holding",
|
||||
"集成拖拽排序功能到PlaylistManager组件": "holding",
|
||||
"将新组件集成到主播放器界面,调整布局确保合理性": "holding",
|
||||
"实现播放列表与音频控制的联动,确保播放状态正确反映": "holding",
|
||||
"添加键盘快捷键支持和状态反馈机制": "holding",
|
||||
"进行组件间通信测试,确保功能协调工作": "holding",
|
||||
"创建播放列表加密/解密工具函数": "holding",
|
||||
"实现播放列表导出功能(文件导出和内容复制)": "holding",
|
||||
"实现播放列表导入功能(文件导入和内容粘贴)": "holding",
|
||||
"实现播放列表一键清空功能及二次确认机制": "holding",
|
||||
"测试导出/导入功能的加密解密正确性": "holding"
|
||||
}
|
||||
}
|
||||
4
.gitignore
vendored
@@ -12,3 +12,7 @@ build
|
||||
/plugins/
|
||||
temp
|
||||
temp/log.txt
|
||||
/.kiro/
|
||||
/.vscode/
|
||||
/.codebuddy/
|
||||
/.idea/
|
||||
@@ -1,22 +0,0 @@
|
||||
# 语言设置
|
||||
|
||||
## 对话语言
|
||||
- **主要语言**: 中文(简体中文)
|
||||
- 与用户对话时请使用中文回复
|
||||
- 代码注释和文档也应该使用中文
|
||||
- 变量名和函数名仍使用英文(遵循编程规范)
|
||||
|
||||
## 代码规范
|
||||
- 代码本身使用英文命名
|
||||
- 注释使用中文说明
|
||||
- 错误信息和用户界面文本使用中文
|
||||
- README和文档文件使用中文编写
|
||||
|
||||
## 示例
|
||||
```typescript
|
||||
// 播放音乐的函数
|
||||
function playMusic(songId: string): void {
|
||||
// 开始播放指定的歌曲
|
||||
console.log('正在播放音乐...')
|
||||
}
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
# Product Overview
|
||||
|
||||
**Ceru Music** is a free music application built as an Electron desktop app. It provides a cross-platform music experience for Windows, macOS, and Linux users.
|
||||
|
||||
## Key Features
|
||||
- Desktop music player application
|
||||
- Cross-platform compatibility (Windows, macOS, Linux)
|
||||
- Modern UI built with Vue.js and TDesign components
|
||||
- Auto-updating capabilities via electron-updater
|
||||
|
||||
## Target Platforms
|
||||
- Windows (primary build target)
|
||||
- macOS
|
||||
- Linux
|
||||
|
||||
The application follows Electron's multi-process architecture with separate main, renderer, and preload processes for security and performance.
|
||||
@@ -1,55 +0,0 @@
|
||||
# Project Structure
|
||||
|
||||
## Root Level Organization
|
||||
```
|
||||
├── src/ # Source code
|
||||
├── resources/ # App resources (icons, etc.)
|
||||
├── build/ # Build artifacts
|
||||
├── out/ # Compiled output
|
||||
└── node_modules/ # Dependencies
|
||||
```
|
||||
|
||||
## Source Code Architecture (`src/`)
|
||||
|
||||
### Electron Multi-Process Structure
|
||||
```
|
||||
src/
|
||||
├── main/ # Main process (Node.js)
|
||||
│ └── index.ts # Entry point for main process
|
||||
├── preload/ # Preload scripts (security bridge)
|
||||
│ ├── index.ts # Preload script implementation
|
||||
│ └── index.d.ts # Type definitions
|
||||
└── renderer/ # Renderer process (Vue app)
|
||||
├── src/ # Vue application source
|
||||
├── index.html # HTML entry point
|
||||
├── auto-imports.d.ts # Auto-generated import types
|
||||
└── components.d.ts # Auto-generated component types
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### TypeScript Configuration
|
||||
- `tsconfig.json`: Root config with project references
|
||||
- `tsconfig.node.json`: Node.js/Electron main process config
|
||||
- `tsconfig.web.json`: Web/renderer process config
|
||||
|
||||
### Build & Development
|
||||
- `electron.vite.config.ts`: Vite configuration for Electron
|
||||
- `electron-builder.yml`: App packaging configuration
|
||||
- `package.json`: Dependencies and scripts
|
||||
|
||||
### Code Quality
|
||||
- `eslint.config.mjs`: ESLint configuration (flat config)
|
||||
- `.prettierrc.yaml`: Prettier formatting rules
|
||||
- `.editorconfig`: Editor configuration
|
||||
|
||||
## Key Conventions
|
||||
- **Renderer alias**: Use `@renderer/*` for renderer source imports
|
||||
- **Auto-imports**: TDesign components and Vue composables are auto-imported
|
||||
- **Process separation**: Maintain clear boundaries between main, preload, and renderer
|
||||
- **TypeScript**: All source files should use TypeScript (.ts/.vue)
|
||||
|
||||
## File Naming
|
||||
- Use kebab-case for component files
|
||||
- Use camelCase for TypeScript files
|
||||
- Vue components should be multi-word (ESLint enforced, but disabled)
|
||||
@@ -1,52 +0,0 @@
|
||||
# Technology Stack
|
||||
|
||||
## Core Technologies
|
||||
- **Electron**: Desktop app framework (v37.2.3)
|
||||
- **Vue 3**: Frontend framework with Composition API (v3.5.17)
|
||||
- **TypeScript**: Primary language for type safety
|
||||
- **Vite**: Build tool and dev server via electron-vite
|
||||
- **PNPM**: Package manager (preferred over npm/yarn)
|
||||
|
||||
## UI Framework
|
||||
- **TDesign Vue Next**: Primary UI component library (v1.15.2)
|
||||
- **SCSS**: Styling preprocessor
|
||||
- **Auto-import**: Automatic component and composable imports
|
||||
|
||||
## State Management & Routing
|
||||
- **Pinia**: State management (v3.0.3)
|
||||
- **Vue Router**: Client-side routing (v4.5.1)
|
||||
|
||||
## Development Tools
|
||||
- **ESLint**: Code linting with Electron Toolkit configs
|
||||
- **Prettier**: Code formatting
|
||||
- **Vue TSC**: Vue TypeScript checking
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
pnpm dev # Start development server
|
||||
pnpm start # Preview built app
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
pnpm lint # Run ESLint
|
||||
pnpm format # Format code with Prettier
|
||||
pnpm typecheck # Run TypeScript checks
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
pnpm build # Build for current platform
|
||||
pnpm build:win # Build for Windows
|
||||
pnpm build:mac # Build for macOS
|
||||
pnpm build:linux # Build for Linux
|
||||
pnpm build:unpack # Build without packaging
|
||||
```
|
||||
|
||||
## Build System
|
||||
- **electron-vite**: Vite-based build system for Electron
|
||||
- **electron-builder**: Application packaging and distribution
|
||||
- Separate TypeScript configs for Node.js and web contexts
|
||||
- Auto-updating via electron-updater
|
||||
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
39
.vscode/launch.json
vendored
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug Renderer Process",
|
||||
"port": 9222,
|
||||
"request": "attach",
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer",
|
||||
"timeout": 60000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug All",
|
||||
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||
"presentation": {
|
||||
"order": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
.vscode/settings.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"editor.fontSize": 14
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
url: https://update.ceru.shiqianjiang.cn
|
||||
updaterCacheDirName: ceru-music-updater
|
||||
|
||||
BIN
docs/assets/3f50d3b838287b4bf1523d0f955fdf37.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/assets/image-20250826214921963.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/assets/image-20250826215101522.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/assets/image-20250826215206862.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/assets/image-20250826215251525.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/assets/image-20250826221438856.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
docs/assets/image-20250826221517247.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
121
docs/auto-update.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 自动更新功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
本项目集成了完整的自动更新功能,使用 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
Normal file
51
docs/使用文档.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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插件`**(目前生态欠缺)** 或现成的**落雪**插件导入使用
|
||||
|
||||
###### 导入完成点击使用
|
||||
|
||||
@@ -61,6 +61,6 @@ appImage:
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://example.com/auto-updates
|
||||
url: https://update.ceru.shiqianjiang.cn
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.0.9",
|
||||
"version": "1.1.0",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
|
||||
262
src/main/autoUpdate.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'node:path';
|
||||
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let currentUpdateInfo: UpdateInfo | null = null;
|
||||
let downloadProgress = { percent: 0, transferred: 0, total: 0 };
|
||||
|
||||
// 更新信息接口
|
||||
interface UpdateInfo {
|
||||
url: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
pub_date: string;
|
||||
}
|
||||
|
||||
// 更新服务器配置
|
||||
const UPDATE_SERVER = 'https://update.ceru.shiqianjiang.cn';
|
||||
const UPDATE_API_URL = `${UPDATE_SERVER}/update/${process.platform}/${app.getVersion()}`;
|
||||
|
||||
// 初始化自动更新器
|
||||
export function initAutoUpdater(window: BrowserWindow) {
|
||||
mainWindow = window;
|
||||
console.log('Auto updater initialized');
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
export async function checkForUpdates(window?: BrowserWindow) {
|
||||
if (window) {
|
||||
mainWindow = window;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Checking for updates...');
|
||||
mainWindow?.webContents.send('auto-updater:checking-for-update');
|
||||
|
||||
const updateInfo = await fetchUpdateInfo();
|
||||
|
||||
if (updateInfo && isNewerVersion(updateInfo.name, app.getVersion())) {
|
||||
console.log('Update available:', updateInfo);
|
||||
currentUpdateInfo = updateInfo;
|
||||
mainWindow?.webContents.send('auto-updater:update-available', updateInfo);
|
||||
} else {
|
||||
console.log('No update available');
|
||||
mainWindow?.webContents.send('auto-updater:update-not-available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
mainWindow?.webContents.send('auto-updater:error', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取更新信息
|
||||
async function fetchUpdateInfo(): Promise<UpdateInfo | null> {
|
||||
try {
|
||||
const response = await axios.get(UPDATE_API_URL, {
|
||||
timeout: 10000, // 10秒超时
|
||||
validateStatus: (status) => status === 200 || status === 204 // 允许 200 和 204 状态码
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
return response.data as UpdateInfo;
|
||||
} else if (response.status === 204) {
|
||||
// 204 表示没有更新
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
// 服务器响应了错误状态码
|
||||
throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应
|
||||
throw new Error('Network error: No response received');
|
||||
} else {
|
||||
// 其他错误
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 比较版本号
|
||||
function isNewerVersion(remoteVersion: string, currentVersion: string): boolean {
|
||||
const parseVersion = (version: string) => {
|
||||
return version.replace(/^v/, '').split('.').map(num => parseInt(num, 10));
|
||||
};
|
||||
|
||||
const remote = parseVersion(remoteVersion);
|
||||
const current = parseVersion(currentVersion);
|
||||
|
||||
for (let i = 0; i < Math.max(remote.length, current.length); i++) {
|
||||
const r = remote[i] || 0;
|
||||
const c = current[i] || 0;
|
||||
|
||||
if (r > c) return true;
|
||||
if (r < c) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载更新
|
||||
export async function downloadUpdate() {
|
||||
if (!currentUpdateInfo) {
|
||||
throw new Error('No update info available');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting download:', currentUpdateInfo.url);
|
||||
|
||||
// 通知渲染进程开始下载
|
||||
mainWindow?.webContents.send('auto-updater:download-started', currentUpdateInfo);
|
||||
|
||||
const downloadPath = await downloadFile(currentUpdateInfo.url);
|
||||
console.log('Download completed:', downloadPath);
|
||||
|
||||
mainWindow?.webContents.send('auto-updater:update-downloaded', {
|
||||
downloadPath,
|
||||
updateInfo: currentUpdateInfo
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
mainWindow?.webContents.send('auto-updater:error', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
async function downloadFile(url: string): Promise<string> {
|
||||
const fileName = path.basename(url);
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName);
|
||||
|
||||
// 进度节流变量
|
||||
let lastProgressSent = 0;
|
||||
let lastProgressTime = 0;
|
||||
const PROGRESS_THROTTLE_INTERVAL = 500; // 500ms 发送一次进度
|
||||
const PROGRESS_THRESHOLD = 1; // 进度变化超过1%才发送
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'stream',
|
||||
timeout: 30000, // 30秒超时
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
const { loaded, total } = progressEvent;
|
||||
const percent = total ? (loaded / total) * 100 : 0;
|
||||
const currentTime = Date.now();
|
||||
|
||||
// 节流逻辑:只在进度变化显著或时间间隔足够时发送
|
||||
const progressDiff = Math.abs(percent - lastProgressSent);
|
||||
const timeDiff = currentTime - lastProgressTime;
|
||||
|
||||
if (progressDiff >= PROGRESS_THRESHOLD || timeDiff >= PROGRESS_THROTTLE_INTERVAL) {
|
||||
downloadProgress = {
|
||||
percent,
|
||||
transferred: loaded,
|
||||
total: total || 0
|
||||
};
|
||||
|
||||
mainWindow?.webContents.send('auto-updater:download-progress', downloadProgress);
|
||||
lastProgressSent = percent;
|
||||
lastProgressTime = currentTime;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 发送初始进度
|
||||
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
mainWindow?.webContents.send('auto-updater:download-progress', {
|
||||
percent: 0,
|
||||
transferred: 0,
|
||||
total: totalSize
|
||||
});
|
||||
|
||||
// 创建写入流
|
||||
const writer = fs.createWriteStream(downloadPath);
|
||||
|
||||
// 将响应数据流写入文件
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
// 发送最终进度
|
||||
mainWindow?.webContents.send('auto-updater:download-progress', {
|
||||
percent: 100,
|
||||
transferred: totalSize,
|
||||
total: totalSize
|
||||
});
|
||||
|
||||
console.log('File download completed:', downloadPath);
|
||||
resolve(downloadPath);
|
||||
});
|
||||
|
||||
writer.on('error', (error) => {
|
||||
// 删除部分下载的文件
|
||||
fs.unlink(downloadPath, () => {});
|
||||
reject(error);
|
||||
});
|
||||
|
||||
response.data.on('error', (error: Error) => {
|
||||
writer.destroy();
|
||||
fs.unlink(downloadPath, () => {});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
// 删除可能创建的文件
|
||||
if (fs.existsSync(downloadPath)) {
|
||||
fs.unlink(downloadPath, () => {});
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
throw new Error(`Download failed: HTTP ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
throw new Error('Download failed: Network error');
|
||||
} else {
|
||||
throw new Error(`Download failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 退出并安装
|
||||
export function quitAndInstall() {
|
||||
if (!currentUpdateInfo) {
|
||||
console.error('No update info available for installation');
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于不同平台,处理方式不同
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: 打开安装程序
|
||||
const fileName = path.basename(currentUpdateInfo.url);
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName);
|
||||
|
||||
if (fs.existsSync(downloadPath)) {
|
||||
shell.openPath(downloadPath).then(() => {
|
||||
app.quit();
|
||||
});
|
||||
} else {
|
||||
console.error('Downloaded file not found:', downloadPath);
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
// macOS: 打开 dmg 或 zip 文件
|
||||
const fileName = path.basename(currentUpdateInfo.url);
|
||||
const downloadPath = path.join(app.getPath('temp'), fileName);
|
||||
|
||||
if (fs.existsSync(downloadPath)) {
|
||||
shell.openPath(downloadPath).then(() => {
|
||||
app.quit();
|
||||
});
|
||||
} else {
|
||||
console.error('Downloaded file not found:', downloadPath);
|
||||
}
|
||||
} else {
|
||||
// Linux: 打开下载文件夹
|
||||
shell.showItemInFolder(path.join(app.getPath('temp'), path.basename(currentUpdateInfo.url)));
|
||||
}
|
||||
}
|
||||
28
src/main/events/autoUpdate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { initAutoUpdater, checkForUpdates, downloadUpdate, quitAndInstall } from '../autoUpdate';
|
||||
|
||||
// 注册自动更新相关的IPC事件
|
||||
export function registerAutoUpdateEvents() {
|
||||
// 检查更新
|
||||
ipcMain.handle('auto-updater:check-for-updates', (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
if (window) {
|
||||
checkForUpdates(window);
|
||||
}
|
||||
});
|
||||
|
||||
// 下载更新
|
||||
ipcMain.handle('auto-updater:download-update', () => {
|
||||
downloadUpdate();
|
||||
});
|
||||
|
||||
// 安装更新
|
||||
ipcMain.handle('auto-updater:quit-and-install', () => {
|
||||
quitAndInstall();
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化自动更新(在主窗口创建后调用)
|
||||
export function initAutoUpdateForWindow(window: BrowserWindow) {
|
||||
initAutoUpdater(window);
|
||||
}
|
||||
@@ -192,6 +192,7 @@ ipcMain.handle('service-music-request', async (_, api, args) => {
|
||||
|
||||
aiEvents(mainWindow)
|
||||
import './events/musicCache'
|
||||
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
@@ -276,6 +277,14 @@ app.whenReady().then(async () => {
|
||||
|
||||
createWindow()
|
||||
createTray()
|
||||
|
||||
// 注册自动更新事件
|
||||
registerAutoUpdateEvents()
|
||||
|
||||
// 初始化自动更新器
|
||||
if (mainWindow) {
|
||||
initAutoUpdateForWindow(mainWindow)
|
||||
}
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
|
||||
@@ -197,7 +197,9 @@ class CeruMusicPluginHost {
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
makeRequest().catch(() => {}) // 错误已在makeRequest中处理
|
||||
makeRequest().catch((error) => {
|
||||
console.error(`[CeruMusic] Unhandled request error in callback mode: ${error.message}`)
|
||||
}) // 确保错误被正确处理
|
||||
return undefined
|
||||
} else {
|
||||
return makeRequest()
|
||||
@@ -407,7 +409,9 @@ class CeruMusicPluginHost {
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
makeRequest().catch(() => {}) // 错误已在makeRequest中处理
|
||||
makeRequest().catch((error) => {
|
||||
console.error(`[CeruMusic] Unhandled request error in callback mode: ${error.message}`)
|
||||
}) // 确保错误被正确处理
|
||||
return undefined
|
||||
} else {
|
||||
return makeRequest()
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { decodeName, formatPlayTime, sizeFormate } from '../../index'
|
||||
import { signatureParams, createHttpFetch } from './util'
|
||||
import { formatSingerName } from '../../utils'
|
||||
|
||||
export default {
|
||||
limit: 30,
|
||||
total: 0,
|
||||
page: 0,
|
||||
allPage: 1,
|
||||
musicSearch(str, page, limit) {
|
||||
const sign = signatureParams(
|
||||
`userid=0&area_code=1&appid=1005&dopicfull=1&page=${page}&token=0&privilegefilter=0&requestid=0&pagesize=${limit}&user_labels=&clienttime=0&sec_aggre=1&iscorrection=1&uuid=0&mid=0&keyword=${str}&dfid=-&clientver=11409&platform=AndroidFilter&tag=`,
|
||||
3
|
||||
)
|
||||
return createHttpFetch(
|
||||
`https://gateway.kugou.com/complexsearch/v3/search/song?userid=0&area_code=1&appid=1005&dopicfull=1&page=${page}&token=0&privilegefilter=0&requestid=0&pagesize=${limit}&user_labels=&clienttime=0&sec_aggre=1&iscorrection=1&uuid=0&mid=0&dfid=-&clientver=11409&platform=AndroidFilter&tag=&keyword=${encodeURIComponent(str)}&signature=${sign}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
|
||||
referer: 'https://kugou.com'
|
||||
}
|
||||
}
|
||||
).then((body) => body)
|
||||
},
|
||||
filterList(raw) {
|
||||
let ids = new Set()
|
||||
const list = []
|
||||
|
||||
raw.forEach((item) => {
|
||||
if (ids.has(item.Audioid)) return
|
||||
ids.add(item.Audioid)
|
||||
|
||||
const types = []
|
||||
const _types = {}
|
||||
if (item.FileSize !== 0) {
|
||||
let size = sizeFormate(item.FileSize)
|
||||
types.push({ type: '128k', size, hash: item.FileHash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
hash: item.FileHash
|
||||
}
|
||||
}
|
||||
if (item.HQ != undefined) {
|
||||
let size = sizeFormate(item.HQ.FileSize)
|
||||
types.push({ type: '320k', size, hash: item.HQ.Hash })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
hash: item.HQ.Hash
|
||||
}
|
||||
}
|
||||
if (item.SQ != undefined) {
|
||||
let size = sizeFormate(item.SQ.FileSize)
|
||||
types.push({ type: 'flac', size, hash: item.SQ.Hash })
|
||||
_types.flac = {
|
||||
size,
|
||||
hash: item.SQ.Hash
|
||||
}
|
||||
}
|
||||
if (item.Res != undefined) {
|
||||
let size = sizeFormate(item.Res.FileSize)
|
||||
types.push({ type: 'flac24bit', size, hash: item.Res.Hash })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
hash: item.Res.Hash
|
||||
}
|
||||
}
|
||||
list.push({
|
||||
singer: decodeName(formatSingerName(item.Singers)),
|
||||
name: decodeName(item.SongName),
|
||||
albumName: decodeName(item.AlbumName),
|
||||
albumId: item.AlbumID,
|
||||
songmid: item.Audioid,
|
||||
source: 'kg',
|
||||
interval: formatPlayTime(item.Duration),
|
||||
_interval: item.Duration,
|
||||
img: null,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
hash: item.FileHash,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {}
|
||||
})
|
||||
})
|
||||
|
||||
return list
|
||||
},
|
||||
handleResult(rawData) {
|
||||
const rawList = []
|
||||
rawData.forEach((item) => {
|
||||
rawList.push(item)
|
||||
item.Grp.forEach((e) => rawList.push(e))
|
||||
})
|
||||
|
||||
return this.filterList(rawList)
|
||||
},
|
||||
search(str, page = 1, limit, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
if (limit == null) limit = this.limit
|
||||
|
||||
return this.musicSearch(str, page, limit).then((data) => {
|
||||
let list = this.handleResult(data.lists)
|
||||
if (!list) return this.search(str, page, limit, retryNum)
|
||||
|
||||
this.total = data.total
|
||||
this.page = page
|
||||
this.allPage = Math.ceil(this.total / limit)
|
||||
|
||||
return Promise.resolve({
|
||||
list,
|
||||
allPage: this.allPage,
|
||||
limit,
|
||||
total: this.total,
|
||||
source: 'kg'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,915 +0,0 @@
|
||||
import { httpFetch } from '../../../request'
|
||||
import { formatSingerName } from '../../utils'
|
||||
import {
|
||||
decodeName,
|
||||
formatPlayTime,
|
||||
sizeFormate,
|
||||
dateFormat,
|
||||
formatPlayCount
|
||||
} from '../../../index'
|
||||
import { signatureParams, createHttpFetch } from './../util'
|
||||
import { getMusicInfosByList } from '../musicInfo'
|
||||
import album from '../album'
|
||||
|
||||
export default {
|
||||
_requestObj_tags: null,
|
||||
_requestObj_listInfo: null,
|
||||
_requestObj_list: null,
|
||||
_requestObj_listRecommend: null,
|
||||
listDetailLimit: 10000,
|
||||
currentTagInfo: {
|
||||
id: undefined,
|
||||
info: undefined
|
||||
},
|
||||
sortList: [
|
||||
{
|
||||
name: '推荐',
|
||||
id: '5'
|
||||
},
|
||||
{
|
||||
name: '最热',
|
||||
id: '6'
|
||||
},
|
||||
{
|
||||
name: '最新',
|
||||
id: '7'
|
||||
},
|
||||
{
|
||||
name: '热藏',
|
||||
id: '3'
|
||||
},
|
||||
{
|
||||
name: '飙升',
|
||||
id: '8'
|
||||
}
|
||||
],
|
||||
cache: new Map(),
|
||||
collectionIdListInfoCache: new Map(),
|
||||
regExps: {
|
||||
listData: /global\.data = (\[.+\]);/,
|
||||
listInfo: /global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};/,
|
||||
// https://www.kugou.com/yy/special/single/1067062.html
|
||||
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取歌曲列表内的音乐
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
*/
|
||||
async getListDetail(id, page) {
|
||||
id = id.toString()
|
||||
|
||||
if (id.includes('special/single/')) id = id.replace(this.regExps.listDetailLink, '$1')
|
||||
// fix https://www.kugou.com/songlist/xxx/?uid=xxx&chl=qq_client&cover=http%3A%2F%2Fimge.kugou.com%xxx.jpg&iszlist=1
|
||||
if (/https?:/.test(id)) {
|
||||
if (id.includes('#')) id = id.replace(/#.*$/, '')
|
||||
if (id.includes('global_collection_id'))
|
||||
return this.getUserListDetailByCollectionId(
|
||||
id.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'),
|
||||
page
|
||||
)
|
||||
if (id.includes('chain='))
|
||||
return this.getUserListDetail3(id.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
|
||||
if (id.includes('.html')) {
|
||||
if (id.includes('zlist.html')) {
|
||||
id = id.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
|
||||
if (id.includes('pagesize')) {
|
||||
id = id
|
||||
.replace('pagesize=30', 'pagesize=' + this.listDetailLimit)
|
||||
.replace('page=1', 'page=' + page)
|
||||
} else {
|
||||
id += `&pagesize=${this.listDetailLimit}&page=${page}`
|
||||
}
|
||||
} else if (!id.includes('song.html'))
|
||||
return this.getUserListDetail3(
|
||||
id.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'),
|
||||
page
|
||||
)
|
||||
}
|
||||
return this.getUserListDetail(id.replace(/^.*?http/, 'http'), page)
|
||||
}
|
||||
if (/^\d+$/.test(id)) return this.getUserListDetailByCode(id, page)
|
||||
if (id.startsWith('gid_'))
|
||||
return this.getUserListDetailByCollectionId(id.replace('gid_', ''), page)
|
||||
if (id.startsWith('id_')) return this.getUserListDetailBySpecialId(id.replace('id_', ''), page)
|
||||
|
||||
return new Error('Failed.')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取SpecialId歌单
|
||||
* @param {*} id
|
||||
*/
|
||||
async getUserListDetailBySpecialId(id, page, tryNum = 0) {
|
||||
if (tryNum > 2) throw new Error('try max num')
|
||||
|
||||
const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise
|
||||
let listData = body.match(this.regExps.listData)
|
||||
let listInfo = body.match(this.regExps.listInfo)
|
||||
if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum)
|
||||
let list = await getMusicInfosByList(JSON.parse(listData[1]))
|
||||
let name
|
||||
let pic
|
||||
if (listInfo) {
|
||||
name = listInfo[1]
|
||||
pic = listInfo[2]
|
||||
}
|
||||
let desc = this.parseHtmlDesc(body)
|
||||
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name,
|
||||
img: pic,
|
||||
desc
|
||||
// author: body.result.info.userinfo.username,
|
||||
// play_count: formatPlayCount(body.result.listen_num),
|
||||
}
|
||||
}
|
||||
},
|
||||
parseHtmlDesc(html) {
|
||||
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
|
||||
let index = html.indexOf(prefix)
|
||||
if (index < 0) return null
|
||||
const afterStr = html.substring(index + prefix.length)
|
||||
index = afterStr.indexOf('</div>')
|
||||
if (index < 0) return null
|
||||
return decodeName(afterStr.substring(0, index))
|
||||
},
|
||||
|
||||
/**
|
||||
* 使用SpecialId获取CollectionId
|
||||
* @param {*} specialId
|
||||
*/
|
||||
async getCollectionIdBySpecialId(specialId) {
|
||||
return httpFetch(`http://mobilecdnbj.kugou.com/api/v5/special/info?specialid=${specialId}`, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70'
|
||||
}
|
||||
}).promise.then(({ body }) => {
|
||||
// console.log('getCollectionIdBySpecialId', body)
|
||||
if (!body.data.global_specialid)
|
||||
return Promise.reject(new Error('Failed to get global collection id.'))
|
||||
return body.data.global_specialid
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取歌单URL
|
||||
* @param {*} sortId
|
||||
* @param {*} tagId
|
||||
* @param {*} page
|
||||
*/
|
||||
getSongListUrl(sortId, tagId, page) {
|
||||
if (tagId == null) tagId = ''
|
||||
return `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t=${sortId}&c=${tagId}&p=${page}`
|
||||
},
|
||||
getInfoUrl(tagId) {
|
||||
return tagId
|
||||
? `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c=${tagId}`
|
||||
: 'http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&'
|
||||
},
|
||||
getSongListDetailUrl(id) {
|
||||
return `http://www2.kugou.kugou.com/yueku/v9/special/single/${id}-5-9999.html`
|
||||
},
|
||||
|
||||
filterInfoHotTag(rawData) {
|
||||
const result = []
|
||||
if (rawData.status !== 1) return result
|
||||
for (const key of Object.keys(rawData.data)) {
|
||||
let tag = rawData.data[key]
|
||||
result.push({
|
||||
id: tag.special_id,
|
||||
name: tag.special_name,
|
||||
source: 'kg'
|
||||
})
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
filterTagInfo(rawData) {
|
||||
const result = []
|
||||
for (const name of Object.keys(rawData)) {
|
||||
result.push({
|
||||
name,
|
||||
list: rawData[name].data.map((tag) => ({
|
||||
parent_id: tag.parent_id,
|
||||
parent_name: tag.pname,
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
source: 'kg'
|
||||
}))
|
||||
})
|
||||
}
|
||||
return result
|
||||
},
|
||||
filterSongList(rawData) {
|
||||
return rawData.map((item) => ({
|
||||
play_count: item.total_play_count || formatPlayCount(item.play_count),
|
||||
id: 'id_' + item.specialid,
|
||||
author: item.nickname,
|
||||
name: item.specialname,
|
||||
time: dateFormat(item.publish_time || item.publishtime, 'Y-M-D'),
|
||||
img: item.img || item.imgurl,
|
||||
total: item.songcount,
|
||||
grade: item.grade,
|
||||
desc: item.intro,
|
||||
source: 'kg'
|
||||
}))
|
||||
},
|
||||
|
||||
getSongList(sortId, tagId, page, tryNum = 0) {
|
||||
if (this._requestObj_list) this._requestObj_list.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_list = httpFetch(this.getSongListUrl(sortId, tagId, page))
|
||||
return this._requestObj_list.promise.then(({ body }) => {
|
||||
if (!body || body.status !== 1) return this.getSongList(sortId, tagId, page, ++tryNum)
|
||||
return this.filterSongList(body.special_db)
|
||||
})
|
||||
},
|
||||
getSongListRecommend(tryNum = 0) {
|
||||
if (this._requestObj_listRecommend) this._requestObj_listRecommend.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_listRecommend = httpFetch(
|
||||
'http://everydayrec.service.kugou.com/guess_special_recommend',
|
||||
{
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': 'KuGou2012-8275-web_browser_event_handler'
|
||||
},
|
||||
body: {
|
||||
appid: 1001,
|
||||
clienttime: 1566798337219,
|
||||
clientver: 8275,
|
||||
key: 'f1f93580115bb106680d2375f8032d96',
|
||||
mid: '21511157a05844bd085308bc76ef3343',
|
||||
platform: 'pc',
|
||||
userid: '262643156',
|
||||
return_min: 6,
|
||||
return_max: 15
|
||||
}
|
||||
}
|
||||
)
|
||||
return this._requestObj_listRecommend.promise.then(({ body }) => {
|
||||
if (body.status !== 1) return this.getSongListRecommend(++tryNum)
|
||||
return this.filterSongList(body.data.special_list)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 通过CollectionId获取歌单详情
|
||||
* @param {*} id
|
||||
*/
|
||||
async getUserListInfoByCollectionId(id) {
|
||||
if (!id || id.length > 1000) return Promise.reject(new Error('get list error'))
|
||||
if (this.collectionIdListInfoCache.has(id)) return this.collectionIdListInfoCache.get(id)
|
||||
|
||||
const params = `appid=1058&specialid=0&global_specialid=${id}&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-`
|
||||
return createHttpFetch(
|
||||
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`,
|
||||
{
|
||||
headers: {
|
||||
mid: '1586163242519',
|
||||
Referer: 'https://m3ws.kugou.com/share/index.php',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
|
||||
dfid: '-',
|
||||
clienttime: '1586163242519'
|
||||
}
|
||||
}
|
||||
).then((body) => {
|
||||
let info = {
|
||||
type: body.type,
|
||||
userName: body.nickname,
|
||||
userAvatar: body.user_avatar,
|
||||
imageUrl: body.imgurl,
|
||||
desc: body.intro,
|
||||
name: body.specialname,
|
||||
globalSpecialid: body.global_specialid,
|
||||
total: body.songcount,
|
||||
playCount: body.playcount
|
||||
}
|
||||
|
||||
this.collectionIdListInfoCache.set(id, info)
|
||||
return info
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 通过SpecialId获取歌单
|
||||
* @param {*} id
|
||||
*/
|
||||
// async getUserListDetailBySpecialId(id, page = 1, limit = 300) {
|
||||
// if (!id || id.length > 1000) return Promise.reject(new Error('get list error.'))
|
||||
// const listInfo = await this.getListInfoBySpecialId(id)
|
||||
|
||||
// const params = `specialid=${id}&need_sort=1&module=CloudMusic&clientver=11589&pagesize=${limit}&userid=0&page=${page}&type=0&area_code=1&appid=1005`
|
||||
// return createHttpFetch(`http://pubsongs.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params, 2)}`, {
|
||||
// headers: {
|
||||
// 'User-Agent': 'Android10-AndroidPhone-11589-201-0-playlist-wifi',
|
||||
// },
|
||||
// }).then(body => {
|
||||
// if (!body.info) return Promise.reject(new Error('Get list failed.'))
|
||||
// const songList = this.filterListByCollectionId(body.info)
|
||||
|
||||
// return {
|
||||
// list: songList || [],
|
||||
// page,
|
||||
// limit,
|
||||
// total: body.count,
|
||||
// source: 'kg',
|
||||
// info: {
|
||||
// name: listInfo.name,
|
||||
// img: listInfo.image,
|
||||
// desc: listInfo.desc,
|
||||
// // author: listInfo.userName,
|
||||
// // play_count: formatPlayCount(listInfo.playCount),
|
||||
// },
|
||||
// }
|
||||
// })
|
||||
// },
|
||||
/**
|
||||
* 通过CollectionId获取歌单
|
||||
* @param {*} id
|
||||
*/
|
||||
async getUserListDetailByCollectionId(id, page = 1, limit = 300) {
|
||||
if (!id || id.length > 1000) return Promise.reject(new Error('ID error.'))
|
||||
const listInfo = await this.getUserListInfoByCollectionId(id)
|
||||
|
||||
const params = `need_sort=1&module=CloudMusic&clientver=11589&pagesize=${limit}&global_collection_id=${id}&userid=0&page=${page}&type=0&area_code=1&appid=1005`
|
||||
return createHttpFetch(
|
||||
`http://pubsongs.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params, 'android')}`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Android10-AndroidPhone-11589-201-0-playlist-wifi'
|
||||
}
|
||||
}
|
||||
).then((body) => {
|
||||
if (!body.info) return Promise.reject(new Error('Get list failed.'))
|
||||
const songList = this.filterListByCollectionId(body.info)
|
||||
|
||||
return {
|
||||
list: songList || [],
|
||||
page,
|
||||
limit,
|
||||
total: listInfo.total,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: listInfo.name,
|
||||
img: listInfo.imageUrl && listInfo.imageUrl.replace('{size}', 240),
|
||||
desc: listInfo.desc,
|
||||
author: listInfo.userName,
|
||||
play_count: formatPlayCount(listInfo.playCount)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 过滤GlobalSpecialId歌单数据
|
||||
* @param {*} rawData
|
||||
*/
|
||||
filterListByCollectionId(rawData) {
|
||||
let ids = new Set()
|
||||
let list = []
|
||||
rawData.forEach((item) => {
|
||||
if (!item) return
|
||||
if (ids.has(item.hash)) return
|
||||
ids.add(item.hash)
|
||||
const types = []
|
||||
const _types = {}
|
||||
|
||||
item.relate_goods.forEach((data) => {
|
||||
let size = sizeFormate(data.size)
|
||||
switch (data.level) {
|
||||
case 2:
|
||||
types.push({ type: '128k', size, hash: data.hash })
|
||||
_types['128k'] = {
|
||||
size,
|
||||
hash: data.hash
|
||||
}
|
||||
break
|
||||
case 4:
|
||||
types.push({ type: '320k', size, hash: data.hash })
|
||||
_types['320k'] = {
|
||||
size,
|
||||
hash: data.hash
|
||||
}
|
||||
break
|
||||
case 5:
|
||||
types.push({ type: 'flac', size, hash: data.hash })
|
||||
_types.flac = {
|
||||
size,
|
||||
hash: data.hash
|
||||
}
|
||||
break
|
||||
case 6:
|
||||
types.push({ type: 'flac24bit', size, hash: data.hash })
|
||||
_types.flac24bit = {
|
||||
size,
|
||||
hash: data.hash
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
list.push({
|
||||
singer:
|
||||
formatSingerName(item.singerinfo, 'name') ||
|
||||
decodeName(item.name).split(' - ')[0].replace(/&/g, '、'),
|
||||
name: decodeName(item.name).split(' - ')[1],
|
||||
albumName: decodeName(item.albuminfo.name),
|
||||
albumId: item.albuminfo.id,
|
||||
songmid: item.audio_id,
|
||||
source: 'kg',
|
||||
interval: formatPlayTime(parseInt(item.timelen) / 1000),
|
||||
img: null,
|
||||
lrc: null,
|
||||
hash: item.hash,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {}
|
||||
})
|
||||
})
|
||||
return list
|
||||
},
|
||||
/**
|
||||
* 通过酷狗码获取歌单
|
||||
* @param {*} id
|
||||
* @param {*} page
|
||||
*/
|
||||
async getUserListDetailByCode(id, page = 1) {
|
||||
// type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列
|
||||
const codeData = await createHttpFetch('http://t.kugou.com/command/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'KG-RC': 1,
|
||||
'KG-THash': 'network_super_call.cpp:3676261689:379',
|
||||
'User-Agent': ''
|
||||
},
|
||||
body: {
|
||||
appid: 1001,
|
||||
clientver: 9020,
|
||||
mid: '21511157a05844bd085308bc76ef3343',
|
||||
clienttime: 640612895,
|
||||
key: '36164c4015e704673c588ee202b9ecb8',
|
||||
data: id
|
||||
}
|
||||
})
|
||||
if (!codeData) return Promise.reject(new Error('Get list failed.'))
|
||||
const codeInfo = codeData.info
|
||||
|
||||
switch (codeInfo.type) {
|
||||
case 2:
|
||||
if (!codeInfo.global_collection_id)
|
||||
return this.getUserListDetailBySpecialId(codeInfo.id, page)
|
||||
break
|
||||
case 3:
|
||||
return album.getAlbumDetail(codeInfo.id, page)
|
||||
}
|
||||
if (codeInfo.global_collection_id)
|
||||
return this.getUserListDetailByCollectionId(codeInfo.global_collection_id, page)
|
||||
|
||||
if (codeInfo.userid != null) {
|
||||
const songList = await createHttpFetch(
|
||||
'http://www2.kugou.kugou.com/apps/kucodeAndShare/app/',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'KG-RC': 1,
|
||||
'KG-THash': 'network_super_call.cpp:3676261689:379',
|
||||
'User-Agent': ''
|
||||
},
|
||||
body: {
|
||||
appid: 1001,
|
||||
clientver: 9020,
|
||||
mid: '21511157a05844bd085308bc76ef3343',
|
||||
clienttime: 640612895,
|
||||
key: '36164c4015e704673c588ee202b9ecb8',
|
||||
data: {
|
||||
id: codeInfo.id,
|
||||
type: 3,
|
||||
userid: codeInfo.userid,
|
||||
collect_type: 0,
|
||||
page: 1,
|
||||
pagesize: codeInfo.count
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
// console.log(songList)
|
||||
let list = await getMusicInfosByList(songList || codeInfo.list)
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: codeInfo.count,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: codeInfo.name,
|
||||
img: (codeInfo.img_size && codeInfo.img_size.replace('{size}', 240)) || codeInfo.img,
|
||||
// desc: body.result.info.list_desc,
|
||||
author: codeInfo.username
|
||||
// play_count: formatPlayCount(info.count),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getUserListDetail3(chain, page) {
|
||||
const songInfo = await createHttpFetch(
|
||||
`http://m.kugou.com/schain/transfer?pagesize=${this.listDetailLimit}&chain=${chain}&su=1&page=${page}&n=0.7928855356604456`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!songInfo.list) {
|
||||
if (songInfo.global_collection_id)
|
||||
return this.getUserListDetailByCollectionId(songInfo.global_collection_id, page)
|
||||
else
|
||||
return this.getUserListDetail4(songInfo, chain, page).catch(() =>
|
||||
this.getUserListDetail5(chain)
|
||||
)
|
||||
}
|
||||
let list = await getMusicInfosByList(songInfo.list)
|
||||
// console.log(info, songInfo)
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: this.listDetailLimit,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: songInfo.info.name,
|
||||
img: songInfo.info.img,
|
||||
// desc: body.result.info.list_desc,
|
||||
author: songInfo.info.username
|
||||
// play_count: formatPlayCount(info.count),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getUserListDetailByLink({ info }, link) {
|
||||
let listInfo = info['0']
|
||||
let total = listInfo.count
|
||||
let tasks = []
|
||||
let page = 0
|
||||
while (total) {
|
||||
const limit = total > 90 ? 90 : total
|
||||
total -= limit
|
||||
page += 1
|
||||
tasks.push(
|
||||
createHttpFetch(
|
||||
link.replace(/pagesize=\d+/, 'pagesize=' + limit).replace(/page=\d+/, 'page=' + page),
|
||||
{
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
|
||||
Referer: link
|
||||
}
|
||||
}
|
||||
).then((data) => data.list.info)
|
||||
)
|
||||
}
|
||||
let result = await Promise.all(tasks).then(([...datas]) => datas.flat())
|
||||
result = await getMusicInfosByList(result)
|
||||
// console.log(result)
|
||||
return {
|
||||
list: result,
|
||||
page,
|
||||
limit: this.listDetailLimit,
|
||||
total: result.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: listInfo.name,
|
||||
img: listInfo.pic && listInfo.pic.replace('{size}', 240),
|
||||
// desc: body.result.info.list_desc,
|
||||
author: listInfo.list_create_username
|
||||
// play_count: formatPlayCount(listInfo.count),
|
||||
}
|
||||
}
|
||||
},
|
||||
createGetListDetail2Task(id, total) {
|
||||
let tasks = []
|
||||
let page = 0
|
||||
while (total) {
|
||||
const limit = total > 300 ? 300 : total
|
||||
total -= limit
|
||||
page += 1
|
||||
const params =
|
||||
'appid=1058&global_specialid=' +
|
||||
id +
|
||||
'&specialid=0&plat=0&version=8000&page=' +
|
||||
page +
|
||||
'&pagesize=' +
|
||||
limit +
|
||||
'&srcappid=2919&clientver=20000&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-'
|
||||
tasks.push(
|
||||
createHttpFetch(
|
||||
`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(params, 'web')}`,
|
||||
{
|
||||
headers: {
|
||||
mid: '1586163263991',
|
||||
Referer: 'https://m3ws.kugou.com/share/index.php',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
|
||||
dfid: '-',
|
||||
clienttime: '1586163263991'
|
||||
}
|
||||
}
|
||||
).then((data) => data.info)
|
||||
)
|
||||
}
|
||||
return Promise.all(tasks).then(([...datas]) => datas.flat())
|
||||
},
|
||||
async getUserListDetail2(global_collection_id) {
|
||||
let id = global_collection_id
|
||||
if (id.length > 1000) throw new Error('get list error')
|
||||
const params =
|
||||
'appid=1058&specialid=0&global_specialid=' +
|
||||
id +
|
||||
'&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-'
|
||||
let info = await createHttpFetch(
|
||||
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`,
|
||||
{
|
||||
headers: {
|
||||
mid: '1586163242519',
|
||||
Referer: 'https://m3ws.kugou.com/share/index.php',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
|
||||
dfid: '-',
|
||||
clienttime: '1586163242519'
|
||||
}
|
||||
}
|
||||
)
|
||||
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
|
||||
let list = await getMusicInfosByList(songInfo)
|
||||
// console.log(info, songInfo, list)
|
||||
return {
|
||||
list,
|
||||
page: 1,
|
||||
limit: this.listDetailLimit,
|
||||
total: list.length,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: info.specialname,
|
||||
img: info.imgurl && info.imgurl.replace('{size}', 240),
|
||||
desc: info.intro,
|
||||
author: info.nickname,
|
||||
play_count: formatPlayCount(info.playcount)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getListInfoByChain(chain) {
|
||||
if (this.cache.has(chain)) return this.cache.get(chain)
|
||||
const { body } = await httpFetch(`https://m.kugou.com/share/?chain=${chain}&id=${chain}`, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
|
||||
}
|
||||
}).promise
|
||||
// console.log(body)
|
||||
let result = body.match(/var\sphpParam\s=\s({.+?});/)
|
||||
if (result) result = JSON.parse(result[1])
|
||||
this.cache.set(chain, result)
|
||||
return result
|
||||
},
|
||||
|
||||
async getUserListDetailByPcChain(chain) {
|
||||
let key = `${chain}_pc_list`
|
||||
if (this.cache.has(key)) return this.cache.get(key)
|
||||
const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36'
|
||||
}
|
||||
}).promise
|
||||
let result = body.match(/var\sdataFromSmarty\s=\s(\[.+?\])/)
|
||||
if (result) result = JSON.parse(result[1])
|
||||
this.cache.set(chain, result)
|
||||
result = await getMusicInfosByList(result)
|
||||
// console.log(info, songInfo)
|
||||
return result
|
||||
},
|
||||
|
||||
async getUserListDetail4(songInfo, chain, page) {
|
||||
const limit = 100
|
||||
const [listInfo, list] = await Promise.all([
|
||||
this.getListInfoByChain(chain),
|
||||
this.getUserListDetailBySpecialId(songInfo.id, page, limit)
|
||||
])
|
||||
return {
|
||||
list: list || [],
|
||||
page,
|
||||
limit,
|
||||
total: list.length ?? 0,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: listInfo.specialname,
|
||||
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
|
||||
// desc: body.result.info.list_desc,
|
||||
author: listInfo.nickname
|
||||
// play_count: formatPlayCount(info.count),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getUserListDetail5(chain) {
|
||||
const [listInfo, list] = await Promise.all([
|
||||
this.getListInfoByChain(chain),
|
||||
this.getUserListDetailByPcChain(chain)
|
||||
])
|
||||
return {
|
||||
list: list || [],
|
||||
page: 1,
|
||||
limit: this.listDetailLimit,
|
||||
total: list.length ?? 0,
|
||||
source: 'kg',
|
||||
info: {
|
||||
name: listInfo.specialname,
|
||||
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
|
||||
// desc: body.result.info.list_desc,
|
||||
author: listInfo.nickname
|
||||
// play_count: formatPlayCount(info.count),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async getUserListDetail(link, page, retryNum = 0) {
|
||||
if (retryNum > 3) return Promise.reject(new Error('link try max num'))
|
||||
|
||||
const requestLink = httpFetch(link, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
|
||||
Referer: link
|
||||
},
|
||||
follow_max: 2
|
||||
})
|
||||
const {
|
||||
headers: { location },
|
||||
statusCode,
|
||||
body
|
||||
} = await requestLink.promise
|
||||
// console.log(body, location, statusCode)
|
||||
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
|
||||
if (typeof body == 'string') {
|
||||
if (body.includes('"global_collection_id":'))
|
||||
return this.getUserListDetailByCollectionId(
|
||||
body.replace(/^[\s\S]+?"global_collection_id":"(\w+)"[\s\S]+?$/, '$1'),
|
||||
page
|
||||
)
|
||||
if (body.includes('"albumid":'))
|
||||
return album.getAlbumDetail(body.replace(/^[\s\S]+?"albumid":(\w+)[\s\S]+?$/, '$1'), page)
|
||||
if (body.includes('"album_id":') && link.includes('album/info'))
|
||||
return album.getAlbumDetail(body.replace(/^[\s\S]+?"album_id":(\w+)[\s\S]+?$/, '$1'), page)
|
||||
if (body.includes('list_id = "') && link.includes('album/info'))
|
||||
return album.getAlbumDetail(body.replace(/^[\s\S]+?list_id = "(\w+)"[\s\S]+?$/, '$1'), page)
|
||||
}
|
||||
if (location) {
|
||||
// 概念版分享链接 https://t1.kugou.com/xxx
|
||||
if (location.includes('global_specialid'))
|
||||
return this.getUserListDetailByCollectionId(
|
||||
location.replace(/^.*?global_specialid=(\w+)(?:&.*$|#.*$|$)/, '$1'),
|
||||
page
|
||||
)
|
||||
if (location.includes('global_collection_id'))
|
||||
return this.getUserListDetailByCollectionId(
|
||||
location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'),
|
||||
page
|
||||
)
|
||||
if (location.includes('chain='))
|
||||
return this.getUserListDetail3(
|
||||
location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'),
|
||||
page
|
||||
)
|
||||
if (location.includes('.html')) {
|
||||
if (location.includes('zlist.html')) {
|
||||
let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
|
||||
if (link.includes('pagesize')) {
|
||||
link = link
|
||||
.replace('pagesize=30', 'pagesize=' + this.listDetailLimit)
|
||||
.replace('page=1', 'page=' + page)
|
||||
} else {
|
||||
link += `&pagesize=${this.listDetailLimit}&page=${page}`
|
||||
}
|
||||
return this.getUserListDetail(link, page, ++retryNum)
|
||||
} else
|
||||
return this.getUserListDetail3(
|
||||
location.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'),
|
||||
page
|
||||
)
|
||||
}
|
||||
return this.getUserListDetail(location, page, ++retryNum)
|
||||
}
|
||||
if (body.errcode !== 0) return this.getUserListDetail(link, page, ++retryNum)
|
||||
return this.getUserListDetailByLink(body, link)
|
||||
},
|
||||
|
||||
// 获取列表信息
|
||||
getListInfo(tagId, tryNum = 0) {
|
||||
if (this._requestObj_listInfo) this._requestObj_listInfo.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_listInfo = httpFetch(this.getInfoUrl(tagId))
|
||||
return this._requestObj_listInfo.promise.then(({ body }) => {
|
||||
if (body.status !== 1) return this.getListInfo(tagId, ++tryNum)
|
||||
return {
|
||||
limit: body.data.params.pagesize,
|
||||
page: body.data.params.p,
|
||||
total: body.data.params.total,
|
||||
source: 'kg'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取列表数据
|
||||
getList(sortId, tagId, page) {
|
||||
let tasks = [this.getSongList(sortId, tagId, page)]
|
||||
tasks.push(
|
||||
this.currentTagInfo.id === tagId
|
||||
? Promise.resolve(this.currentTagInfo.info)
|
||||
: this.getListInfo(tagId).then((info) => {
|
||||
this.currentTagInfo.id = tagId
|
||||
this.currentTagInfo.info = Object.assign({}, info)
|
||||
return info
|
||||
})
|
||||
)
|
||||
if (!tagId && page === 1 && sortId === this.sortList[0].id)
|
||||
tasks.push(this.getSongListRecommend()) // 如果是所有类别,则顺便获取推荐列表
|
||||
return Promise.all(tasks).then(([list, info, recommendList]) => {
|
||||
if (recommendList) list.unshift(...recommendList)
|
||||
return {
|
||||
list,
|
||||
...info
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取标签
|
||||
getTags(tryNum = 0) {
|
||||
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
this._requestObj_tags = httpFetch(this.getInfoUrl())
|
||||
return this._requestObj_tags.promise.then(({ body }) => {
|
||||
if (body.status !== 1) return this.getTags(++tryNum)
|
||||
return {
|
||||
hotTag: this.filterInfoHotTag(body.data.hotTag),
|
||||
tags: this.filterTagInfo(body.data.tagids),
|
||||
source: 'kg'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getDetailPageUrl(id) {
|
||||
if (typeof id == 'string') {
|
||||
if (/^https?:\/\//.test(id)) return id
|
||||
id = id.replace('id_', '')
|
||||
}
|
||||
return `https://www.kugou.com/yy/special/single/${id}.html`
|
||||
},
|
||||
|
||||
search(text, page, limit = 20) {
|
||||
const params = `userid=1384394652&req_custom=1&appid=1005&req_multi=1&version=11589&page=${page}&filter=0&pagesize=${limit}&order=0&clienttime=1681779443&iscorrection=1&searchsong=0&keyword=${text}&mid=288799920684148686226285199951543865551&dfid=3eSBsO1u97EY1zeIZd40hH4p&clientver=11589&platform=AndroidFilter`
|
||||
const url = encodeURI(
|
||||
`http://complexsearchretry.kugou.com/v1/search/special?${params}&signature=${signatureParams(params, 'android')}`
|
||||
)
|
||||
return createHttpFetch(url).then((body) => {
|
||||
// console.log(body)
|
||||
return {
|
||||
list: body.lists.map((item) => {
|
||||
return {
|
||||
play_count: formatPlayCount(item.total_play_count),
|
||||
id: item.gid ? `gid_${item.gid}` : `id_${item.specialid}`,
|
||||
author: item.nickname,
|
||||
name: item.specialname,
|
||||
time: dateFormat(item.publish_time, 'Y-M-D'),
|
||||
img: item.img,
|
||||
grade: item.grade,
|
||||
desc: item.intro,
|
||||
total: item.song_count,
|
||||
source: 'kg'
|
||||
}
|
||||
}),
|
||||
limit,
|
||||
total: body.total,
|
||||
source: 'kg'
|
||||
}
|
||||
})
|
||||
// http://msearchretry.kugou.com/api/v3/search/special?version=9209&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&pagesize=20&filter=0&page=1&sver=2&with_res_tag=0
|
||||
// http://ioscdn.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&plat=2&version=7910&correct=1&sver=5
|
||||
// http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2
|
||||
}
|
||||
}
|
||||
|
||||
// getList
|
||||
// getTags
|
||||
// getListDetail
|
||||
@@ -1,196 +0,0 @@
|
||||
import { httpFetch } from '../../../request'
|
||||
import { formatPlayTime } from '../../../index'
|
||||
// import { sizeFormate } from '../../index'
|
||||
|
||||
// const boardList = [{ id: 'mg__27553319', name: '咪咕尖叫新歌榜', bangid: '27553319' }, { id: 'mg__27186466', name: '咪咕尖叫热歌榜', bangid: '27186466' }, { id: 'mg__27553408', name: '咪咕尖叫原创榜', bangid: '27553408' }, { id: 'mg__23189800', name: '咪咕港台榜', bangid: '23189800' }, { id: 'mg__23189399', name: '咪咕内地榜', bangid: '23189399' }, { id: 'mg__19190036', name: '咪咕欧美榜', bangid: '19190036' }, { id: 'mg__23189813', name: '咪咕日韩榜', bangid: '23189813' }, { id: 'mg__23190126', name: '咪咕彩铃榜', bangid: '23190126' }, { id: 'mg__15140045', name: '咪咕KTV榜', bangid: '15140045' }, { id: 'mg__15140034', name: '咪咕网络榜', bangid: '15140034' }, { id: 'mg__23217754', name: 'MV榜', bangid: '23217754' }, { id: 'mg__23218151', name: '新专辑榜', bangid: '23218151' }, { id: 'mg__21958042', name: 'iTunes榜', bangid: '21958042' }, { id: 'mg__21975570', name: 'billboard榜', bangid: '21975570' }, { id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815' }, { id: 'mg__22272904', name: '中国TOP排行榜', bangid: '22272904' }, { id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943' }, { id: 'mg__22273437', name: '英国UK榜', bangid: '22273437' }]
|
||||
const boardList = [
|
||||
{ id: 'mg__27553319', name: '尖叫新歌榜', bangid: '27553319', webId: 'jianjiao_newsong' },
|
||||
{ id: 'mg__27186466', name: '尖叫热歌榜', bangid: '27186466', webId: 'jianjiao_hotsong' },
|
||||
{ id: 'mg__27553408', name: '尖叫原创榜', bangid: '27553408', webId: 'jianjiao_original' },
|
||||
{ id: 'mg__migumusic', name: '音乐榜', bangid: 'migumusic', webId: 'migumusic' },
|
||||
{ id: 'mg__movies', name: '影视榜', bangid: 'movies', webId: 'movies' },
|
||||
{ id: 'mg__23189800', name: '港台榜', bangid: '23189800', webId: 'hktw' },
|
||||
{ id: 'mg__23189399', name: '内地榜', bangid: '23189399', webId: 'mainland' },
|
||||
{ id: 'mg__19190036', name: '欧美榜', bangid: '19190036', webId: 'eur_usa' },
|
||||
{ id: 'mg__23189813', name: '日韩榜', bangid: '23189813', webId: 'jpn_kor' },
|
||||
{ id: 'mg__23190126', name: '彩铃榜', bangid: '23190126', webId: 'coloring' },
|
||||
{ id: 'mg__15140045', name: 'KTV榜', bangid: '15140045', webId: 'ktv' },
|
||||
{ id: 'mg__15140034', name: '网络榜', bangid: '15140034', webId: 'network' },
|
||||
{ id: 'mg__23217754', name: 'MV榜', bangid: '23217754', webId: 'mv' },
|
||||
{ id: 'mg__23218151', name: '新专辑榜', bangid: '23218151', webId: 'newalbum' },
|
||||
{ id: 'mg__21958042', name: '美国iTunes榜', bangid: '21958042', webId: 'itunes' },
|
||||
{ id: 'mg__21975570', name: '美国billboard榜', bangid: '21975570', webId: 'billboard' },
|
||||
{ id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815', webId: 'hito' },
|
||||
{ id: 'mg__22272904', name: '中国TOP排行榜', bangid: '22272904' },
|
||||
{ id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943', webId: 'mnet' },
|
||||
{ id: 'mg__22273437', name: '英国UK榜', bangid: '22273437', webId: 'uk' }
|
||||
]
|
||||
// const boardList = [
|
||||
// { id: 'mg__jianjiao_newsong', bangid: 'jianjiao_newsong', name: '尖叫新歌榜' },
|
||||
// { id: 'mg__jianjiao_hotsong', bangid: 'jianjiao_hotsong', name: '尖叫热歌榜' },
|
||||
// { id: 'mg__jianjiao_original', bangid: 'jianjiao_original', name: '尖叫原创榜' },
|
||||
// { id: 'mg__migumusic', bangid: 'migumusic', name: '音乐榜' },
|
||||
// { id: 'mg__movies', bangid: 'movies', name: '影视榜' },
|
||||
// { id: 'mg__mainland', bangid: 'mainland', name: '内地榜' },
|
||||
// { id: 'mg__hktw', bangid: 'hktw', name: '港台榜' },
|
||||
// { id: 'mg__eur_usa', bangid: 'eur_usa', name: '欧美榜' },
|
||||
// { id: 'mg__jpn_kor', bangid: 'jpn_kor', name: '日韩榜' },
|
||||
// { id: 'mg__coloring', bangid: 'coloring', name: '彩铃榜' },
|
||||
// { id: 'mg__ktv', bangid: 'ktv', name: 'KTV榜' },
|
||||
// { id: 'mg__network', bangid: 'network', name: '网络榜' },
|
||||
// { id: 'mg__newalbum', bangid: 'newalbum', name: '新专辑榜' },
|
||||
// { id: 'mg__mv', bangid: 'mv', name: 'MV榜' },
|
||||
// { id: 'mg__itunes', bangid: 'itunes', name: '美国iTunes榜' },
|
||||
// { id: 'mg__billboard', bangid: 'billboard', name: '美国billboard榜' },
|
||||
// { id: 'mg__hito', bangid: 'hito', name: 'Hito中文榜' },
|
||||
// { id: 'mg__mnet', bangid: 'mnet', name: '韩国Melon榜' },
|
||||
// { id: 'mg__uk', bangid: 'uk', name: '英国UK榜' },
|
||||
// ]
|
||||
|
||||
export default {
|
||||
limit: 10000,
|
||||
getUrl(id, page) {
|
||||
const targetBoard = boardList.find((board) => board.bangid == id)
|
||||
return `https://music.migu.cn/v3/music/top/${targetBoard.webId}`
|
||||
// return `http://m.music.migu.cn/migu/remoting/cms_list_tag?nid=${id}&pageSize=${this.limit}&pageNo=${page - 1}`
|
||||
},
|
||||
successCode: '000000',
|
||||
requestBoardsObj: null,
|
||||
regExps: {
|
||||
listData: /var listData = (\{.+\})<\/script>/
|
||||
},
|
||||
getData(url) {
|
||||
const requestObj = httpFetch(url)
|
||||
return requestObj.promise
|
||||
},
|
||||
getSinger(singers) {
|
||||
let arr = []
|
||||
singers.forEach((singer) => {
|
||||
arr.push(singer.name)
|
||||
})
|
||||
return arr.join('、')
|
||||
},
|
||||
getIntv(interval) {
|
||||
if (!interval) return 0
|
||||
let intvArr = interval.split(':')
|
||||
let intv = 0
|
||||
let unit = 1
|
||||
while (intvArr.length) {
|
||||
intv += intvArr.pop() * unit
|
||||
unit *= 60
|
||||
}
|
||||
return parseInt(intv)
|
||||
},
|
||||
formateIntv() {},
|
||||
filterData(rawData) {
|
||||
// console.log(JSON.stringify(rawData))
|
||||
// console.log(rawData)
|
||||
let ids = new Set()
|
||||
const list = []
|
||||
rawData.forEach((item) => {
|
||||
if (ids.has(item.copyrightId)) return
|
||||
ids.add(item.copyrightId)
|
||||
|
||||
const types = []
|
||||
const _types = {}
|
||||
|
||||
const size = null
|
||||
types.push({ type: '128k', size })
|
||||
_types['128k'] = { size }
|
||||
|
||||
if (item.hq) {
|
||||
const size = null
|
||||
types.push({ type: '320k', size })
|
||||
_types['320k'] = { size }
|
||||
}
|
||||
if (item.sq) {
|
||||
const size = null
|
||||
types.push({ type: 'flac', size })
|
||||
_types.flac = { size }
|
||||
}
|
||||
|
||||
list.push({
|
||||
singer: this.getSinger(item.singers),
|
||||
name: item.name,
|
||||
albumName: item.album && item.album.albumName,
|
||||
albumId: item.album && item.album.albumId,
|
||||
songmid: item.id,
|
||||
copyrightId: item.copyrightId,
|
||||
source: 'mg',
|
||||
interval: item.duration ? formatPlayTime(this.getIntv(item.duration)) : null,
|
||||
img: item.mediumPic ? `https:${item.mediumPic}` : null,
|
||||
lrc: null,
|
||||
// lrcUrl: item.lrcUrl,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {}
|
||||
})
|
||||
})
|
||||
return list
|
||||
},
|
||||
filterBoardsData(rawList) {
|
||||
// console.log(rawList)
|
||||
let list = []
|
||||
for (const board of rawList) {
|
||||
if (board.template != 'group1') continue
|
||||
for (const item of board.itemList) {
|
||||
if (
|
||||
(item.template != 'row1' && item.template != 'grid1' && !item.actionUrl) ||
|
||||
!item.actionUrl.includes('rank-info')
|
||||
)
|
||||
continue
|
||||
|
||||
let data = item.displayLogId.param
|
||||
list.push({
|
||||
id: 'mg__' + data.rankId,
|
||||
name: data.rankName,
|
||||
bangid: String(data.rankId)
|
||||
})
|
||||
}
|
||||
}
|
||||
return list
|
||||
},
|
||||
async getBoards(retryNum = 0) {
|
||||
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
// let response
|
||||
// try {
|
||||
// response = await this.getBoardsData()
|
||||
// } catch (error) {
|
||||
// return this.getBoards(retryNum)
|
||||
// }
|
||||
// // console.log(response.body.data.contentItemList)
|
||||
// if (response.statusCode !== 200 || response.body.code !== this.successCode) return this.getBoards(retryNum)
|
||||
// const list = this.filterBoardsData(response.body.data.contentItemList)
|
||||
// // console.log(list)
|
||||
// // console.log(JSON.stringify(list))
|
||||
// this.list = list
|
||||
// return {
|
||||
// list,
|
||||
// source: 'mg',
|
||||
// }
|
||||
this.list = boardList
|
||||
return {
|
||||
list: boardList,
|
||||
source: 'mg'
|
||||
}
|
||||
},
|
||||
getList(bangid, page, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
return this.getData(this.getUrl(bangid, page)).then(({ statusCode, body }) => {
|
||||
if (statusCode !== 200) return this.getList(bangid, page, retryNum)
|
||||
let listData = body.match(this.regExps.listData)
|
||||
if (!listData) return this.getList(bangid, page, retryNum)
|
||||
const datas = JSON.parse(RegExp.$1)
|
||||
// console.log(datas)
|
||||
listData = this.filterData(datas.songs.items)
|
||||
return {
|
||||
total: datas.songs.itemTotal,
|
||||
list: this.filterData(datas.songs.items),
|
||||
limit: this.limit,
|
||||
page,
|
||||
source: 'mg'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/preload/index.d.ts
vendored
@@ -2,6 +2,7 @@ import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { MainApi, MethodParams } from '../main/services/musicSdk/index'
|
||||
// 自定义 API 接口
|
||||
interface CustomAPI {
|
||||
autoUpdater: any
|
||||
minimize: () => void
|
||||
maximize: () => void
|
||||
close: () => void
|
||||
|
||||
@@ -67,7 +67,48 @@ const api = {
|
||||
getSize: () => ipcRenderer.invoke('music-cache:get-size')
|
||||
},
|
||||
|
||||
getUserConfig: () => ipcRenderer.invoke('get-user-config')
|
||||
getUserConfig: () => ipcRenderer.invoke('get-user-config'),
|
||||
|
||||
// 自动更新相关
|
||||
autoUpdater: {
|
||||
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);
|
||||
},
|
||||
onUpdateAvailable: (callback: () => void) => {
|
||||
ipcRenderer.on('auto-updater:update-available', callback);
|
||||
},
|
||||
onUpdateNotAvailable: (callback: () => void) => {
|
||||
ipcRenderer.on('auto-updater:update-not-available', callback);
|
||||
},
|
||||
onDownloadProgress: (callback: (progress: any) => void) => {
|
||||
ipcRenderer.on('auto-updater:download-progress', (_, progress) => callback(progress));
|
||||
},
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
ipcRenderer.on('auto-updater:update-downloaded', callback);
|
||||
},
|
||||
onError: (callback: (error: string) => void) => {
|
||||
ipcRenderer.on('auto-updater:error', (_, error) => callback(error));
|
||||
},
|
||||
onDownloadStarted: (callback: (updateInfo: any) => void) => {
|
||||
ipcRenderer.on('auto-updater:download-started', (_, updateInfo) => callback(updateInfo));
|
||||
},
|
||||
|
||||
// 移除所有监听器
|
||||
removeAllListeners: () => {
|
||||
ipcRenderer.removeAllListeners('auto-updater:checking-for-update');
|
||||
ipcRenderer.removeAllListeners('auto-updater:update-available');
|
||||
ipcRenderer.removeAllListeners('auto-updater:update-not-available');
|
||||
ipcRenderer.removeAllListeners('auto-updater:download-started');
|
||||
ipcRenderer.removeAllListeners('auto-updater:download-progress');
|
||||
ipcRenderer.removeAllListeners('auto-updater:update-downloaded');
|
||||
ipcRenderer.removeAllListeners('auto-updater:error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
3
src/renderer/components.d.ts
vendored
@@ -38,6 +38,9 @@ declare module 'vue' {
|
||||
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']
|
||||
Versions: typeof import('./src/components/Versions.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,25 @@ import { onMounted } from 'vue'
|
||||
import GlobalAudio from './components/Play/GlobalAudio.vue'
|
||||
import FloatBall from './components/AI/FloatBall.vue'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { useAutoUpdate } from './composables/useAutoUpdate'
|
||||
|
||||
const userInfo = LocalUserDetailStore()
|
||||
const { checkForUpdates } = useAutoUpdate()
|
||||
|
||||
import './assets/main.css'
|
||||
import './assets/theme/blue.css'
|
||||
import './assets/theme/pink.css'
|
||||
import './assets/theme/orange.css'
|
||||
import './assets/theme/cyan.css'
|
||||
|
||||
onMounted(() => {
|
||||
userInfo.init()
|
||||
// 设置测试音频URL
|
||||
loadSavedTheme()
|
||||
|
||||
// 应用启动后延迟3秒检查更新,避免影响启动速度
|
||||
setTimeout(() => {
|
||||
checkForUpdates()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
// 基于现有主题文件的配置
|
||||
@@ -51,4 +60,5 @@ const applyTheme = (themeName) => {
|
||||
<router-view />
|
||||
<GlobalAudio />
|
||||
<FloatBall />
|
||||
<UpdateProgress />
|
||||
</template>
|
||||
|
||||
73
src/renderer/src/components/Settings/UpdateSettings.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="update-settings">
|
||||
<div class="update-section">
|
||||
<h3>自动更新</h3>
|
||||
<div class="update-info">
|
||||
<p>当前版本: {{ currentVersion }}</p>
|
||||
<t-button
|
||||
theme="primary"
|
||||
:loading="isChecking"
|
||||
@click="handleCheckUpdate"
|
||||
>
|
||||
{{ isChecking ? '检查中...' : '检查更新' }}
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAutoUpdate } from '../../composables/useAutoUpdate'
|
||||
import { Button as TButton } from 'tdesign-vue-next'
|
||||
|
||||
const { checkForUpdates } = useAutoUpdate()
|
||||
|
||||
const currentVersion = ref('1.0.9') // 从package.json获取
|
||||
const isChecking = ref(false)
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
isChecking.value = true
|
||||
try {
|
||||
await checkForUpdates()
|
||||
} finally {
|
||||
// 延迟重置状态,给用户足够时间看到通知
|
||||
setTimeout(() => {
|
||||
isChecking.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 可以在这里获取当前版本号
|
||||
// currentVersion.value = await window.api.getAppVersion()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-settings {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.update-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.update-section h3 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.update-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.update-info p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
145
src/renderer/src/components/UpdateExample.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="update-example">
|
||||
<div class="update-section">
|
||||
<h3>自动更新</h3>
|
||||
<div class="update-info">
|
||||
<p>当前版本: {{ currentVersion }}</p>
|
||||
<t-button theme="primary" :loading="isChecking" @click="handleCheckUpdate">
|
||||
{{ isChecking ? '检查中...' : '检查更新' }}
|
||||
</t-button>
|
||||
|
||||
<!-- 测试按钮 -->
|
||||
<t-button theme="default" @click="testProgress">
|
||||
测试进度显示
|
||||
</t-button>
|
||||
</div>
|
||||
|
||||
<!-- 显示当前下载状态 -->
|
||||
<div class="debug-info">
|
||||
<p>下载状态: {{ downloadState.isDownloading ? '下载中' : '未下载' }}</p>
|
||||
<p>进度: {{ Math.round(downloadState.progress.percent) }}%</p>
|
||||
<p>已下载: {{ formatBytes(downloadState.progress.transferred) }}</p>
|
||||
<p>总大小: {{ formatBytes(downloadState.progress.total) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义进度组件 -->
|
||||
<UpdateProgress />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAutoUpdate } from '../composables/useAutoUpdate'
|
||||
import UpdateProgress from './UpdateProgress.vue'
|
||||
import { downloadState } from '../services/autoUpdateService'
|
||||
|
||||
const { checkForUpdates } = useAutoUpdate()
|
||||
|
||||
const currentVersion = ref('1.0.8') // 从 package.json 获取
|
||||
const isChecking = ref(false)
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
isChecking.value = true
|
||||
try {
|
||||
await checkForUpdates()
|
||||
} finally {
|
||||
// 延迟重置状态,给用户足够时间看到通知
|
||||
setTimeout(() => {
|
||||
isChecking.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试进度显示
|
||||
const testProgress = () => {
|
||||
console.log('开始测试进度显示')
|
||||
|
||||
// 模拟下载开始
|
||||
downloadState.isDownloading = true
|
||||
downloadState.updateInfo = {
|
||||
url: 'https://example.com/test.zip',
|
||||
name: '1.0.9',
|
||||
notes: '测试更新',
|
||||
pub_date: new Date().toISOString()
|
||||
}
|
||||
downloadState.progress = {
|
||||
percent: 0,
|
||||
transferred: 0,
|
||||
total: 10 * 1024 * 1024 // 10MB
|
||||
}
|
||||
|
||||
// 模拟进度更新
|
||||
let progress = 0
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 10
|
||||
if (progress >= 100) {
|
||||
progress = 100
|
||||
clearInterval(interval)
|
||||
|
||||
// 3秒后停止下载状态
|
||||
setTimeout(() => {
|
||||
downloadState.isDownloading = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
downloadState.progress = {
|
||||
percent: progress,
|
||||
transferred: (downloadState.progress.total * progress) / 100,
|
||||
total: downloadState.progress.total
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// 格式化字节大小
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.update-example {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.update-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.update-section h3 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.update-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.update-info p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-info p {
|
||||
margin: 4px 0;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
225
src/renderer/src/components/UpdateProgress.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div v-if="downloadState.isDownloading" class="update-progress-overlay">
|
||||
<div class="update-progress-modal">
|
||||
<div class="progress-header">
|
||||
<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"
|
||||
:style="{ width: `${downloadState.progress.percent}%` }"
|
||||
></div>
|
||||
</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>
|
||||
<span>总大小: {{ formatBytes(downloadState.progress.total) }}</span>
|
||||
</div>
|
||||
<div class="download-speed" v-if="downloadSpeed > 0">
|
||||
下载速度: {{ formatBytes(downloadSpeed) }}/s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { downloadState } from '../services/autoUpdateService'
|
||||
|
||||
const downloadSpeed = ref(0)
|
||||
let lastTransferred = 0
|
||||
let lastTime = 0
|
||||
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.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
|
||||
}
|
||||
downloadSpeed.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (speedInterval) {
|
||||
clearInterval(speedInterval)
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化字节大小
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.update-progress-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.update-progress-modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.progress-header h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #0052d9, #266fe8);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-weight: 600;
|
||||
color: #0052d9;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.download-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.download-speed {
|
||||
text-align: center;
|
||||
color: #0052d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 暗色主题适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.update-progress-modal {
|
||||
background: #2d2d2d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.progress-header h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.progress-header p {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.progress-details {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
src/renderer/src/composables/useAutoUpdate.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { autoUpdateService, downloadState } from '../services/autoUpdateService';
|
||||
|
||||
export function useAutoUpdate() {
|
||||
// 检查更新
|
||||
const checkForUpdates = async () => {
|
||||
await autoUpdateService.checkForUpdates();
|
||||
};
|
||||
|
||||
// 下载更新
|
||||
const downloadUpdate = async () => {
|
||||
await autoUpdateService.downloadUpdate();
|
||||
};
|
||||
|
||||
// 安装更新
|
||||
const quitAndInstall = async () => {
|
||||
await autoUpdateService.quitAndInstall();
|
||||
};
|
||||
|
||||
return {
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
quitAndInstall,
|
||||
downloadState // 导出下载状态供组件使用
|
||||
};
|
||||
}
|
||||
237
src/renderer/src/services/autoUpdateService.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { NotifyPlugin, DialogPlugin } from 'tdesign-vue-next';
|
||||
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export interface DownloadProgress {
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
url: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
pub_date: string;
|
||||
}
|
||||
|
||||
// 响应式的下载状态
|
||||
export const downloadState = reactive({
|
||||
isDownloading: false,
|
||||
progress: {
|
||||
percent: 0,
|
||||
transferred: 0,
|
||||
total: 0
|
||||
} as DownloadProgress,
|
||||
updateInfo: null as UpdateInfo | null
|
||||
});
|
||||
|
||||
export class AutoUpdateService {
|
||||
private static instance: AutoUpdateService;
|
||||
private isListening = false;
|
||||
|
||||
constructor() {
|
||||
// 构造函数中自动开始监听
|
||||
this.startListening();
|
||||
}
|
||||
|
||||
static getInstance(): AutoUpdateService {
|
||||
if (!AutoUpdateService.instance) {
|
||||
AutoUpdateService.instance = new AutoUpdateService();
|
||||
}
|
||||
return AutoUpdateService.instance;
|
||||
}
|
||||
|
||||
// 开始监听更新消息
|
||||
startListening() {
|
||||
if (this.isListening) return;
|
||||
|
||||
this.isListening = true;
|
||||
|
||||
// 监听各种更新事件
|
||||
window.api.autoUpdater.onCheckingForUpdate(() => {
|
||||
this.showCheckingNotification();
|
||||
});
|
||||
|
||||
window.api.autoUpdater.onUpdateAvailable((_,updateInfo: UpdateInfo) => {
|
||||
this.showUpdateAvailableDialog(updateInfo);
|
||||
});
|
||||
|
||||
window.api.autoUpdater.onUpdateNotAvailable(() => {
|
||||
this.showNoUpdateNotification();
|
||||
});
|
||||
|
||||
window.api.autoUpdater.onDownloadStarted((updateInfo: UpdateInfo) => {
|
||||
this.handleDownloadStarted(updateInfo);
|
||||
});
|
||||
|
||||
window.api.autoUpdater.onDownloadProgress((progress: DownloadProgress) => {
|
||||
console.log(progress)
|
||||
|
||||
this.showDownloadProgressNotification(progress);
|
||||
});
|
||||
|
||||
window.api.autoUpdater.onUpdateDownloaded(() => {
|
||||
this.showUpdateDownloadedDialog();
|
||||
});
|
||||
|
||||
window.api.autoUpdater.onError((_,error: string) => {
|
||||
this.showUpdateErrorNotification(error);
|
||||
});
|
||||
}
|
||||
|
||||
// 停止监听更新消息
|
||||
stopListening() {
|
||||
if (!this.isListening) return;
|
||||
|
||||
this.isListening = false;
|
||||
window.api.autoUpdater.removeAllListeners();
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
await window.api.autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
NotifyPlugin.error({
|
||||
title: '更新检查失败',
|
||||
content: '无法检查更新,请稍后重试',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 下载更新
|
||||
async downloadUpdate() {
|
||||
try {
|
||||
await window.api.autoUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
console.error('下载更新失败:', error);
|
||||
NotifyPlugin.error({
|
||||
title: '下载更新失败',
|
||||
content: '无法下载更新,请稍后重试',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 安装更新
|
||||
async quitAndInstall() {
|
||||
try {
|
||||
await window.api.autoUpdater.quitAndInstall();
|
||||
} catch (error) {
|
||||
console.error('安装更新失败:', error);
|
||||
NotifyPlugin.error({
|
||||
title: '安装更新失败',
|
||||
content: '无法安装更新,请稍后重试',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 显示检查更新通知
|
||||
private showCheckingNotification() {
|
||||
NotifyPlugin.info({
|
||||
title: '检查更新',
|
||||
content: '正在检查是否有新版本...',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
|
||||
// 显示有更新可用对话框
|
||||
private showUpdateAvailableDialog(updateInfo: UpdateInfo) {
|
||||
// 保存更新信息到状态中
|
||||
downloadState.updateInfo = updateInfo;
|
||||
console.log(updateInfo)
|
||||
const releaseDate = new Date(updateInfo.pub_date).toLocaleDateString('zh-CN');
|
||||
|
||||
const dialog =DialogPlugin.confirm({
|
||||
header: `发现新版本 ${updateInfo.name}`,
|
||||
body: `发布时间: ${releaseDate}\n\n更新说明:\n${updateInfo.notes || '暂无更新说明'}\n\n是否立即下载此更新?`,
|
||||
confirmBtn: '立即下载',
|
||||
cancelBtn: '稍后提醒',
|
||||
onConfirm: () => {
|
||||
this.downloadUpdate();
|
||||
dialog.hide()
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('用户选择稍后下载更新');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 显示无更新通知
|
||||
private showNoUpdateNotification() {
|
||||
NotifyPlugin.info({
|
||||
title: '已是最新版本',
|
||||
content: '当前已是最新版本,无需更新',
|
||||
duration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
// 处理下载开始事件
|
||||
private handleDownloadStarted(updateInfo: UpdateInfo) {
|
||||
downloadState.isDownloading = true;
|
||||
downloadState.updateInfo = updateInfo;
|
||||
downloadState.progress = {
|
||||
percent: 0,
|
||||
transferred: 0,
|
||||
total: 0
|
||||
};
|
||||
|
||||
console.log('开始下载更新:', updateInfo.name);
|
||||
}
|
||||
|
||||
// 更新下载进度状态
|
||||
private showDownloadProgressNotification(progress: DownloadProgress) {
|
||||
// 更新响应式状态
|
||||
downloadState.isDownloading = true;
|
||||
downloadState.progress = progress;
|
||||
|
||||
console.log(`下载进度: ${Math.round(progress.percent)}% (${this.formatBytes(progress.transferred)} / ${this.formatBytes(progress.total)})`);
|
||||
}
|
||||
|
||||
// 显示更新下载完成对话框
|
||||
private showUpdateDownloadedDialog() {
|
||||
// 更新下载状态
|
||||
downloadState.isDownloading = false;
|
||||
downloadState.progress.percent = 100;
|
||||
|
||||
DialogPlugin.confirm({
|
||||
header: '更新下载完成',
|
||||
body: '新版本已下载完成,是否立即重启应用以完成更新?',
|
||||
confirmBtn: '立即重启',
|
||||
cancelBtn: '稍后重启',
|
||||
onConfirm: () => {
|
||||
this.quitAndInstall();
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log('用户选择稍后重启');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 显示更新错误通知
|
||||
private showUpdateErrorNotification(error: string) {
|
||||
NotifyPlugin.error({
|
||||
title: '更新失败',
|
||||
content: `更新过程中出现错误: ${error}`,
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化字节大小
|
||||
private 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];
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const autoUpdateService = AutoUpdateService.getInstance();
|
||||
51
src/renderer/src/views/Settings.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="settings-container">
|
||||
<h2>应用设置</h2>
|
||||
|
||||
<!-- 其他设置项 -->
|
||||
<div class="settings-section">
|
||||
<h3>常规设置</h3>
|
||||
<!-- 这里可以添加其他设置项 -->
|
||||
</div>
|
||||
|
||||
<!-- 自动更新设置 -->
|
||||
<UpdateSettings />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UpdateSettings from '../components/Settings/UpdateSettings.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-container h2 {
|
||||
margin-bottom: 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 32px;
|
||||
padding: 20px;
|
||||
background: var(--td-bg-color-container);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
</style>
|
||||
9
src/types/global.d.ts
vendored
@@ -11,6 +11,15 @@ declare global {
|
||||
getSize: () => Promise<number>
|
||||
}
|
||||
}
|
||||
api: {
|
||||
// 自动更新相关
|
||||
autoUpdater: {
|
||||
checkForUpdates: () => Promise<void>
|
||||
quitAndInstall: () => Promise<void>
|
||||
onMessage: (callback: (data: { type: string; data?: any }) => void) => void
|
||||
removeMessageListener: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1467
website/CeruUse.html
Normal file
BIN
website/assets/3f50d3b838287b4bf1523d0f955fdf37.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
website/assets/image-20250813180317221.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
website/assets/image-20250813180856660.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
website/assets/image-20250813180944752.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
website/assets/image-20250826214921963.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
website/assets/image-20250826215101522.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
website/assets/image-20250826215206862.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
website/assets/image-20250826215251525.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
website/assets/image-20250826221438856.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
website/assets/image-20250826221517247.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
1587
website/design.html
Normal file
258
website/index.html
Normal file
@@ -0,0 +1,258 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ceru Music - 跨平台音乐播放器</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<div class="nav-logo">
|
||||
<img src="./resources/logo.svg" alt="Ceru Music" class="logo-img">
|
||||
<span class="logo-text">Ceru Music</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">功能特色</a>
|
||||
<a href="#download">下载</a>
|
||||
<a href="./CeruUse.html" target="_blank">文档</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero">
|
||||
<div class="hero-container">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">
|
||||
<span class="gradient-text">Ceru Music</span>
|
||||
<br>跨平台音乐播放器
|
||||
</h1>
|
||||
<p class="hero-description">
|
||||
集成多平台音乐源,提供优雅的桌面音乐体验。支持网易云音乐、QQ音乐等多个平台,让你的音乐世界更加丰富。
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<button class="btn btn-primary" onclick="scrollToDownload()">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7,10 12,15 17,10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
立即下载
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="scrollToFeatures()">
|
||||
了解更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-image">
|
||||
<div class="app-preview">
|
||||
<div class="app-window">
|
||||
<div class="window-header">
|
||||
<div class="window-controls">
|
||||
<span class="control close"></span>
|
||||
<span class="control minimize"></span>
|
||||
<span class="control maximize"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-content">
|
||||
<div class="music-player-preview">
|
||||
<div class="album-art"></div>
|
||||
<div class="player-info">
|
||||
<div class="song-title"></div>
|
||||
<div class="artist-name"></div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="features">
|
||||
<div class="container">
|
||||
<h2 class="section-title">功能特色</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v6m0 6v6"/>
|
||||
<path d="m21 12-6-3-6 3-6-3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>多平台音源</h3>
|
||||
<p>支持网易云音乐、QQ音乐等多个平台,一站式访问海量音乐资源</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>跨平台支持</h3>
|
||||
<p>原生桌面应用,支持 Windows、macOS、Linux 三大操作系统</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M9 18V5l12-2v13"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<circle cx="18" cy="16" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>歌词显示</h3>
|
||||
<p>实时歌词显示,支持专辑信息获取,让音乐体验更加丰富</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>性能优化</h3>
|
||||
<p>虚拟滚动技术,轻松处理大型音乐列表,流畅的用户体验</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>本地播放列表</h3>
|
||||
<p>创建和管理个人播放列表,本地数据存储,个性化音乐体验</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>优雅界面</h3>
|
||||
<p>现代化设计语言,流畅动画效果,为你带来愉悦的视觉体验</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Download Section -->
|
||||
<section id="download" class="download">
|
||||
<div class="container">
|
||||
<h2 class="section-title">立即下载</h2>
|
||||
<p class="section-subtitle">选择适合你操作系统的版本</p>
|
||||
<div class="download-cards">
|
||||
<div class="download-card">
|
||||
<div class="platform-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M0 12v-2h24v2H12.5c-.2 0-.5.2-.5.5s.3.5.5.5H24v2H0v-2h11.5c.2 0 .5-.2.5-.5s-.3-.5-.5-.5H0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Windows</h3>
|
||||
<p>Windows 10/11 (64-bit)</p>
|
||||
<button class="btn btn-download" onclick="downloadApp('windows')">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7,10 12,15 17,10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
下载 .exe
|
||||
</button>
|
||||
</div>
|
||||
<div class="download-card">
|
||||
<div class="platform-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.5 2C13.3 2 14 2.7 14 3.5S13.3 5 12.5 5 11 4.3 11 3.5 11.7 2 12.5 2M21 9H15L13.5 7.5C13.1 7.1 12.6 6.9 12 6.9S10.9 7.1 10.5 7.5L9 9H3C1.9 9 1 9.9 1 11V19C1 20.1 1.9 21 3 21H21C22.1 21 23 20.1 23 19V11C23 9.9 22.1 9 21 9Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>macOS</h3>
|
||||
<p>macOS 10.15+ (Intel & Apple Silicon)</p>
|
||||
<button class="btn btn-download" onclick="downloadApp('macos')">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7,10 12,15 17,10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
下载 .dmg
|
||||
</button>
|
||||
</div>
|
||||
<div class="download-card">
|
||||
<div class="platform-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C13.1 2 14 2.9 14 4C14 5.1 13.1 6 12 6C10.9 6 10 5.1 10 4C10 2.9 10.9 2 12 2M21 9V7L15 1H5C3.9 1 3 1.9 3 3V21C3 22.1 3.9 23 5 23H19C20.1 23 21 22.1 21 21V9H21Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Linux</h3>
|
||||
<p>Ubuntu 18.04+ / Debian 10+</p>
|
||||
<button class="btn btn-download" onclick="downloadApp('linux')">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7,10 12,15 17,10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
下载 .AppImage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-info">
|
||||
<p>当前版本: <span class="version">v1.0.0</span> | 更新时间: 2024年12月</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<div class="footer-logo">
|
||||
<img src="./resources/logo.svg" alt="Ceru Music" class="logo-img">
|
||||
<span class="logo-text">Ceru Music</span>
|
||||
</div>
|
||||
<p>跨平台音乐播放器,为你带来优雅的音乐体验</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>产品</h4>
|
||||
<ul>
|
||||
<li><a href="#features">功能特色</a></li>
|
||||
<li><a href="#download">下载</a></li>
|
||||
<li><a href="./design.html">设计文档</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>开发者</h4>
|
||||
<ul>
|
||||
<!-- <li><a href="../docs/api.md">API 文档</a></li> -->
|
||||
<li><a href="./pluginDev.html">插件开发</a></li>
|
||||
<li><a href="https://github.com/timeshiftsauce">GitHub</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>支持</h4>
|
||||
<ul>
|
||||
<li><a href="./CeruUse.html">使用文档</a></li>
|
||||
<li><a href="#contact">联系我们</a></li>
|
||||
<li><a href="#feedback">反馈建议</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2025 Ceru Music. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1567
website/pluginDev.html
Normal file
BIN
website/resources/icon.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
website/resources/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
website/resources/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
website/resources/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 437 B |
BIN
website/resources/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 633 B |
BIN
website/resources/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
website/resources/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 815 B |
BIN
website/resources/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
website/resources/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
website/resources/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
website/resources/icons/icon.icns
Normal file
BIN
website/resources/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
website/resources/logo.ico
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
website/resources/logo.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
10
website/resources/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 |
576
website/script.js
Normal file
@@ -0,0 +1,576 @@
|
||||
// Smooth scrolling functions
|
||||
function scrollToDownload() {
|
||||
document.getElementById('download').scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToFeatures() {
|
||||
document.getElementById('features').scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// GitHub repository configuration
|
||||
const GITHUB_REPO = 'timeshiftsauce/CeruMusic';
|
||||
const GITHUB_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
|
||||
|
||||
// Cache for release data
|
||||
let releaseData = null;
|
||||
let releaseDataTimestamp = null;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Download functionality
|
||||
async function downloadApp(platform) {
|
||||
const button = event.target;
|
||||
const originalText = button.innerHTML;
|
||||
|
||||
// Show loading state
|
||||
button.innerHTML = `
|
||||
<svg class="btn-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56"/>
|
||||
</svg>
|
||||
获取下载链接...
|
||||
`;
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
// Get latest release data
|
||||
const release = await getLatestRelease();
|
||||
|
||||
if (!release) {
|
||||
throw new Error('无法获取最新版本信息');
|
||||
}
|
||||
|
||||
// Find the appropriate download asset
|
||||
const downloadUrl = findDownloadAsset(release.assets, platform);
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw new Error(`暂无 ${getPlatformName(platform)} 版本下载`);
|
||||
}
|
||||
|
||||
// Show success notification
|
||||
showNotification(`正在下载 ${getPlatformName(platform)} 版本 v${release.tag_name}...`, 'success');
|
||||
|
||||
// Start download
|
||||
window.open(downloadUrl, '_blank');
|
||||
|
||||
// Track download
|
||||
trackDownload(platform, release.tag_name);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
showNotification(`下载失败: ${error.message}`, 'error');
|
||||
|
||||
// Fallback to GitHub releases page
|
||||
setTimeout(() => {
|
||||
showNotification('正在跳转到GitHub下载页面...', 'info');
|
||||
window.open(`https://github.com/${GITHUB_REPO}/releases/latest`, '_blank');
|
||||
}, 2000);
|
||||
} finally {
|
||||
// Restore button state
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
// Get latest release from GitHub API
|
||||
async function getLatestRelease() {
|
||||
// Check cache first
|
||||
const now = Date.now();
|
||||
if (releaseData && releaseDataTimestamp && (now - releaseDataTimestamp) < CACHE_DURATION) {
|
||||
return releaseData;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(GITHUB_API_URL);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Cache the data
|
||||
releaseData = data;
|
||||
releaseDataTimestamp = now;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch release data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Find appropriate download asset based on platform
|
||||
function findDownloadAsset(assets, platform) {
|
||||
if (!assets || !Array.isArray(assets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Define file patterns for each platform
|
||||
const patterns = {
|
||||
windows: [
|
||||
/\.exe$/i,
|
||||
/windows.*\.zip$/i,
|
||||
/win32.*\.zip$/i,
|
||||
/win.*x64.*\.zip$/i
|
||||
],
|
||||
macos: [
|
||||
/\.dmg$/i,
|
||||
/darwin.*\.zip$/i,
|
||||
/macos.*\.zip$/i,
|
||||
/mac.*\.zip$/i,
|
||||
/osx.*\.zip$/i
|
||||
],
|
||||
linux: [
|
||||
/\.AppImage$/i,
|
||||
/linux.*\.zip$/i,
|
||||
/linux.*\.tar\.gz$/i,
|
||||
/\.deb$/i,
|
||||
/\.rpm$/i
|
||||
]
|
||||
};
|
||||
|
||||
const platformPatterns = patterns[platform] || [];
|
||||
|
||||
// Try to find exact match
|
||||
for (const pattern of platformPatterns) {
|
||||
const asset = assets.find(asset => pattern.test(asset.name));
|
||||
if (asset) {
|
||||
return asset.browser_download_url;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for any asset that might match the platform
|
||||
const fallbackPatterns = {
|
||||
windows: /win|exe/i,
|
||||
macos: /mac|darwin|dmg/i,
|
||||
linux: /linux|appimage|deb|rpm/i
|
||||
};
|
||||
|
||||
const fallbackPattern = fallbackPatterns[platform];
|
||||
if (fallbackPattern) {
|
||||
const asset = assets.find(asset => fallbackPattern.test(asset.name));
|
||||
if (asset) {
|
||||
return asset.browser_download_url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPlatformName(platform) {
|
||||
const names = {
|
||||
windows: 'Windows',
|
||||
macos: 'macOS',
|
||||
linux: 'Linux'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
}
|
||||
|
||||
// Notification system
|
||||
function showNotification(message, type = 'info') {
|
||||
// Remove existing notifications
|
||||
const existingNotifications = document.querySelectorAll('.notification');
|
||||
existingNotifications.forEach(notification => notification.remove());
|
||||
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="notification-message">${message}</span>
|
||||
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add notification styles if not already added
|
||||
if (!document.querySelector('#notification-styles')) {
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'notification-styles';
|
||||
styles.textContent = `
|
||||
.notification {
|
||||
position: fixed;
|
||||
top: 90px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1001;
|
||||
min-width: 300px;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.notification-success {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.notification-close svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.notification {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
|
||||
// Add to page
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (notification.parentElement) {
|
||||
notification.style.animation = 'slideInRight 0.3s ease-out reverse';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Navbar scroll effect
|
||||
function handleNavbarScroll() {
|
||||
const navbar = document.querySelector('.navbar');
|
||||
if (window.scrollY > 50) {
|
||||
navbar.style.background = 'rgba(255, 255, 255, 0.98)';
|
||||
navbar.style.boxShadow = 'var(--shadow)';
|
||||
} else {
|
||||
navbar.style.background = 'rgba(255, 255, 255, 0.95)';
|
||||
navbar.style.boxShadow = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Intersection Observer for animations
|
||||
function setupAnimations() {
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.animation = 'fadeInUp 0.6s ease-out forwards';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// Observe feature cards and download cards
|
||||
document.querySelectorAll('.feature-card, .download-card').forEach(card => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(30px)';
|
||||
observer.observe(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-detect user's operating system
|
||||
function detectOS() {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (userAgent.includes('win')) return 'windows';
|
||||
if (userAgent.includes('mac')) return 'macos';
|
||||
if (userAgent.includes('linux')) return 'linux';
|
||||
return 'windows'; // default
|
||||
}
|
||||
|
||||
// Highlight user's OS download option
|
||||
function highlightUserOS() {
|
||||
const userOS = detectOS();
|
||||
const downloadCards = document.querySelectorAll('.download-card');
|
||||
|
||||
downloadCards.forEach((card, index) => {
|
||||
const platforms = ['windows', 'macos', 'linux'];
|
||||
if (platforms[index] === userOS) {
|
||||
card.style.border = '2px solid var(--primary-color)';
|
||||
card.style.transform = 'scale(1.02)';
|
||||
|
||||
// Add "推荐" badge
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'recommended-badge';
|
||||
badge.textContent = '推荐';
|
||||
badge.style.cssText = `
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 20px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
`;
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(badge);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
function setupKeyboardNavigation() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Close notifications
|
||||
document.querySelectorAll('.notification').forEach(notification => {
|
||||
notification.remove();
|
||||
});
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.target.classList.contains('btn')) {
|
||||
e.target.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Performance optimization: Lazy load images
|
||||
function setupLazyLoading() {
|
||||
const images = document.querySelectorAll('img[data-src]');
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute('data-src');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
images.forEach(img => imageObserver.observe(img));
|
||||
}
|
||||
|
||||
// Initialize everything when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Setup scroll effects
|
||||
window.addEventListener('scroll', handleNavbarScroll);
|
||||
|
||||
// Setup animations
|
||||
setupAnimations();
|
||||
|
||||
// Highlight user's OS
|
||||
highlightUserOS();
|
||||
|
||||
// Setup keyboard navigation
|
||||
setupKeyboardNavigation();
|
||||
|
||||
// Setup lazy loading
|
||||
setupLazyLoading();
|
||||
|
||||
// Add GitHub links
|
||||
addGitHubLinks();
|
||||
|
||||
// Update version information from GitHub
|
||||
await updateVersionInfo();
|
||||
|
||||
// Add smooth scrolling to all anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Remove the old download button click handlers since downloadApp now handles everything
|
||||
// The downloadApp function is called directly from the HTML onclick attributes
|
||||
});
|
||||
|
||||
// Add spinning animation for loading state
|
||||
const spinningStyles = document.createElement('style');
|
||||
spinningStyles.textContent = `
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(spinningStyles);
|
||||
|
||||
// Error handling for failed downloads
|
||||
window.addEventListener('error', (e) => {
|
||||
console.error('页面错误:', e.error);
|
||||
});
|
||||
|
||||
// Update version information on page
|
||||
async function updateVersionInfo() {
|
||||
try {
|
||||
const release = await getLatestRelease();
|
||||
if (release) {
|
||||
const versionElement = document.querySelector('.version');
|
||||
const versionInfoElement = document.querySelector('.version-info p');
|
||||
|
||||
if (versionElement) {
|
||||
versionElement.textContent = release.tag_name;
|
||||
}
|
||||
|
||||
if (versionInfoElement) {
|
||||
const publishDate = new Date(release.published_at);
|
||||
const formattedDate = publishDate.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
versionInfoElement.innerHTML = `当前版本: <span class="version">${release.tag_name}</span> | 更新时间: ${formattedDate}`;
|
||||
}
|
||||
|
||||
// Update download button text with file sizes if available
|
||||
updateDownloadButtonsWithAssets(release.assets);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update version info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update download buttons with asset information
|
||||
function updateDownloadButtonsWithAssets(assets) {
|
||||
if (!assets || !Array.isArray(assets)) return;
|
||||
|
||||
const downloadCards = document.querySelectorAll('.download-card');
|
||||
const platforms = ['windows', 'macos', 'linux'];
|
||||
|
||||
downloadCards.forEach((card, index) => {
|
||||
const platform = platforms[index];
|
||||
const asset = findAssetForPlatform(assets, platform);
|
||||
|
||||
if (asset) {
|
||||
const button = card.querySelector('.btn-download');
|
||||
const sizeText = formatFileSize(asset.size);
|
||||
const originalText = button.innerHTML;
|
||||
|
||||
// Add file size info
|
||||
button.innerHTML = originalText.replace(/下载 \.(.*?)$/, `下载 .${getFileExtension(asset.name)} (${sizeText})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to find asset for platform
|
||||
function findAssetForPlatform(assets, platform) {
|
||||
const patterns = {
|
||||
windows: [/\.exe$/i, /windows.*\.zip$/i, /win32.*\.zip$/i],
|
||||
macos: [/\.dmg$/i, /darwin.*\.zip$/i, /macos.*\.zip$/i],
|
||||
linux: [/\.AppImage$/i, /linux.*\.zip$/i, /\.deb$/i]
|
||||
};
|
||||
|
||||
const platformPatterns = patterns[platform] || [];
|
||||
|
||||
for (const pattern of platformPatterns) {
|
||||
const asset = assets.find(asset => pattern.test(asset.name));
|
||||
if (asset) return asset;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to get file extension
|
||||
function getFileExtension(filename) {
|
||||
return filename.split('.').pop();
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes) {
|
||||
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(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Analytics tracking (placeholder)
|
||||
function trackDownload(platform, version) {
|
||||
// Add your analytics tracking code here
|
||||
console.log(`Download tracked: ${platform} v${version}`);
|
||||
|
||||
// Example: Google Analytics
|
||||
// gtag('event', 'download', {
|
||||
// 'event_category': 'software',
|
||||
// 'event_label': platform,
|
||||
// 'value': version
|
||||
// });
|
||||
}
|
||||
|
||||
// Add GitHub link functionality
|
||||
function addGitHubLinks() {
|
||||
// Add GitHub link to footer if not exists
|
||||
const footerSection = document.querySelector('.footer-section:nth-child(3) ul');
|
||||
if (footerSection) {
|
||||
const githubLink = document.createElement('li');
|
||||
githubLink.innerHTML = `<a href="https://github.com/${GITHUB_REPO}" target="_blank">GitHub 仓库</a>`;
|
||||
footerSection.appendChild(githubLink);
|
||||
}
|
||||
|
||||
// Add "查看所有版本" link to download section
|
||||
const versionInfo = document.querySelector('.version-info');
|
||||
if (versionInfo) {
|
||||
const allVersionsLink = document.createElement('p');
|
||||
allVersionsLink.innerHTML = `<a href="https://github.com/${GITHUB_REPO}/releases" target="_blank" style="color: var(--primary-color); text-decoration: none;">查看所有版本 →</a>`;
|
||||
allVersionsLink.style.marginTop = '1rem';
|
||||
versionInfo.appendChild(allVersionsLink);
|
||||
}
|
||||
}
|
||||
650
website/styles.css
Normal file
@@ -0,0 +1,650 @@
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--secondary-color: #f8fafc;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--background: #ffffff;
|
||||
--surface: #f8fafc;
|
||||
--border: #e2e8f0;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--border-radius: 12px;
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background: var(--background);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-links a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--primary-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-links a:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
padding: 140px 0 100px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="0.5" fill="%23000" opacity="0.02"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: var(--gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 2rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Hero Image */
|
||||
.hero-image {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.app-preview {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.app-window {
|
||||
width: 400px;
|
||||
height: 300px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
overflow: hidden;
|
||||
transform: rotateY(-15deg) rotateX(10deg);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.app-window:hover {
|
||||
transform: rotateY(-10deg) rotateX(5deg);
|
||||
}
|
||||
|
||||
.window-header {
|
||||
height: 40px;
|
||||
background: #f1f5f9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.control.close { background: #ef4444; }
|
||||
.control.minimize { background: #f59e0b; }
|
||||
.control.maximize { background: #10b981; }
|
||||
|
||||
.window-content {
|
||||
padding: 2rem;
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.music-player-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.album-art {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--gradient);
|
||||
border-radius: 8px;
|
||||
margin: 0 auto;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin-top: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
width: 40%;
|
||||
border-radius: 2px;
|
||||
animation: progress 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% { width: 40%; }
|
||||
50% { width: 70%; }
|
||||
100% { width: 40%; }
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 100px 0;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
transition: var(--transition);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: var(--gradient);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Download Section */
|
||||
.download {
|
||||
padding: 100px 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.download-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
background: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.download-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: var(--surface);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.platform-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.download-card h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.download-card p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.version-info {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.version {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: var(--text-primary);
|
||||
color: white;
|
||||
padding: 60px 0 30px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
gap: 3rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer-section ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer-section ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-section ul li a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.footer-section ul li a:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid #334155;
|
||||
padding-top: 2rem;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-window {
|
||||
width: 300px;
|
||||
height: 225px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.download-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero {
|
||||
padding: 120px 0 80px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.features,
|
||||
.download {
|
||||
padding: 80px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card,
|
||||
.download-card {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.feature-card:hover .feature-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.download-card:hover .platform-icon {
|
||||
transform: scale(1.1);
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||