├── 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 | [![Netlify Status](https://api.netlify.com/api/v1/badges/a55b97dc-1ba1-4859-994a-db396b196aa7/deploy-status)](https://app.netlify.com/sites/cl-music/deploys) [![Vercel Status](https://img.shields.io/badge/vercel-success-{{color}})](https://vercel.com) 5 | 6 | 7 | ![1744334065693](image/README/1744334065693.png) 8 | 9 | ![1744334075944](image/README/1744334075944.png) 10 | 11 | ![1744334087360](image/README/1744334087360.png) 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 等平台。[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=thttps%3a%2f%2fgithub.com%2flovebai%2fcl-music&project-name=cl-music&repository-name=cl-music) [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](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 |
312 | 313 | 314 | 315 | setQuery(e.target.value)} 320 | /> 321 | 322 | 323 | 324 | setSource(e.target.value)} 327 | > 328 | {sources.map(src => ( 329 | 330 | ))} 331 | 332 | 333 | 334 | 335 | 336 | 337 | 音质: {quality}k 338 | 339 | 340 | {qualities.map(q => ( 341 | setQuality(q)}> 342 | {q}k 343 | 344 | ))} 345 | 346 | 347 | 348 | 349 | 350 | 353 | 354 | 355 |
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; --------------------------------------------------------------------------------