Compare commits

...

8 Commits

Author SHA1 Message Date
sqj
8077138609 fix workflow 2025-08-27 13:56:14 +08:00
sqj
9deb6a937b feat:应用更新检测 2025-08-27 13:29:49 +08:00
sqj
1259a6d70d feat:使用文档 2025-08-26 22:20:56 +08:00
sqj
14496d5d0b add(website):更新发布页 2025-08-26 20:01:22 +08:00
sqj
5f042e15eb add(website):更新发布页 2025-08-26 19:52:47 +08:00
sqj
6945c733e9 add(website):更新发布页 2025-08-26 19:32:58 +08:00
sqj
eea157b8d6 fix:gitignore 2025-08-26 18:44:11 +08:00
sqj
0c22eaa212 fix:gitignore 2025-08-26 18:42:28 +08:00
72 changed files with 9045 additions and 1478 deletions

View File

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

View File

@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
fail-fast: false # 如果一个任务失败,其他任务继续运行 fail-fast: false # 如果一个任务失败,其他任务继续运行
matrix: matrix:
os: [windows-latest, macos-latest] # 在WindowsmacOS上运行任务 os: [windows-latest, macos-latest, ubuntu-latest] # 在WindowsmacOS和Ubuntu上运行任务
steps: steps:
- name: Check out git repository - name: Check out git repository
@@ -25,39 +25,63 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 # 安装Node.js 22 这里node环境是能够运行代码的环境 node-version: 22 # 安装Node.js 22
- name: Install Dependencies - name: Install Dependencies
run: | run: |
npm i -g yarn npm i -g yarn
yarn install # 安装项目依赖 yarn install # 安装项目依赖
- name: Build Electron App for windows - name: Build Electron App for Windows
if: matrix.os == 'windows-latest' # 只在Windows上运行 if: matrix.os == 'windows-latest' # 只在Windows上运行
run: yarn run build:win # 构建Windows版应用 run: yarn run build:win # 构建Windows版应用
- name: Build Electron App for macos - name: Build Electron App for macOS
if: matrix.os == 'macos-latest' # 只在macOS上运行 if: matrix.os == 'macos-latest' # 只在macOS上运行
run: | run: yarn run build:mac
yarn run build:mac
- name: Build Electron App for Linux
if: matrix.os == 'ubuntu-latest' # 只在Linux上运行
run: yarn run build:linux
- name: Cleanup Artifacts for Windows - name: Cleanup Artifacts for Windows
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
run: | run: |
npx del-cli "dist/*" "!dist/*.exe" "!dist/*.zip" "!dist/*.yml" # 清理Windows构建产物,只保留特定文件 npx del-cli "dist/*" "!dist/*.exe" "!dist/*.zip" "!dist/*.yml" # 清理Windows构建产物,只保留特定文件
- name: Cleanup Artifacts for MacOS - name: Cleanup Artifacts for macOS
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
run: | run: |
npx del-cli "dist/*" "!dist/(*.dmg|*.zip|latest*.yml)" # 清理macOS构建产物,只保留特定文件 npx del-cli "dist/*" "!dist/(*.dmg|*.zip|*.pkg|latest*.yml)" # 清理macOS构建产物
- name: upload artifacts - name: Cleanup Artifacts for Linux
if: matrix.os == 'ubuntu-latest'
run: |
npx del-cli "dist/*" "!dist/(*.AppImage|*.deb|*.rpm|*.snap|*.tar.gz|latest*.yml)" # 清理Linux构建产物
- name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.os }} name: ${{ matrix.os }}
path: dist # 上传构建产物作为工作流artifact path: dist # 上传构建产物作为工作流artifact
- name: release create-release:
name: Create Release
runs-on: ubuntu-latest
needs: release
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: List artifacts for debugging
run: ls -R artifacts
- name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: 'dist/**' # 将dist目录下所有文件添加到release files: |
artifacts/windows-latest/dist/*
artifacts/macos-latest/dist/*
artifacts/ubuntu-latest/dist/*

4
.gitignore vendored
View File

@@ -12,3 +12,7 @@ build
/plugins/ /plugins/
temp temp
temp/log.txt temp/log.txt
/.kiro/
/.vscode/
/.codebuddy/
/.idea/

View File

@@ -1,22 +0,0 @@
# 语言设置
## 对话语言
- **主要语言**: 中文(简体中文)
- 与用户对话时请使用中文回复
- 代码注释和文档也应该使用中文
- 变量名和函数名仍使用英文(遵循编程规范)
## 代码规范
- 代码本身使用英文命名
- 注释使用中文说明
- 错误信息和用户界面文本使用中文
- README和文档文件使用中文编写
## 示例
```typescript
// 播放音乐的函数
function playMusic(songId: string): void {
// 开始播放指定的歌曲
console.log('正在播放音乐...')
}
```

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

39
.vscode/launch.json vendored
View File

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

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

View File

@@ -1,3 +1,3 @@
provider: generic provider: generic
url: https://example.com/auto-updates url: https://update.ceru.shiqianjiang.cn
updaterCacheDirName: ceru-music-updater updaterCacheDirName: ceru-music-updater

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

121
docs/auto-update.md Normal file
View 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

File diff suppressed because one or more lines are too long

51
docs/使用文档.md Normal file
View 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插件`**(目前生态欠缺)** 或现成的**落雪**插件导入使用![image-20250826221438856](assets/image-20250826221438856.png)
###### 导入完成点击使用![image-20250826221517247](assets/image-20250826221517247.png)

View File

@@ -61,6 +61,6 @@ appImage:
npmRebuild: false npmRebuild: false
publish: publish:
provider: generic provider: generic
url: https://example.com/auto-updates url: https://update.ceru.shiqianjiang.cn
electronDownload: electronDownload:
mirror: https://npmmirror.com/mirrors/electron/ mirror: https://npmmirror.com/mirrors/electron/

View File

@@ -1,6 +1,6 @@
{ {
"name": "ceru-music", "name": "ceru-music",
"version": "1.0.9", "version": "1.1.0",
"description": "一款简洁优雅的音乐播放器", "description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "sqj,wldss,star", "author": "sqj,wldss,star",
@@ -19,8 +19,8 @@
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "yarn run build && electron-builder --dir", "build:unpack": "yarn run build && electron-builder --dir",
"build:win": "yarn run build && electron-builder --win --x64 --config --publish never", "build:win": "yarn run build && electron-builder --win --x64 --config --publish never",
"build:mac": "yarn run build && electron-builder --mac --config --publish never", "build:mac": "yarn run build && electron-builder --mac --config --publish never",
"build:linux": "yarn run build && electron-builder --linux --publish never", "build:linux": "yarn run build && electron-builder --linux --config --publish never",
"build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config", "build:deps": "electron-builder install-app-deps && yarn run build && electron-builder --win --x64 --config",
"buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten" "buildico": "electron-icon-builder --input=./resources/logo.png --output=resources --flatten"
}, },

262
src/main/autoUpdate.ts Normal file
View 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)));
}
}

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

View File

@@ -192,6 +192,7 @@ ipcMain.handle('service-music-request', async (_, api, args) => {
aiEvents(mainWindow) aiEvents(mainWindow)
import './events/musicCache' import './events/musicCache'
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
@@ -276,6 +277,14 @@ app.whenReady().then(async () => {
createWindow() createWindow()
createTray() createTray()
// 注册自动更新事件
registerAutoUpdateEvents()
// 初始化自动更新器
if (mainWindow) {
initAutoUpdateForWindow(mainWindow)
}
app.on('activate', function () { app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the // On macOS it's common to re-create a window in the app when the

View File

@@ -197,7 +197,9 @@ class CeruMusicPluginHost {
} }
if (callback) { if (callback) {
makeRequest().catch(() => {}) // 错误已在makeRequest中处理 makeRequest().catch((error) => {
console.error(`[CeruMusic] Unhandled request error in callback mode: ${error.message}`)
}) // 确保错误被正确处理
return undefined return undefined
} else { } else {
return makeRequest() return makeRequest()
@@ -407,7 +409,9 @@ class CeruMusicPluginHost {
} }
if (callback) { if (callback) {
makeRequest().catch(() => {}) // 错误已在makeRequest中处理 makeRequest().catch((error) => {
console.error(`[CeruMusic] Unhandled request error in callback mode: ${error.message}`)
}) // 确保错误被正确处理
return undefined return undefined
} else { } else {
return makeRequest() return makeRequest()

View File

@@ -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'
})
})
}
}

View File

@@ -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

View File

@@ -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'
}
})
}
}

View File

@@ -2,6 +2,7 @@ import { ElectronAPI } from '@electron-toolkit/preload'
import { MainApi, MethodParams } from '../main/services/musicSdk/index' import { MainApi, MethodParams } from '../main/services/musicSdk/index'
// 自定义 API 接口 // 自定义 API 接口
interface CustomAPI { interface CustomAPI {
autoUpdater: any
minimize: () => void minimize: () => void
maximize: () => void maximize: () => void
close: () => void close: () => void

View File

@@ -67,7 +67,48 @@ const api = {
getSize: () => ipcRenderer.invoke('music-cache:get-size') 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 // Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -38,6 +38,9 @@ declare module 'vue' {
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup'] TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
TSlider: typeof import('tdesign-vue-next')['Slider'] TSlider: typeof import('tdesign-vue-next')['Slider']
TTooltip: typeof import('tdesign-vue-next')['Tooltip'] 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'] Versions: typeof import('./src/components/Versions.vue')['default']
} }
} }

View File

@@ -3,16 +3,25 @@ import { onMounted } from 'vue'
import GlobalAudio from './components/Play/GlobalAudio.vue' import GlobalAudio from './components/Play/GlobalAudio.vue'
import FloatBall from './components/AI/FloatBall.vue' import FloatBall from './components/AI/FloatBall.vue'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail' import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useAutoUpdate } from './composables/useAutoUpdate'
const userInfo = LocalUserDetailStore() const userInfo = LocalUserDetailStore()
const { checkForUpdates } = useAutoUpdate()
import './assets/main.css' import './assets/main.css'
import './assets/theme/blue.css' import './assets/theme/blue.css'
import './assets/theme/pink.css' import './assets/theme/pink.css'
import './assets/theme/orange.css' import './assets/theme/orange.css'
import './assets/theme/cyan.css' import './assets/theme/cyan.css'
onMounted(() => { onMounted(() => {
userInfo.init() userInfo.init()
// 设置测试音频URL
loadSavedTheme() loadSavedTheme()
// 应用启动后延迟3秒检查更新避免影响启动速度
setTimeout(() => {
checkForUpdates()
}, 3000)
}) })
// 基于现有主题文件的配置 // 基于现有主题文件的配置
@@ -51,4 +60,5 @@ const applyTheme = (themeName) => {
<router-view /> <router-view />
<GlobalAudio /> <GlobalAudio />
<FloatBall /> <FloatBall />
<UpdateProgress />
</template> </template>

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

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

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

View 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 // 导出下载状态供组件使用
};
}

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

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

View File

@@ -11,6 +11,15 @@ declare global {
getSize: () => Promise<number> 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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

1587
website/design.html Normal file

File diff suppressed because one or more lines are too long

258
website/index.html Normal file
View 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>&copy; 2025 Ceru Music. All rights reserved.</p>
</div>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>

1567
website/pluginDev.html Normal file

File diff suppressed because one or more lines are too long

BIN
website/resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
website/resources/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
website/resources/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View 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
View 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
View 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;
}