├── public
├── favicon.ico
├── logo192.png
├── logo512.png
├── robots.txt
├── default_cover.png
├── manifest.json
└── index.html
├── image
└── README
│ ├── 1744334065693.png
│ ├── 1744334075944.png
│ └── 1744334087360.png
├── src
├── setupTests.js
├── App.test.js
├── setupProxy.js
├── reportWebVitals.js
├── index.js
├── index.css
└── App.js
├── conf
└── nginx.conf
├── .gitignore
├── Dockerfile
├── package.json
├── .github
└── workflows
│ └── main.yml
├── README.md
└── worker.js
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lovebai/cl-music/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lovebai/cl-music/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lovebai/cl-music/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/default_cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lovebai/cl-music/HEAD/public/default_cover.png
--------------------------------------------------------------------------------
/image/README/1744334065693.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lovebai/cl-music/HEAD/image/README/1744334065693.png
--------------------------------------------------------------------------------
/image/README/1744334075944.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lovebai/cl-music/HEAD/image/README/1744334075944.png
--------------------------------------------------------------------------------
/image/README/1744334087360.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lovebai/cl-music/HEAD/image/README/1744334087360.png
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/conf/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | listen [::]:80;
4 | server_name localhost;
5 |
6 | location / {
7 | root /usr/share/nginx/html;
8 | try_files $uri $uri/ /index.html;
9 | }
10 |
11 | error_page 500 502 503 504 /50x.html;
12 | location = /50x.html {
13 | root /usr/share/nginx/html;
14 | }
15 | }
--------------------------------------------------------------------------------
/src/setupProxy.js:
--------------------------------------------------------------------------------
1 | const { createProxyMiddleware } = require('http-proxy-middleware');
2 |
3 | module.exports = function(app) {
4 | app.use(
5 | '/api',
6 | createProxyMiddleware({
7 | target: 'https://music-api.gdstudio.xyz/api.php',
8 | changeOrigin: true,
9 | pathRewrite: {
10 | '^/api': '', // 把 "/api" 从路径中移除
11 | },
12 | })
13 | );
14 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-slim AS base
2 |
3 | ENV NPM_HOME="/npm"
4 | ENV PATH="$NPM_HOME:$PATH"
5 | ENV REACT_APP_API_BASE="https://music-api.ucds.me/api"
6 | RUN corepack enable
7 | COPY . /app
8 | WORKDIR /app
9 |
10 | FROM base AS build
11 | RUN --mount=type=cache,id=npm,target=/npm/store npm i --frozen-lockfile
12 | RUN npm run build
13 |
14 | # Production stage
15 | FROM nginx:alpine-slim AS production-stage
16 | COPY ./conf/nginx.conf /etc/nginx/conf.d/default.conf
17 | COPY --from=build /app/build /usr/share/nginx/html
18 | EXPOSE 80
19 | CMD ["nginx", "-g", "daemon off;"]
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 | import { ToastContainer } from 'react-toastify';
7 | import 'react-toastify/dist/ReactToastify.css';
8 |
9 | const root = ReactDOM.createRoot(document.getElementById('root'));
10 | root.render(
11 |
12 |
13 |
25 |
26 | );
27 |
28 | // If you want to start measuring performance in your app, pass a function
29 | // to log results (for example: reportWebVitals(console.log))
30 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
31 | reportWebVitals();
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cl-music",
3 | "version": "0.1.1",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/dom": "^10.4.0",
7 | "@testing-library/jest-dom": "^6.6.3",
8 | "@testing-library/react": "^16.3.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "axios": "^1.8.4",
11 | "react": "^19.1.0",
12 | "react-bootstrap": "^2.10.9",
13 | "react-dom": "^19.1.0",
14 | "react-icons": "^5.5.0",
15 | "react-player": "^2.16.0",
16 | "react-scripts": "5.0.1",
17 | "react-toastify": "^11.0.5",
18 | "web-vitals": "^2.1.4"
19 | },
20 | "scripts": {
21 | "start": "react-scripts start",
22 | "build": "react-scripts build",
23 | "test": "react-scripts test",
24 | "eject": "react-scripts eject"
25 | },
26 | "eslintConfig": {
27 | "extends": [
28 | "react-app",
29 | "react-app/jest"
30 | ]
31 | },
32 | "browserslist": {
33 | "production": [
34 | ">0.2%",
35 | "not dead",
36 | "not op_mini all"
37 | ],
38 | "development": [
39 | "last 1 chrome version",
40 | "last 1 firefox version",
41 | "last 1 safari version"
42 | ]
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | tags:
4 | - 'v*'
5 | env:
6 | IMAGE_NAME: cl-music
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - name: Set up QEMU for multi-architecture builds
15 | uses: docker/setup-qemu-action@v2
16 |
17 | - name: Set up Docker Buildx
18 | uses: docker/setup-buildx-action@v2
19 |
20 | - name: Log in to registry
21 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
22 |
23 | - name: Build and Push image
24 | run: |
25 | IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
26 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
27 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
28 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
29 | [ "$VERSION" == "main" ] && VERSION=latest
30 | echo IMAGE_ID=$IMAGE_ID
31 | echo VERSION=$VERSION
32 | docker buildx build --push \
33 | --tag $IMAGE_ID:$VERSION \
34 | --platform linux/amd64,linux/arm/v7,linux/arm64 .
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | 全平台音乐搜索
28 |
29 |
30 |
31 |
32 |
33 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CL-Music 全平台音乐搜索
2 |
3 | 一款基于React开发的在线音乐搜索和播放应用。
4 | [](https://app.netlify.com/sites/cl-music/deploys) [](https://vercel.com)
5 |
6 |
7 | 
8 |
9 | 
10 |
11 | 
12 |
13 | ## 功能特点
14 |
15 | - 🎵 支持多平台音乐搜索
16 |
17 | - 网易云音乐
18 | - QQ音乐
19 | - TIDAL
20 | - Spotify
21 | - YouTube Music
22 | - Qobuz
23 | - JOOX
24 | - Deezer
25 | - 咪咕音乐
26 | - 酷狗音乐
27 | - 酷我音乐
28 | - 喜马拉雅
29 | - 🎨 主要功能
30 |
31 | - 音乐搜索
32 | - 在线播放
33 | - 音乐下载
34 | - 音质选择(最高支持999k)
35 | - 歌词显示(支持双语歌词)
36 | - 专辑封面显示
37 |
38 | ## 项目部署
39 | 生产环境需配置一个环境编辑,`REACT_APP_API_BASE` 后端API地址,由于跨域问题不可以直接使用,一般填写反代 `https://music-api.gdstudio.xyz/api.php` 后地址,可使用nginx、caddy 等web服务反代,也可是cloudflare worker反代,我这边提供了worker反向代理的代码,见 [worker.js](worker.js)
40 | 可快速部署到 netlify 、vercel、Cloudflare Pages 等平台。[](https://vercel.com/new/clone?repository-url=thttps%3a%2f%2fgithub.com%2flovebai%2fcl-music&project-name=cl-music&repository-name=cl-music) [](https://app.netlify.com/start/deploy?repository=https://github.com/lovebai/cl-music)
41 |
42 | **Docker部署**
43 | - docker
44 |
45 | ```shell
46 | docker run -d -p 80:80 --restart always ghcr.io/lovebai/cl-music:0.1.1
47 | ```
48 | - docker-compose
49 |
50 | ```yaml
51 | services:
52 | lovebai:
53 | image: ghcr.io/lovebai/cl-music:0.1.1
54 | restart: always
55 | ports:
56 | - '80:80'
57 | ```
58 |
59 | ## 技术栈
60 |
61 | - React
62 | - React Bootstrap
63 | - Axios
64 | - React Player
65 | - React Icons
66 | - React Toastify
67 |
68 | ## 本地开发
69 |
70 | 1. 克隆项目
71 |
72 | ```bash
73 | git clone https://github.com/lovebai/cl-music.git
74 | ```
75 |
76 | 2. 安装依赖
77 |
78 | ```bash
79 | cd cl-music
80 | npm install
81 | ```
82 |
83 | 3. 启动开发服务器
84 |
85 | ```bash
86 | npm start
87 | ```
88 |
89 | 4. 打开浏览器访问 `http://localhost:3000`
90 |
91 | ## 构建部署
92 |
93 | 构建生产版本:
94 |
95 | ```bash
96 | npm run build
97 | ```
98 |
99 | ## 项目结构
100 |
101 | ```
102 | cl-music/
103 | ├── public/ # 静态文件
104 | ├── src/ # 源代码
105 | │ ├── App.js # 主应用组件
106 | │ ├── index.js # 入口文件
107 | │ └── setupProxy.js # 开发代理配置
108 | └── package.json # 项目配置文件
109 | ```
110 |
111 | ## API接口
112 |
113 | 项目使用的是第三方音乐API接口,通过setupProxy.js配置代理访问。
114 |
115 | 后端API接口:`https://music-api.gdstudio.xyz/api.php`
116 |
117 | ## 许可证
118 |
119 | MIT License
120 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
15 | .fixed-bottom {
16 | box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
17 | z-index: 1000;
18 | }
19 |
20 | .react-player audio {
21 | width: 100%;
22 | }
23 |
24 | .card {
25 | transition: transform 0.2s;
26 | }
27 |
28 | .card:hover {
29 | transform: translateY(-2px);
30 | }
31 |
32 |
33 | .lyric-container {
34 | overflow: hidden;
35 | position: relative;
36 | background: rgba(255,255,255,0.9);
37 | border-radius: 8px;
38 | box-shadow: 0 2px 8px rgba(0,0,0,0.1);
39 |
40 | &.collapsed {
41 | .full-lyrics {
42 | display: none;
43 | }
44 | .current-lyric {
45 | padding: 10px;
46 | text-align: center;
47 | }
48 | }
49 |
50 | &.expanded {
51 | .current-lyric {
52 | display: none;
53 | }
54 | }
55 | }
56 |
57 | .lyric-wrapper {
58 | padding: 10px;
59 | }
60 |
61 | .current-lyric {
62 | font-weight: 500;
63 | font-size: 1.1em;
64 | line-height: 1.4;
65 |
66 | .translated-lyric {
67 | font-size: 0.9em;
68 | color: #666;
69 | margin-top: 4px;
70 | }
71 | }
72 |
73 | .full-lyrics {
74 | max-height: 340px;
75 | overflow-y: auto;
76 |
77 | .lyric-line {
78 | padding: 8px 0;
79 | transition: all 0.2s ease;
80 |
81 | &.active {
82 | color: #0d6efd;
83 | font-weight: bold;
84 | }
85 | }
86 | }
87 |
88 | .full-lyrics {
89 | max-height: 340px;
90 | overflow-y: auto;
91 | padding: 10px;
92 |
93 |
94 | opacity: 1;
95 | visibility: visible;
96 | transition: opacity 0.3s ease;
97 | }
98 |
99 | .lyric-container.collapsed .full-lyrics {
100 | opacity: 0;
101 | visibility: hidden;
102 | max-height: 0;
103 | padding: 0;
104 | }
105 |
106 | @media (max-width: 768px) {
107 | .lyric-container {
108 | margin-top: 10px;
109 |
110 | &.collapsed {
111 | max-height: 50px !important;
112 | }
113 |
114 | &.expanded {
115 | max-height: 60vh !important;
116 | }
117 | }
118 |
119 | .current-lyric {
120 | font-size: 1em;
121 | }
122 | }
123 |
124 | .github-corner:hover svg {
125 | transform: rotate(-45deg) scale(1.1);
126 | color: #24292e !important;
127 | }
128 |
129 |
130 | @media (max-width: 768px) {
131 | .github-corner svg {
132 | top: 10px;
133 | right: 10px;
134 | size: 28px;
135 | }
136 | }
137 |
138 |
139 | .main-content {
140 | min-height: calc(100vh - 120px);
141 | }
142 |
143 |
144 | @media (max-width: 768px) {
145 | .main-content {
146 | min-height: calc(100vh - 80px);
147 | padding-bottom: 80px !important;
148 | }
149 |
150 | .fixed-bottom {
151 | height: auto !important;
152 | padding: 10px;
153 | }
154 | }
155 |
156 | .search-result-card {
157 | margin-bottom: 20px;
158 | }
--------------------------------------------------------------------------------
/worker.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Cloudflare Worker for Reverse Proxying music-api.gdstudio.xyz
3 | * with CORS headers and path rewriting (/api -> /api.php).
4 | */
5 |
6 | // 目标 API 的基础 URL
7 | const TARGET_API_BASE = 'https://music-api.gdstudio.xyz';
8 | // 需要重写的源路径
9 | const SOURCE_PATH = '/api';
10 | // 重写后的目标路径
11 | const TARGET_PATH = '/api.php';
12 |
13 | export default {
14 | async fetch(request, env, ctx) {
15 | // --- 1. 构建目标 URL (包含路径重写) ---
16 | const url = new URL(request.url);
17 | let targetPath = url.pathname; // 默认使用原始路径
18 |
19 | // 检查路径是否需要重写
20 | if (url.pathname === SOURCE_PATH) {
21 | targetPath = TARGET_PATH; // 将 /api 替换为 /api.php
22 | console.log(`Path rewritten: ${url.pathname} -> ${targetPath}`); // Optional: logging for debugging
23 | }
24 | // --- 注意: 如果你只想允许 /api 路径,可以在这里添加 else 块 ---
25 | else {
26 | // 对于非 /api 的路径,返回 404 Not Found
27 | return new Response('Not Found. Only /api is allowed.', { status: 404 });
28 | }
29 | // -------------------------------------------------------------
30 |
31 | // 将目标 API 的域名/协议与(可能重写后的)路径和查询参数组合起来
32 | const targetUrl = new URL(targetPath + url.search, TARGET_API_BASE);
33 | console.log(`Forwarding request to: ${targetUrl.toString()}`); // Optional: logging
34 |
35 | // --- 2. 处理 CORS 预检请求 (OPTIONS) ---
36 | if (request.method === 'OPTIONS') {
37 | // 特别注意:如果只允许 /api,OPTIONS 请求也应该只在该路径下成功
38 | // 但通常 OPTIONS 是针对将要发生的实际请求路径,所以这里的处理保持不变即可
39 | return handleCorsPreflight(request);
40 | }
41 |
42 | // --- 3. 准备转发给目标 API 的请求 ---
43 | const requestHeaders = new Headers(request.headers);
44 | requestHeaders.set('Host', new URL(TARGET_API_BASE).host);
45 | // 移除 Cloudflare 特定的头信息
46 | requestHeaders.delete('cf-connecting-ip');
47 | requestHeaders.delete('cf-ipcountry');
48 | requestHeaders.delete('cf-ray');
49 | requestHeaders.delete('cf-visitor');
50 | requestHeaders.delete('x-forwarded-proto');
51 | requestHeaders.delete('x-real-ip');
52 |
53 | // --- 4. 向目标 API 发送请求 ---
54 | let response;
55 | try {
56 | response = await fetch(targetUrl.toString(), {
57 | method: request.method,
58 | headers: requestHeaders,
59 | body: request.body,
60 | redirect: 'follow'
61 | });
62 | } catch (error) {
63 | console.error('Error fetching target API:', error);
64 | return new Response('Proxy failed to fetch target API', { status: 502 });
65 | }
66 |
67 | // --- 5. 准备返回给客户端的响应 (添加 CORS 头) ---
68 | const responseHeaders = new Headers(response.headers);
69 | responseHeaders.set('Access-Control-Allow-Origin', '*');
70 | responseHeaders.append('Vary', 'Origin');
71 | // 移除可能暴露信息的头
72 | responseHeaders.delete('X-Powered-By');
73 | responseHeaders.delete('server');
74 |
75 | // --- 6. 返回最终响应 ---
76 | return new Response(response.body, {
77 | status: response.status,
78 | statusText: response.statusText,
79 | headers: responseHeaders
80 | });
81 | }
82 | };
83 |
84 | /**
85 | * 处理 CORS 预检请求 (OPTIONS) 的辅助函数
86 | * @param {Request} request 传入的 OPTIONS 请求
87 | * @returns {Response} 带有 CORS 允许头的响应
88 | */
89 | function handleCorsPreflight(request) {
90 | const headers = new Headers();
91 | const requestHeaders = request.headers;
92 |
93 | headers.set('Access-Control-Allow-Origin', '*'); // 或者指定你的域名
94 | headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, HEAD, OPTIONS');
95 |
96 | const requestedHeaders = requestHeaders.get('Access-Control-Request-Headers');
97 | if (requestedHeaders) {
98 | headers.set('Access-Control-Allow-Headers', requestedHeaders);
99 | } else {
100 | headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, X-Requested-With');
101 | }
102 |
103 | // headers.set('Access-Control-Allow-Credentials', 'true'); // 如果需要凭证且源非'*'
104 | headers.set('Access-Control-Max-Age', '86400'); // 24 小时
105 |
106 | return new Response(null, {
107 | status: 204, // No Content
108 | headers: headers
109 | });
110 | }
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef,useCallback } from 'react';
2 | import { Container, Row, Col, Form, Button, Card, Spinner, Dropdown } from 'react-bootstrap';
3 | import axios from 'axios';
4 | import ReactPlayer from 'react-player';
5 | import { FaPlay, FaPause, FaDownload, FaMusic ,FaChevronDown,FaChevronUp,FaGithub } from 'react-icons/fa';
6 | import { toast } from 'react-toastify';
7 |
8 |
9 | const API_BASE = process.env.REACT_APP_API_BASE || '/api';
10 |
11 |
12 | const Github = ()=>{
13 | return (
14 |
21 |
32 |
33 | )
34 | }
35 |
36 | const MusicSearch = () => {
37 | const [query, setQuery] = useState('');
38 | const [results, setResults] = useState([]);
39 | const [source, setSource] = useState('netease');
40 | const [quality, setQuality] = useState('999');
41 | const [loading, setLoading] = useState(false);
42 | const [currentTrack, setCurrentTrack] = useState(null);
43 | const [playerUrl, setPlayerUrl] = useState('');
44 | const [isPlaying, setIsPlaying] = useState(false);
45 | const playerRef = useRef(null);
46 | const [coverCache, setCoverCache] = useState({});
47 | const [lyricData, setLyricData] = useState({
48 | rawLyric: '',
49 | tLyric: '',
50 | parsedLyric: []
51 | });
52 | const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
53 | const [lyricExpanded, setLyricExpanded] = useState(false);
54 | const lyricsContainerRef = useRef(null);
55 |
56 |
57 | const sources = [
58 | 'netease','joox','tencent', 'tidal', 'spotify',
59 | 'ytmusic', 'qobuz', 'deezer',
60 | 'migu', 'kugou', 'kuwo', 'ximalaya'
61 | ];
62 |
63 | const qualities = ['128', '192', '320', '740', '999'];
64 |
65 | const parseLyric = (text) => {
66 | const lines = text.split('\n');
67 | const pattern = /\[(\d+):(\d+\.\d+)\]/;
68 |
69 | return lines.map(line => {
70 | const match = line.match(pattern);
71 | if (match) {
72 | const minutes = parseFloat(match[1]);
73 | const seconds = parseFloat(match[2]);
74 | return {
75 | time: minutes * 60 + seconds,
76 | text: line.replace(match[0], '').trim()
77 | };
78 | }
79 | return null;
80 | }).filter(Boolean);
81 | };
82 |
83 | const handleSearch = async (e) => {
84 | e.preventDefault();
85 | if (!query) return;
86 |
87 | setLoading(true);
88 | try {
89 | const response = await axios.get(`${API_BASE}`, {
90 | params: {
91 | types: 'search',
92 | source: source,
93 | name: query,
94 | count: 20,
95 | pages: 1
96 | }
97 | });
98 | // setResults(response.data || []);
99 | // 获取结果后处理封面
100 | const resultsWithCover = await Promise.all(
101 | response.data.map(async track => ({
102 | ...track,
103 | picUrl: await fetchCover(track.source, track.pic_id)
104 | }))
105 | );
106 |
107 | setResults(resultsWithCover);
108 | } catch (error) {
109 | console.error('Search error:', error);
110 | toast.error('搜索失败,请稍后重试', {
111 | icon: '❌',
112 | className: 'custom-toast error-toast'
113 | });
114 | }
115 | setLoading(false);
116 | };
117 |
118 |
119 | const fetchCover = async (source, picId, size = 300) => {
120 | const cacheKey = `${source}-${picId}-${size}`;
121 |
122 | // 检查缓存
123 | if (coverCache[cacheKey]) return coverCache[cacheKey];
124 |
125 | try {
126 | const response = await axios.get(`${API_BASE}`, {
127 | params: {
128 | types: 'pic',
129 | source: source,
130 | id: picId,
131 | size: size
132 | }
133 | });
134 |
135 | const url = response.data.url.replace(/\\/g, '');
136 |
137 | // 更新缓存
138 | setCoverCache(prev => ({
139 | ...prev,
140 | [cacheKey]: url
141 | }));
142 |
143 | return url;
144 | } catch (error) {
145 | console.error('封面获取失败:', error);
146 | return 'default_cover.jpg';
147 | }
148 | };
149 |
150 | const handlePlay = async (track) => {
151 | if (currentTrack?.id === track.id) {
152 | setIsPlaying(!isPlaying);
153 | return;
154 | }
155 |
156 | try {
157 | const [urlResponse, lyricResponse] = await Promise.all([
158 | axios.get(API_BASE, {
159 | params: { types: 'url', source: track.source, id: track.id, br: quality }
160 | }),
161 | axios.get(API_BASE, {
162 | params: { types: 'lyric', source: track.source, id: track.lyric_id }
163 | })
164 | ]);
165 | console.log(urlResponse.data.size);
166 |
167 |
168 |
169 | const rawLyric = lyricResponse.data.lyric || '';
170 | const tLyric = lyricResponse.data.tlyric || '';
171 |
172 | setLyricData({
173 | rawLyric,
174 | tLyric,
175 | parsedLyric: parseLyric(rawLyric)
176 | });
177 |
178 | setPlayerUrl('');
179 | setIsPlaying(false);
180 |
181 | const response = await axios.get(`${API_BASE}`, {
182 | params: {
183 | types: 'url',
184 | source: track.source,
185 | id: track.id,
186 | br: quality
187 | }
188 | });
189 |
190 | const url = response.data?.url?.replace(/\\/g, '');
191 | if (!url) throw new Error('无效的音频链接');
192 |
193 | // 确保状态更新顺序
194 | setCurrentTrack(track);
195 | setPlayerUrl(url);
196 | setIsPlaying(true);
197 |
198 | } catch (error) {
199 | console.error('Play error:', error);
200 | setIsPlaying(false);
201 | setPlayerUrl('');
202 | toast.warning('当前音频无效不可用', {
203 | icon: '⚠️',
204 | className: 'custom-toast warning-toast'
205 | });
206 | }
207 | };
208 |
209 | const useThrottle = (callback, delay) => {
210 | const lastCall = useRef(0);
211 |
212 | return useCallback((...args) => {
213 | const now = new Date().getTime();
214 | if (now - lastCall.current >= delay) {
215 | lastCall.current = now;
216 | callback(...args);
217 | }
218 | }, [callback, delay]);
219 | };
220 |
221 | const handleProgress = useThrottle((state) => {
222 | const currentTime = state.playedSeconds;
223 | const lyrics = lyricData.parsedLyric;
224 |
225 | let newIndex = -1;
226 | for (let i = lyrics.length - 1; i >= 0; i--) {
227 | if (currentTime >= lyrics[i].time) {
228 | newIndex = i;
229 | break;
230 | }
231 | }
232 |
233 | if (newIndex !== currentLyricIndex) {
234 | setCurrentLyricIndex(newIndex);
235 | }
236 | }, 500); // 节流500m
237 |
238 | const handleDownload = async (track) => {
239 | try {
240 | const response = await axios.get(`${API_BASE}`, {
241 | params: {
242 | types: 'url',
243 | source: track.source,
244 | id: track.id,
245 | br: quality
246 | }
247 | });
248 |
249 | const downloadUrl = response.data.url.replace(/\\/g, '');
250 | const link = document.createElement('a');
251 | link.href = downloadUrl;
252 | // link.download = `${track.name} - ${track.artist}.mp3`; //下载为mp3格式
253 | const extension = getFileExtension(downloadUrl);
254 | link.download = `${track.name} - ${track.artist}.${extension}`;
255 | document.body.appendChild(link);
256 | link.click();
257 | document.body.removeChild(link);
258 | } catch (error) {
259 | console.error('Download error:', error);
260 | toast.error('下载失败,请稍后重试', {
261 | icon: '❌',
262 | className: 'custom-toast error-toast'
263 | });
264 | }
265 | };
266 |
267 | // 处理文件名后缀
268 | const getFileExtension = (url) => {
269 | try {
270 | // 处理可能包含反斜杠的URL
271 | const cleanUrl = url.replace(/\\/g, '');
272 | const fileName = new URL(cleanUrl).pathname
273 | .split('/')
274 | .pop()
275 | .split(/[#?]/)[0]; // 移除可能的哈希和查询参数
276 |
277 | // 使用正则表达式提取后缀
278 | const extensionMatch = fileName.match(/\.([a-z0-9]+)$/i);
279 | return extensionMatch ? extensionMatch[1] : 'audio';
280 | } catch {
281 | return 'audio'; // 默认后缀
282 | }
283 | };
284 |
285 |
286 | // 添加滚动效果
287 | useEffect(() => {
288 | if (lyricExpanded && currentLyricIndex >= 0 && lyricsContainerRef.current) {
289 | const activeLines = lyricsContainerRef.current.getElementsByClassName('active');
290 | if (activeLines.length > 0) {
291 | activeLines[0].scrollIntoView({
292 | behavior: 'smooth',
293 | block: 'center',
294 | inline: 'nearest'
295 | });
296 | }
297 | }
298 | }, [currentLyricIndex, lyricExpanded]);
299 |
300 |
301 | return (
302 |
308 |
309 | 全平台音乐搜索
310 |
311 |
356 |
357 | {loading && (
358 |
359 |
360 |
361 | )}
362 |
363 |
364 | {results.map((track) => (
365 |
366 |
367 |
368 |
369 |

{
380 | e.target.src = 'default_cover.png';
381 | }}
382 | />
383 |
384 |
{track.name}
385 | {track.artist} - {track.album}
386 |
387 |
388 |
389 |
390 |
405 |
412 |
413 |
414 |
415 |
416 | ))}
417 |
418 |
419 |
426 |
427 |
428 |
429 | {currentTrack && (
430 |
431 |

437 |
438 |
{currentTrack.name}
439 | {currentTrack.artist}
440 |
441 |
442 | )}
443 |
450 |
451 |
452 |
453 |
454 |
461 |
462 |
463 | {lyricData.parsedLyric[currentLyricIndex] && (
464 |
465 | {lyricData.parsedLyric[currentLyricIndex].text}
466 | {lyricData.tLyric && (
467 |
468 | {parseLyric(lyricData.tLyric)[currentLyricIndex]?.text}
469 |
470 | )}
471 |
472 |
473 | )}
474 |
475 | {lyricExpanded && (
476 |
{
480 | // 记录用户滚动行为
481 | sessionStorage.setItem('userScrolled', true);
482 | }}
483 | >
484 | {lyricData.parsedLyric.map((line, index) => (
485 |
490 |
{line.text}
491 | {lyricData.tLyric && (
492 |
493 | {parseLyric(lyricData.tLyric)[index]?.text}
494 |
495 | )}
496 |
497 | ))}
498 | {lyricData.parsedLyric.length === 0 && (
499 |
暂无歌词
500 | )}
501 |
502 | )}
503 | {lyricData.parsedLyric.length === 0 && (
504 |
暂无歌词
505 | )}
506 |
507 |
508 |
509 |
510 | console.log('播放器就绪')}
516 | onError={(e) => {
517 | console.error('播放错误:', e);
518 | setIsPlaying(false);
519 | }}
520 | onEnded={() => {
521 | setIsPlaying(false);
522 | // 保留当前曲目信息但停止播放
523 | }}
524 | config={{ file: { forceAudio: true } }}
525 | height={0}
526 | style={{ display: playerUrl ? 'block' : 'none' }} // 隐藏未初始化的播放器
527 | />
528 |
529 |
530 |
531 |
544 |
545 |
546 |
547 |
548 | );
549 | };
550 |
551 | export default MusicSearch;
--------------------------------------------------------------------------------