├── .env ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── README.md ├── docker-compose.yml ├── jsconfig.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma ├── schema.prisma └── seed.ts ├── public ├── next.svg └── vercel.svg ├── src ├── api │ └── config.js ├── app │ ├── HomeLayout.style.js │ ├── ReduxProvider.jsx │ ├── favicon.ico │ ├── favorite │ │ ├── layout.jsx │ │ └── page.jsx │ ├── globals.css │ ├── home │ │ ├── components │ │ │ ├── header.jsx │ │ │ ├── miniPlayer │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── normalPlayer │ │ │ │ └── index.jsx │ │ │ ├── playList │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ │ ├── player │ │ │ │ └── index.jsx │ │ │ └── recommendList │ │ │ │ ├── index.jsx │ │ │ │ └── style.js │ │ ├── layout.jsx │ │ ├── page.jsx │ │ ├── rank │ │ │ ├── [id] │ │ │ │ ├── page.jsx │ │ │ │ └── style.js │ │ │ ├── components │ │ │ │ └── main.jsx │ │ │ ├── page.jsx │ │ │ └── style.js │ │ ├── recommend │ │ │ └── main.jsx │ │ ├── singer │ │ │ ├── [id] │ │ │ │ ├── page.jsx │ │ │ │ └── style.js │ │ │ ├── components │ │ │ │ ├── horizen-item.jsx │ │ │ │ ├── main.jsx │ │ │ │ ├── songList.jsx │ │ │ │ └── top-header.jsx │ │ │ ├── page.jsx │ │ │ └── style.js │ │ └── style.js │ ├── layout.jsx │ ├── mine │ │ └── page.jsx │ ├── page.jsx │ ├── providers.jsx │ └── registry.jsx ├── assets │ ├── global-style.js │ └── music.png ├── components │ ├── confirm │ │ └── index.jsx │ ├── header │ │ └── index.jsx │ ├── loading-v2 │ │ └── index.jsx │ ├── loading │ │ └── index.jsx │ ├── progress-circle │ │ └── index.jsx │ ├── scroll │ │ └── index.jsx │ ├── slider │ │ ├── index.jsx │ │ └── style.js │ ├── svg-icon │ │ └── index.jsx │ └── toast │ │ └── index.jsx ├── hooks │ ├── useGetAlbums.jsx │ ├── useGetSingerSongs.jsx │ └── useSingerMutation.jsx ├── lib │ └── db.ts ├── store │ ├── index.js │ └── slices │ │ ├── singers.js │ │ └── songs.js └── utils │ └── index.js ├── tailwind.config.js └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="postgresql://username:password@localhost:5432/mydb?schema=public" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | /postgres 9 | /prisma/migrations 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@nextui-org/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## 项目简介 3 | 4 | **Nextjs** + **App Router**路由模式 + 客户端**SWR**仿**网易云音乐**的WebApp项目实战 5 | 6 | 7 | ## 本地运行 8 | 9 | run the development server: 10 | 11 | ```bash 12 | npm install 13 | # or 14 | pnpm install 15 | 16 | 17 | npm run dev 18 | # or 19 | pnpm dev 20 | ``` 21 | 22 | 23 | ## 歌曲源数据 24 | 25 | ### 精力旺盛的法子 26 | 27 | path:/src/api/config.js 28 | 29 | ```js 30 | export const API_BASE_URL = "http://localhost:3100"; 31 | ``` 32 | 33 | 34 | 小伙伴本地学习的时候需要本地运行一个拿歌曲数据的服务 35 | 36 | 进入[react-cloud-music](https://github.com/sanyuan0704/react-cloud-music) 项目 ,NeteaseCloudMusicApi目录下安装包,启动服务 37 | 38 | 39 | 40 | 然后**API_BASE_URL**放你启动的**服务地址**就行了 41 | 42 | 43 | ### 懒人必备的法子 44 | 45 | 自己本地mock点假数据,搞那么费事干嘛呢~~~ 46 | 47 | 48 | ## Prisma 49 | 50 | 项目集成了**Prisma** + **Postgresql**可以从数据库中获取数据展示在页面上 51 | 52 | 不过需要小伙伴本地搭建个数据库 53 | 54 | 前端部分: 55 | 56 | ![image](https://github.com/user-attachments/assets/1fa41bf9-fe2f-4c0d-b50f-046091fe4726) 57 | 58 | 59 | ## 🙏🙏🙏 在线求Star 60 | 61 | 如果您觉得这个项目还不错, 可以在 Github 上面帮我点个star, 支持一下作者 62 | 63 | ## 效果展示 64 | ![69399a6d444d22f313097b03ee0b6d9](https://github.com/user-attachments/assets/3e9013d6-910d-4982-99e9-e8eae076d4b7) 65 | 66 | ![0e262c74cc7e008eebc86d451eb1808](https://github.com/user-attachments/assets/b6ae1c4b-14fe-41bc-8cc9-75d84c69cd66) 67 | 68 | ![01d63184d8bc6e0464623b89a089214](https://github.com/user-attachments/assets/2cf0b3c5-5cfa-4154-b89a-c272f0e780df) 69 | 70 | ![2d345b9cce50fbadc3704dec53fbfbb](https://github.com/user-attachments/assets/c9bdd4f3-1ebe-45b2-bebe-1378fc6a2cc9) 71 | 72 | ![5eb080affd87a42fc2308d820d30a46](https://github.com/user-attachments/assets/39e51e32-92f7-404d-bb5d-aa877536babf) 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | version: "3.1" 3 | services: 4 | db: 5 | image: postgres 6 | volumes: 7 | - ./postgres:/var/lib/postgresql/data 8 | restart: always 9 | ports: 10 | - 5432:5432 11 | environment: 12 | - POSTGRES_USER=cluonote 13 | - POSTGRES_PASSWORD=EASYlife.520 14 | 15 | adminer: 16 | image: adminer 17 | restart: always 18 | ports: 19 | - 8080:8080 20 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | compiler: { 4 | styledComponents: true, 5 | }, 6 | reactStrictMode: false, 7 | }; 8 | 9 | export default nextConfig; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@nextui-org/react": "^2.4.2", 13 | "@prisma/client": "^5.17.0", 14 | "@reduxjs/toolkit": "^2.2.3", 15 | "better-scroll": "^2.5.1", 16 | "framer-motion": "^11.2.10", 17 | "next": "14.2.3", 18 | "react": "^18", 19 | "react-dom": "^18", 20 | "react-lazyload": "^3.2.1", 21 | "react-redux": "^9.1.2", 22 | "react-transition-group": "^4.2.1", 23 | "redux": "^5.0.1", 24 | "styled-components": "6.1.10", 25 | "swiper": "^8.1.5", 26 | "swr": "^2.2.5" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^20.14.12", 30 | "eslint": "^8", 31 | "eslint-config-next": "14.2.3", 32 | "postcss": "^8", 33 | "prisma": "^5.17.0", 34 | "tailwindcss": "^3.4.1", 35 | "ts-node": "^10.9.2", 36 | "typescript": "^5.5.4" 37 | }, 38 | "prisma": { 39 | "seed": "ts-node prisma/seed.ts" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model Video { 17 | id Int @id @default(autoincrement()) 18 | title String @unique 19 | desc String? 20 | pic String 21 | authorId Int 22 | author User? @relation(fields: [authorId], references: [id]) 23 | categoryId Int 24 | category Category? @relation(fields: [categoryId], references: [id]) 25 | level Int @default(1) 26 | createdAt DateTime @default(now()) 27 | updatedAt DateTime @updatedAt 28 | chapter Chapter[] 29 | } 30 | 31 | model Category { 32 | id Int @id @default(autoincrement()) 33 | name String @unique 34 | video Video[] 35 | } 36 | 37 | model Chapter { 38 | id Int @id @default(autoincrement()) 39 | title String 40 | cover String 41 | url String 42 | videoId Int 43 | video Video? @relation(fields: [videoId], references: [id]) 44 | } 45 | 46 | model User { 47 | id Int @id @default(autoincrement()) 48 | avatar String 49 | name String 50 | video Video[] 51 | } 52 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | // js用 const { PrismaClient } = require("@prisma/client"); 3 | // 初始化 Prisma Client 4 | const prisma = new PrismaClient(); 5 | 6 | async function main() { 7 | //在此编写 Prisma Client 查询 8 | const user = await prisma.user.create({ 9 | data: { 10 | name: "小马", 11 | avatar: 12 | "https://p3-passport.byteimg.com/img/user-avatar/585e1491713363bc8f67d06c485e8260~100x100.awebp", 13 | }, 14 | }); 15 | console.log(user); 16 | } 17 | 18 | main() 19 | .catch((e) => { 20 | console.error(e); 21 | process.exit(1); 22 | }) 23 | .finally(async () => { 24 | // 关闭 Prisma Client 25 | await prisma.$disconnect(); 26 | }); 27 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/config.js: -------------------------------------------------------------------------------- 1 | export const API_BASE_URL = "http://localhost:3100"; 2 | 3 | //歌手种类 4 | export const categoryTypes = [ 5 | { 6 | name: "华语男", 7 | key: "1001", 8 | }, 9 | { 10 | name: "华语女", 11 | key: "1002", 12 | }, 13 | { 14 | name: "华语组合", 15 | key: "1003", 16 | }, 17 | { 18 | name: "欧美男", 19 | key: "2001", 20 | }, 21 | { 22 | name: "欧美女", 23 | key: "2002", 24 | }, 25 | { 26 | name: "欧美组合", 27 | key: "2003", 28 | }, 29 | { 30 | name: "日本男", 31 | key: "6001", 32 | }, 33 | { 34 | name: "日本女", 35 | key: "6002", 36 | }, 37 | { 38 | name: "日本组合", 39 | key: "6003", 40 | }, 41 | { 42 | name: "韩国男", 43 | key: "7001", 44 | }, 45 | { 46 | name: "韩国女", 47 | key: "7002", 48 | }, 49 | { 50 | name: "韩国组合", 51 | key: "7003", 52 | }, 53 | { 54 | name: "其他男歌手", 55 | key: "4001", 56 | }, 57 | { 58 | name: "其他女歌手", 59 | key: "4002", 60 | }, 61 | { 62 | name: "其他组合", 63 | key: "4003", 64 | }, 65 | ]; 66 | 67 | //歌手首字母 68 | export const alphaTypes = [ 69 | { 70 | key: "A", 71 | name: "A", 72 | }, 73 | { 74 | key: "B", 75 | name: "B", 76 | }, 77 | { 78 | key: "C", 79 | name: "C", 80 | }, 81 | { 82 | key: "D", 83 | name: "D", 84 | }, 85 | { 86 | key: "E", 87 | name: "E", 88 | }, 89 | { 90 | key: "F", 91 | name: "F", 92 | }, 93 | { 94 | key: "G", 95 | name: "G", 96 | }, 97 | { 98 | key: "H", 99 | name: "H", 100 | }, 101 | { 102 | key: "I", 103 | name: "I", 104 | }, 105 | { 106 | key: "J", 107 | name: "J", 108 | }, 109 | { 110 | key: "K", 111 | name: "K", 112 | }, 113 | { 114 | key: "L", 115 | name: "L", 116 | }, 117 | { 118 | key: "M", 119 | name: "M", 120 | }, 121 | { 122 | key: "N", 123 | name: "N", 124 | }, 125 | { 126 | key: "O", 127 | name: "O", 128 | }, 129 | { 130 | key: "P", 131 | name: "P", 132 | }, 133 | { 134 | key: "Q", 135 | name: "Q", 136 | }, 137 | { 138 | key: "R", 139 | name: "R", 140 | }, 141 | { 142 | key: "S", 143 | name: "S", 144 | }, 145 | { 146 | key: "T", 147 | name: "T", 148 | }, 149 | { 150 | key: "U", 151 | name: "U", 152 | }, 153 | { 154 | key: "V", 155 | name: "V", 156 | }, 157 | { 158 | key: "W", 159 | name: "W", 160 | }, 161 | { 162 | key: "X", 163 | name: "X", 164 | }, 165 | { 166 | key: "Y", 167 | name: "Y", 168 | }, 169 | { 170 | key: "Z", 171 | name: "Z", 172 | }, 173 | ]; 174 | 175 | //排行榜编号 176 | export const RankTypes = { 177 | 0: "云音乐新歌榜", 178 | 1: "云音乐热歌榜", 179 | 2: "网易原创歌曲榜", 180 | 3: "云音乐飙升榜", 181 | 4: "云音乐国电榜", 182 | 5: "UK排行榜周榜", 183 | 6: "美国Billboard周榜", 184 | 7: "KTV唛榜", 185 | 8: "iTunes榜", 186 | 9: "Hit FM Top榜", 187 | 10: "日本Oricon周榜", 188 | 11: "韩国Melon排行榜周榜", 189 | 12: "韩国Mnet排行榜周榜", 190 | 13: "韩国Melon原声周榜", 191 | 14: "中国TOP排行榜(港台榜)", 192 | 15: "中国TOP排行榜(内地榜)", 193 | 16: "香港电台中文歌曲龙虎榜", 194 | 17: "华语金曲榜", 195 | 18: "中国嘻哈榜", 196 | 19: "法国 NRJ Vos Hits 周榜", 197 | 20: "台湾Hito排行榜", 198 | 21: "Beatport全球电子舞曲榜", 199 | 22: "云音乐ACG音乐榜", 200 | 23: "江小白YOLO云音乐说唱榜", 201 | }; 202 | 203 | //歌单一页限定歌曲数量 204 | export const ONE_PAGE_COUNT = 50; 205 | 206 | //顶部的高度 207 | export const HEADER_HEIGHT = 45; 208 | 209 | //播放模式 210 | export const playMode = { 211 | sequence: 0, 212 | loop: 1, 213 | random: 2, 214 | }; 215 | 216 | // 倍速播放配置 217 | export const list = [ 218 | { 219 | key: 0.75, 220 | name: "x0.75", 221 | }, 222 | { 223 | key: 1, 224 | name: "x1", 225 | }, 226 | { 227 | key: 1.25, 228 | name: "x1.25", 229 | }, 230 | { 231 | key: 1.5, 232 | name: "x1.5", 233 | }, 234 | { 235 | key: 2, 236 | name: "x2", 237 | }, 238 | ]; 239 | -------------------------------------------------------------------------------- /src/app/HomeLayout.style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import style from "../assets/global-style"; 3 | 4 | export const Top = styled.div` 5 | display: flex; 6 | flex-direction: row; 7 | justify-content: space-between; 8 | padding: 5px 10px; 9 | background: ${style["theme-color"]}; 10 | & > span { 11 | line-height: 40px; 12 | color: #f1f1f1; 13 | font-size: 20px; 14 | &.iconfont { 15 | font-size: 25px; 16 | } 17 | } 18 | `; 19 | 20 | export const Tab = styled.div` 21 | height: 44px; 22 | display: flex; 23 | flex-direction: row; 24 | justify-content: space-around; 25 | background: ${style["theme-color"]}; 26 | a { 27 | flex: 1; 28 | padding: 2px 0; 29 | font-size: 14px; 30 | color: #e4e4e4; 31 | &.selected { 32 | span { 33 | padding: 3px 0; 34 | font-weight: 700; 35 | color: #f1f1f1; 36 | border-bottom: 2px solid #f1f1f1; 37 | } 38 | } 39 | } 40 | `; 41 | 42 | export const TabItem = styled.div` 43 | height: 100%; 44 | display: flex; 45 | flex-direction: row; 46 | justify-content: center; 47 | align-items: center; 48 | .selected { 49 | padding: 3px 0; 50 | font-weight: 700; 51 | color: #f1f1f1; 52 | border-bottom: 2px solid #f1f1f1; 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /src/app/ReduxProvider.jsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react' 4 | import { Provider } from 'react-redux' 5 | import store from '../store' 6 | 7 | const ReduxProvider = ({ children }) => { 8 | return {children} 9 | } 10 | 11 | export default ReduxProvider -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrcluo/react-nextjs/89286f12ec6bab5b731b7cb81dabff83d593550f/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/favorite/layout.jsx: -------------------------------------------------------------------------------- 1 | export default function FavoriteLayout({ children }) { 2 | return ( 3 |
4 |
共享的头部
5 | {children} 6 |
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/favorite/page.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useSelector } from "react-redux"; 3 | export default function Home() { 4 | return ( 5 |
6 |

Favorite Page

7 |
{useSelector((state) => state.singers.contact)}
8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body {} 20 | 21 | @layer utilities { 22 | .text-balance { 23 | text-wrap: balance; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/home/components/header.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import SvgIcon from "@/components/svg-icon"; 6 | import { Top, Tab, TabItem } from "../../HomeLayout.style"; 7 | 8 | function Header() { 9 | const pathName = usePathname(); 10 | const pathCheck = (path) => { 11 | const reg = new RegExp(`/(${path})$`); 12 | return reg.test(pathName) ? "selected" : ""; 13 | }; 14 | return ( 15 |
16 | 17 | alert("用户中心正在开发中,敬请期待:)")} 20 | > 21 | 22 | 23 | 云音悦 24 | alert("用户中心正在开发中,敬请期待:)")} 27 | > 28 | 29 | 30 | 31 | 32 | 33 | 34 | 推荐 35 | 36 | 37 | 38 | 39 | 歌手 40 | 41 | 42 | 43 | 44 | 排行榜 45 | 46 | 47 | 48 |
49 | ); 50 | } 51 | 52 | export default React.memo(Header); 53 | -------------------------------------------------------------------------------- /src/app/home/components/miniPlayer/index.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useRef, useCallback } from "react"; 4 | import { CSSTransition } from "react-transition-group"; 5 | import ProgressCircle from "@/components/progress-circle"; 6 | import SvgIcon from "@/components/svg-icon"; 7 | import { getName } from "@/utils"; 8 | import { MiniPlayerContainer } from "./style"; 9 | function MiniPlayer(props) { 10 | const { full, song, playing, percent } = props; 11 | const { clickPlaying, togglePlayList } = props; 12 | 13 | const miniPlayerRef = useRef(); 14 | const miniWrapperRef = useRef(); 15 | const miniImageRef = useRef(); 16 | 17 | const handleTogglePlayList = useCallback( 18 | (e) => { 19 | togglePlayList(true); 20 | e.stopPropagation(); 21 | }, 22 | [togglePlayList] 23 | ); 24 | 25 | return ( 26 | { 31 | miniPlayerRef.current.style.display = "flex"; 32 | }} 33 | onExited={() => { 34 | miniPlayerRef.current.style.display = "none"; 35 | }} 36 | > 37 | 38 |
39 |
40 | img 48 |
49 |
50 |
51 |

{song.name}

52 |

{getName(song.ar)}

53 |
54 |
55 | 56 | {playing ? ( 57 | clickPlaying(e, false)} 60 | > 61 | 62 | 63 | ) : ( 64 | clickPlaying(e, true)} 67 | > 68 | 69 | 70 | )} 71 | 72 |
73 |
74 | 75 |
76 |
77 |
78 | ); 79 | } 80 | export default React.memo(MiniPlayer); 81 | -------------------------------------------------------------------------------- /src/app/home/components/miniPlayer/style.js: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from "styled-components"; 2 | import style from "@/assets/global-style"; 3 | 4 | const rotate = keyframes` 5 | 0%{ 6 | transform: rotate(0); 7 | } 8 | 100%{ 9 | transform: rotate(360deg); 10 | } 11 | `; 12 | 13 | export const MiniPlayerContainer = styled.div` 14 | display: flex; 15 | align-items: center; 16 | position: fixed; 17 | left: 0; 18 | bottom: 0; 19 | z-index: 1000; 20 | width: 100%; 21 | height: 60px; 22 | background: ${style["highlight-background-color"]}; 23 | &.mini-enter { 24 | transform: translate3d(0, 100%, 0); 25 | } 26 | &.mini-enter-active { 27 | transform: translate3d(0, 0, 0); 28 | transition: all 0.4s; 29 | } 30 | &.mini-exit-active { 31 | transform: translate3d(0, 100%, 0); 32 | transition: all 0.4s; 33 | } 34 | .icon { 35 | flex: 0 0 40px; 36 | width: 40px; 37 | height: 40px; 38 | padding: 0 10px 0 20px; 39 | box-sizing: content-box; 40 | .imgWrapper { 41 | width: 100%; 42 | height: 100%; 43 | img { 44 | max-width: max-content; 45 | border-radius: 50%; 46 | &.play { 47 | animation: ${rotate} 10s infinite; 48 | &.pause { 49 | animation-play-state: paused; 50 | } 51 | } 52 | } 53 | } 54 | } 55 | .text { 56 | display: flex; 57 | flex-direction: column; 58 | justify-content: center; 59 | flex: 1; 60 | line-height: 20px; 61 | overflow: hidden; 62 | .name { 63 | margin-bottom: 2px; 64 | font-size: ${style["font-size-m"]}; 65 | color: ${style["font-color-desc"]}; 66 | ${style.noWrap()} 67 | } 68 | .desc { 69 | font-size: ${style["font-size-s"]}; 70 | color: ${style["font-color-desc-v2"]}; 71 | ${style.noWrap()} 72 | } 73 | } 74 | .control { 75 | flex: 0 0 30px; 76 | padding: 0 10px; 77 | .iconfont, 78 | .icon-playlist { 79 | font-size: 30px; 80 | color: ${style["theme-color"]}; 81 | } 82 | .icon-mini { 83 | font-size: 16px; 84 | position: absolute; 85 | left: 8px; 86 | top: 8px; 87 | &.icon-play { 88 | left: 9px; 89 | } 90 | } 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /src/app/home/components/normalPlayer/index.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function NormalPlayer() { 4 | return
213
; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/home/components/playList/index.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useRef, useState, useCallback } from "react"; 4 | import { CSSTransition } from "react-transition-group"; 5 | import { useSelector, useDispatch } from "react-redux"; 6 | import Scroll from "@/components/scroll"; 7 | import Confirm from "@/components/confirm"; 8 | import SvgIcon from "@/components/svg-icon"; 9 | import { shuffle, findIndex, getName } from "@/utils"; 10 | import { playMode } from "@/api/config"; 11 | import { 12 | changeShowPlayList, 13 | changePlayList, 14 | changeCurrentIndex, 15 | changePlayMode, 16 | } from "@/store/slices/songs"; 17 | import { 18 | PlayListWrapper, 19 | ListHeader, 20 | ListContent, 21 | ScrollWrapper, 22 | } from "./style"; 23 | function PlayList() { 24 | const dispatch = useDispatch(); 25 | const _states = useSelector((state) => state.songs); 26 | let { 27 | mode, 28 | sequencePlayList = [], 29 | currentIndex, 30 | playList = [], 31 | currentSong = {}, 32 | showPlayList, 33 | } = _states; 34 | 35 | const listContentRef = useRef(); 36 | const listWrapperRef = useRef(); 37 | const playListRef = useRef(); 38 | const confirmRef = useRef(); 39 | 40 | const [isShow, setIsShow] = useState(false); 41 | 42 | const onEnterCB = useCallback(() => { 43 | setIsShow(true); 44 | listWrapperRef.current.style["transform"] = `translate3d(0, 100%, 0)`; 45 | }, []); 46 | 47 | const onEnteringCB = useCallback(() => { 48 | listWrapperRef.current.style["transition"] = "all 0.3s"; 49 | listWrapperRef.current.style["transform"] = `translate3d(0, 0, 0)`; 50 | }, []); 51 | 52 | const onExitCB = useCallback(() => { 53 | listWrapperRef.current.style["transform"] = `translate3d(0, 0px, 0)`; 54 | }, []); 55 | 56 | const onExitingCB = useCallback(() => { 57 | listWrapperRef.current.style["transition"] = "all 0.3s"; 58 | listWrapperRef.current.style["transform"] = `translate3d(0px, 100%, 0px)`; 59 | }, []); 60 | 61 | const onExitedCB = useCallback(() => { 62 | setIsShow(false); 63 | listWrapperRef.current.style["transform"] = `translate3d(0px, 100%, 0px)`; 64 | }, []); 65 | 66 | const changeMode = (e) => { 67 | let newMode = (mode + 1) % 3; 68 | if (newMode === 0) { 69 | dispatch(changePlayList(sequencePlayList)); 70 | let index = findIndex(currentSong, sequencePlayList); 71 | dispatch(changeCurrentIndex(index)); 72 | } else if (newMode === 1) { 73 | dispatch(changePlayList(sequencePlayList)); 74 | } else if (newMode === 2) { 75 | let newList = shuffle(sequencePlayList); 76 | let index = findIndex(currentSong, newList); 77 | dispatch(changePlayList(newList)); 78 | dispatch(changeCurrentIndex(index)); 79 | } 80 | dispatch(changePlayMode(newMode)); 81 | }; 82 | 83 | const getPlayMode = () => { 84 | let content, text; 85 | if (mode === playMode.sequence) { 86 | content = "icon-inturn"; 87 | text = "顺序播放"; 88 | } else if (mode === playMode.loop) { 89 | content = "icon-danquxunhuan"; 90 | text = "单曲循环"; 91 | } else { 92 | content = "icon-suijibofang"; 93 | text = "随机播放"; 94 | } 95 | return ( 96 |
changeMode(e)}> 97 | 98 | {text} 99 |
100 | ); 101 | }; 102 | 103 | const togglePlayListDispatch = (status) => { 104 | dispatch(changeShowPlayList(status)); 105 | }; 106 | 107 | const handleShowClear = () => { 108 | confirmRef.current.show(); 109 | }; 110 | 111 | const handleConfirmClear = () => { 112 | // clearDispatch(); 113 | // // 修复清空播放列表后点击同样的歌曲,播放器不出现的bug 114 | // clearPreSong(); 115 | }; 116 | 117 | const getFavoriteIcon = (item) => { 118 | return ; 119 | }; 120 | 121 | const getCurrentIcon = (item) => { 122 | const current = currentSong.id === item.id; 123 | return current ? ( 124 | 125 | ) : ( 126 | 127 | ); 128 | }; 129 | 130 | const handleScroll = (pos) => {}; 131 | 132 | const handleChangeCurrentIndex = (index) => { 133 | if (currentIndex === index) return; 134 | dispatch(changeCurrentIndex(index)); 135 | }; 136 | 137 | const handleDeleteSong = (e, song) => { 138 | e.stopPropagation(); 139 | // deleteSongDispatch(song); 140 | }; 141 | 142 | return ( 143 | 153 | togglePlayListDispatch(false)} 157 | > 158 |
e.stopPropagation()} 162 | > 163 | 164 |

165 | {getPlayMode()} 166 | {/* 167 |  168 | */} 169 |

170 |
171 | 172 | handleScroll(pos)} 177 | bounceTop={false} 178 | > 179 | 180 | {playList.map((item, index) => { 181 | return ( 182 |
  • handleChangeCurrentIndex(index)} 186 | > 187 | {getCurrentIcon(item)} 188 | 189 | {item.name} - {getName(item.ar)} 190 | 191 | {/* {getFavoriteIcon(item)} */} 192 | {/* handleDeleteSong(e, item)} 195 | > 196 | 197 | */} 198 |
  • 199 | ); 200 | })} 201 |
    202 |
    203 |
    204 |
    205 | 212 |
    213 |
    214 | ); 215 | } 216 | export default React.memo(PlayList); 217 | -------------------------------------------------------------------------------- /src/app/home/components/playList/style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import style from "@/assets/global-style"; 3 | 4 | export const PlayListWrapper = styled.div` 5 | position: fixed; 6 | left: 0; 7 | right: 0; 8 | top: 0; 9 | bottom: 0; 10 | z-index: 1000; 11 | background-color: ${style["background-color-shadow"]}; 12 | &.list-fade-enter { 13 | opacity: 0; 14 | } 15 | &.list-fade-enter-active { 16 | opacity: 1; 17 | transition: all 0.3s; 18 | } 19 | &.list-fade-exit { 20 | opacity: 1; 21 | } 22 | &.list-fade-exit-active { 23 | opacity: 0; 24 | transition: all 0.3s; 25 | } 26 | .list_wrapper { 27 | position: absolute; 28 | left: 0; 29 | bottom: 0; 30 | width: 100%; 31 | opacity: 1; 32 | border-radius: 10px 10px 0 0; 33 | background-color: ${style["highlight-background-color"]}; 34 | transform: translate3d(0, 0, 0); 35 | .list_close { 36 | text-align: center; 37 | line-height: 50px; 38 | background: ${style["background-color"]}; 39 | font-size: ${style["font-size-l"]}; 40 | color: ${style["font-color-desc"]}; 41 | } 42 | } 43 | `; 44 | 45 | export const ListHeader = styled.div` 46 | position: relative; 47 | padding: 20px 30px 10px 20px; 48 | .title { 49 | display: flex; 50 | align-items: center; 51 | > div { 52 | flex: 1; 53 | .text { 54 | flex: 1; 55 | font-size: ${style["font-size-m"]}; 56 | color: ${style["font-color-desc"]}; 57 | } 58 | } 59 | .iconfont { 60 | margin-right: 10px; 61 | font-size: ${style["font-size-ll"]}; 62 | color: ${style["theme-color"]}; 63 | } 64 | 65 | .clear { 66 | ${style.extendClick()} 67 | font-size: ${style["font-size-l"]}; 68 | } 69 | .change-mode { 70 | display: flex; 71 | } 72 | } 73 | `; 74 | export const ScrollWrapper = styled.div` 75 | height: 400px; 76 | overflow: hidden; 77 | `; 78 | 79 | export const ListContent = styled.div` 80 | .item { 81 | display: flex; 82 | align-items: center; 83 | height: 40px; 84 | padding: 0 30px 0 20px; 85 | overflow: hidden; 86 | &.list-enter, 87 | &.list-exit-done { 88 | height: 0; 89 | } 90 | &.list-enter-active, 91 | &.list-leave-active { 92 | transition: all 0.1s; 93 | } 94 | .current { 95 | flex: 0 0 20px; 96 | width: 20px; 97 | font-size: ${style["font-size-s"]}; 98 | color: ${style["theme-color"]}; 99 | } 100 | .text { 101 | flex: 1; 102 | ${style.noWrap()} 103 | font-size: ${style["font-size-m"]}; 104 | color: ${style["font-color-desc-v2"]}; 105 | .icon-favorite { 106 | color: ${style["theme-color"]}; 107 | } 108 | } 109 | .like { 110 | ${style.extendClick()} 111 | margin-right: 15px; 112 | font-size: ${style["font-size-m"]}; 113 | color: ${style["theme-color"]}; 114 | } 115 | .delete { 116 | ${style.extendClick()} 117 | font-size: ${style["font-size-s"]}; 118 | color: ${style["theme-color"]}; 119 | } 120 | } 121 | `; 122 | -------------------------------------------------------------------------------- /src/app/home/components/player/index.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useRef, useState, useEffect } from "react"; 4 | import { useSelector, useDispatch } from "react-redux"; 5 | import { isEmptyObject, getSongUrl } from "@/utils"; 6 | import Toast from "@/components/toast"; 7 | import { 8 | changeCurrentSong, 9 | changePlaying, 10 | changeShowPlayList, 11 | } from "@/store/slices/songs"; 12 | import MiniPlayer from "../miniPlayer"; 13 | import PlayList from "../playList"; 14 | 15 | function Player() { 16 | const dispatch = useDispatch(); 17 | const _states = useSelector((state) => state.songs); 18 | let { 19 | speed, 20 | playing, 21 | currentIndex, 22 | playList = [], 23 | currentSong = {}, 24 | } = _states; 25 | 26 | const audioRef = useRef(); 27 | const toastRef = useRef(); 28 | 29 | const [currentTime, setCurrentTime] = useState(0); 30 | const [duration, setDuration] = useState(0); 31 | const [preSong, setPreSong] = useState({}); 32 | const [modeText, setModeText] = useState(""); 33 | 34 | let percent = isNaN(currentTime / duration) ? 0 : currentTime / duration; 35 | 36 | useEffect(() => { 37 | if ( 38 | !playList.length || 39 | currentIndex === -1 || 40 | !playList[currentIndex] || 41 | playList[currentIndex].id === preSong.id 42 | ) 43 | return; 44 | let current = playList[currentIndex]; 45 | dispatch(changeCurrentSong(current)); 46 | setPreSong(current); // 保存当前歌曲 47 | audioRef.current.src = getSongUrl(current.id); 48 | audioRef.current.autoplay = true; 49 | audioRef.current.playbackRate = speed; 50 | dispatch(changePlaying(true)); 51 | setCurrentTime(0); 52 | setDuration((current.dt / 1000) | 0); // 设置歌曲时长 53 | }, [currentIndex, playList]); 54 | 55 | useEffect(() => { 56 | playing ? audioRef.current.play() : audioRef.current.pause(); 57 | }, [playing]); 58 | const clickPlaying = (e, state) => { 59 | e.stopPropagation(); 60 | dispatch(changePlaying(state)); 61 | }; 62 | 63 | const togglePlayListDispatch = (data) => { 64 | dispatch(changeShowPlayList(data)); 65 | }; 66 | 67 | const updateTime = (e) => { 68 | setCurrentTime(e.target.currentTime); 69 | }; 70 | 71 | const handleEnd = () => {}; 72 | const handleError = () => {}; 73 | 74 | return ( 75 |
    76 | {isEmptyObject(currentSong) ? null : ( 77 | 84 | )} 85 | 86 | 87 | 93 | 94 |
    95 | ); 96 | } 97 | export default React.memo(Player); 98 | -------------------------------------------------------------------------------- /src/app/home/components/recommendList/index.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import LazyLoad from "react-lazyload"; 5 | import Link from "next/link"; 6 | import SvgIcon from "@/components/svg-icon"; 7 | import musicImg from "@/assets/music.png"; 8 | import { ListWrapper, ListItem, List } from "./style"; 9 | 10 | function RecommendList(props) { 11 | return ( 12 | 13 |

    推荐歌单

    14 | 15 | {props.recommendList.map((item) => { 16 | return ( 17 | 18 | 19 |
    20 |
    21 | 29 | } 30 | > 31 | music 37 | 38 |
    39 | 40 | 41 | {Math.floor(item.playCount / 10000)}万 42 | 43 |
    44 |
    45 |
    {item.name}
    46 | 47 |
    48 | ); 49 | })} 50 |
    51 |
    52 | ); 53 | } 54 | 55 | export default React.memo(RecommendList); 56 | -------------------------------------------------------------------------------- /src/app/home/components/recommendList/style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import style from "@/assets/global-style"; 3 | 4 | export const ListWrapper = styled.div` 5 | max-width: 100%; 6 | .title { 7 | font-weight: 700; 8 | padding-left: 6px; 9 | font-size: 14px; 10 | line-height: 60px; 11 | color: ${style["font-color"]}; 12 | } 13 | `; 14 | export const List = styled.div` 15 | width: 100%; 16 | display: flex; 17 | flex-direction: row; 18 | flex-wrap: wrap; 19 | justify-content: space-around; 20 | &:after { 21 | content: ""; 22 | flex: auto; 23 | } 24 | `; 25 | 26 | export const ListItem = styled.div` 27 | position: relative; 28 | width: 32%; 29 | .decorate { 30 | z-index: 1; 31 | position: absolute; 32 | top: 0; 33 | width: 100%; 34 | height: 35px; 35 | border-radius: 3px; 36 | background: linear-gradient(hsla(0, 0%, 43%, 0.4), hsla(0, 0%, 100%, 0)); 37 | } 38 | .img_wrapper { 39 | position: relative; 40 | height: 0; 41 | padding-bottom: 100%; 42 | .play_count { 43 | z-index: 1; 44 | display: flex; 45 | position: absolute; 46 | right: 2px; 47 | top: 2px; 48 | font-size: ${style["font-size-s"]}; 49 | line-height: 15px; 50 | color: ${style["font-color-light"]}; 51 | .play { 52 | vertical-align: top; 53 | } 54 | } 55 | img { 56 | position: absolute; 57 | width: 100%; 58 | height: 100%; 59 | border-radius: 3px; 60 | } 61 | } 62 | .desc { 63 | overflow: hidden; 64 | margin-top: 2px; 65 | padding: 0 2px; 66 | height: 50px; 67 | text-align: left; 68 | font-size: ${style["font-size-s"]}; 69 | line-height: 1.4; 70 | color: ${style["font-color-desc"]}; 71 | } 72 | `; 73 | -------------------------------------------------------------------------------- /src/app/home/layout.jsx: -------------------------------------------------------------------------------- 1 | import Header from "./components/header"; 2 | import Player from "./components/player"; 3 | 4 | export const metadata = { 5 | title: "Create Next App", 6 | description: "Generated by create next app", 7 | }; 8 | 9 | export default function HomeLayout({ children }) { 10 | return ( 11 |
    12 |
    13 | {children} 14 | 15 |
    16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/home/page.jsx: -------------------------------------------------------------------------------- 1 | import { API_BASE_URL } from "@/api/config"; 2 | import RecommendMain from "./recommend/main"; 3 | import { Content } from "./style"; 4 | export default async function Recommend(props) { 5 | const { songsCount = 0 } = props; 6 | const getData = async () => { 7 | const res = await fetch(`${API_BASE_URL}/banner`); 8 | if (!res.ok) { 9 | throw new Error("Failed to fetch data"); 10 | } 11 | return res.json(); 12 | }; 13 | const getrecommendData = async () => { 14 | const res = await fetch(`${API_BASE_URL}/personalized`); 15 | if (!res.ok) { 16 | throw new Error("Failed to fetch data"); 17 | } 18 | return res.json(); 19 | }; 20 | const bannerList = await getData(); 21 | const recommendList = await getrecommendData(); 22 | return ( 23 | 24 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/home/rank/[id]/page.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState, useCallback, useRef } from "react"; 3 | import { useParams, useRouter } from "next/navigation"; 4 | import { CSSTransition } from "react-transition-group"; 5 | import Loading from "@/components/loading"; 6 | import Scroll from "@/components/scroll"; 7 | import SvgIcon from "@/components/svg-icon"; 8 | import useGetAlbums from "@/hooks/useGetAlbums"; 9 | import Header from "@/components/header"; 10 | import style from "@/assets/global-style"; 11 | import SongsList from "../../singer/components/songList"; 12 | import { EnterLoading } from "../../singer/style"; 13 | import { Container, SongListWrapper, TopDesc } from "./style"; 14 | 15 | function Rank() { 16 | const route = useParams(); 17 | const router = useRouter(); 18 | const [showStatus, setShowStatus] = useState(true); 19 | const { data = {}, isLoading } = useGetAlbums(route?.id); 20 | const ImgWrapperRef = useRef(); 21 | const SongScrollWrapperRef = useRef(); 22 | const ScrollRef = useRef(); 23 | const HeaderRef = useRef(); 24 | const InitialHeight = useRef(0); 25 | 26 | const OFFSET = 5; 27 | const HEADER_HEIGHT = 45; 28 | 29 | useEffect(() => { 30 | let h = ImgWrapperRef.current.offsetHeight; 31 | InitialHeight.current = h; 32 | SongScrollWrapperRef.current.style.top = `${h - OFFSET}px`; 33 | ScrollRef.current.refresh(); 34 | }, []); 35 | 36 | const setShowStatusFalse = useCallback(() => { 37 | setShowStatus(false); 38 | }, []); 39 | 40 | const handleScroll = (pos) => { 41 | let height = InitialHeight.current; 42 | const newY = pos.y; 43 | const headerDOM = HeaderRef.current; 44 | const minScrollY = -(height - OFFSET) + HEADER_HEIGHT; 45 | const percent = Math.abs(pos.y / minScrollY); 46 | if (newY < minScrollY) { 47 | //防止溢出的歌单内容遮住Header 48 | headerDOM.style.zIndex = 100; 49 | headerDOM.style.backgroundColor = style["theme-color"]; 50 | headerDOM.style.opacity = Math.min(1, percent - 1); 51 | } else { 52 | headerDOM.style.backgroundColor = ""; 53 | headerDOM.style.opacity = 1; 54 | } 55 | }; 56 | 57 | const renderTopDesc = () => { 58 | return ( 59 | 60 |
    61 |
    62 |
    63 |
    64 |
    65 | 66 |
    67 | 68 | 69 | {Math.floor(data?.playlist?.subscribedCount / 1000) / 10}万 70 | 71 |
    72 |
    73 |
    74 |
    {data?.playlist?.name}
    75 |
    76 |
    77 | 78 |
    79 |
    {data?.playlist?.creator.nickname}
    80 |
    81 |
    82 |
    83 | ); 84 | }; 85 | 86 | return ( 87 | router.back()} 94 | > 95 | 96 |
    97 | {renderTopDesc()} 98 | 102 | 108 | 109 | 110 | 111 | {isLoading ? ( 112 | 113 | 114 | 115 | ) : null} 116 | 117 | 118 | ); 119 | } 120 | 121 | export default Rank; 122 | -------------------------------------------------------------------------------- /src/app/home/rank/[id]/style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import style from "@/assets/global-style"; 3 | 4 | export const Container = styled.div` 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | right: 0; 9 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)}; 10 | width: 100%; 11 | z-index: 100; 12 | overflow: hidden; 13 | background: #f2f3f4; 14 | transform-origin: right bottom; 15 | &.fly-enter, 16 | &.fly-appear { 17 | transform: rotateZ(30deg) translate3d(100%, 0, 0); 18 | } 19 | &.fly-enter-active, 20 | &.fly-appear-active { 21 | transition: transform 0.3s; 22 | transform: rotateZ(0deg) translate3d(0, 0, 0); 23 | } 24 | &.fly-exit { 25 | transform: rotateZ(0deg) translate3d(0, 0, 0); 26 | } 27 | &.fly-exit-active { 28 | transition: transform 0.3s; 29 | transform: rotateZ(30deg) translate3d(100%, 0, 0); 30 | } 31 | `; 32 | 33 | export const ImgWrapper = styled.div` 34 | position: relative; 35 | width: 100%; 36 | height: 0; 37 | padding-top: 75%; 38 | transform-origin: top; 39 | background: url(${(props) => props?.bgurl}); 40 | background-size: cover; 41 | z-index: 50; 42 | .filter { 43 | position: absolute; 44 | top: 0; 45 | left: 0; 46 | width: 100%; 47 | height: 100%; 48 | /* : blur(20px); */ 49 | background: rgba(7, 17, 27, 0.3); 50 | } 51 | `; 52 | export const CollectButton = styled.div` 53 | position: fixed; 54 | left: 0; 55 | right: 0; 56 | margin: auto; 57 | box-sizing: border-box; 58 | width: 120px; 59 | height: 40px; 60 | margin-top: -55px; 61 | z-index: 50; 62 | background: ${style["theme-color"]}; 63 | color: ${style["font-color-light"]}; 64 | border-radius: 20px; 65 | text-align: center; 66 | font-size: 0; 67 | line-height: 40px; 68 | .iconfont { 69 | display: inline-block; 70 | margin-right: 10px; 71 | font-size: 12px; 72 | vertical-align: 1px; 73 | } 74 | .text { 75 | display: inline-block; 76 | font-size: 14px; 77 | letter-spacing: 5px; 78 | } 79 | `; 80 | 81 | export const SongListWrapper = styled.div` 82 | position: absolute; 83 | z-index: 50; 84 | top: 0; 85 | left: 0; 86 | bottom: ${(props) => (props.play ? "60px" : 0)}; 87 | right: 0; 88 | > div { 89 | position: absolute; 90 | left: 0; 91 | width: 100%; 92 | overflow: visible; 93 | } 94 | `; 95 | export const BgLayer = styled.div` 96 | position: absolute; 97 | top: 0; 98 | bottom: 0; 99 | width: 100%; 100 | background: white; 101 | border-radius: 10px; 102 | z-index: 50; 103 | `; 104 | 105 | export const TopDesc = styled.div` 106 | background-size: 100%; 107 | padding: 5px 20px; 108 | display: flex; 109 | justify-content: space-around; 110 | align-items: center; 111 | box-sizing: border-box; 112 | width: 100%; 113 | height: 225px; 114 | position: relative; 115 | .background { 116 | z-index: -1; 117 | background: url(${(props) => props.background}) left top no-repeat; 118 | background: contain; 119 | background-position: 0 0; 120 | background-size: 100% 100%; 121 | position: absolute; 122 | width: 100%; 123 | height: 100%; 124 | filter: blur(20px); 125 | .filter { 126 | position: absolute; 127 | z-index: 10; 128 | top: 0; 129 | left: 0; 130 | width: 100%; 131 | height: 100%; 132 | background: rgba(7, 17, 27, 0.2); 133 | } 134 | } 135 | .img_wrapper { 136 | width: 120px; 137 | height: 120px; 138 | position: relative; 139 | .decorate { 140 | position: absolute; 141 | top: 0; 142 | width: 100%; 143 | height: 35px; 144 | border-radius: 3px; 145 | background: linear-gradient(hsla(0, 0%, 43%, 0.4), hsla(0, 0%, 100%, 0)); 146 | } 147 | .play_count { 148 | position: absolute; 149 | right: 2px; 150 | top: 2px; 151 | font-size: ${style["font-size-s"]}; 152 | line-height: 15px; 153 | color: ${style["font-color-light"]}; 154 | display: flex; 155 | padding-right: 10px; 156 | padding-top: 10px; 157 | .svg-class { 158 | margin-right: 4px; 159 | } 160 | .play { 161 | vertical-align: top; 162 | } 163 | } 164 | img { 165 | width: 120px; 166 | height: 120px; 167 | border-radius: 3px; 168 | } 169 | } 170 | .desc_wrapper { 171 | flex: 1; 172 | display: flex; 173 | flex-direction: column; 174 | justify-content: space-around; 175 | height: 120px; 176 | padding: 0 10px; 177 | .title { 178 | max-height: 70px; 179 | overflow: hidden; 180 | text-overflow: ellipsis; 181 | color: ${style["font-color-light"]}; 182 | font-weight: 700; 183 | line-height: 1.5; 184 | font-size: ${style["font-size-l"]}; 185 | } 186 | .person { 187 | display: flex; 188 | .avatar { 189 | width: 20px; 190 | height: 20px; 191 | margin-right: 5px; 192 | img { 193 | width: 100%; 194 | height: 100%; 195 | border-radius: 50%; 196 | } 197 | } 198 | .name { 199 | line-height: 20px; 200 | font-size: ${style["font-size-m"]}; 201 | color: ${style["font-color-desc-v2"]}; 202 | } 203 | } 204 | } 205 | `; 206 | -------------------------------------------------------------------------------- /src/app/home/rank/components/main.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import Link from "next/link"; 5 | import Scroll from "@/components/scroll"; 6 | import Loading from "@/components/loading"; 7 | import { filterIndex } from "@/utils"; 8 | import { EnterLoading } from "../../singer/style"; 9 | import { List, ListItem, SongList, Container } from "../style"; 10 | function Rank(props) { 11 | const { rankList, loading = true } = props; 12 | 13 | const renderSongList = (list) => { 14 | return list.length ? ( 15 | 16 | {list.map((item, index) => { 17 | return ( 18 |
  • 19 | {index + 1}. {item.first} - {item.second} 20 |
  • 21 | ); 22 | })} 23 |
    24 | ) : null; 25 | }; 26 | 27 | const renderRankList = (list, global) => { 28 | return ( 29 | 30 | {list.map((item, index) => { 31 | return ( 32 | 36 | 37 |
    38 | 39 |
    40 | 41 | {item.updateFrequency} 42 | 43 |
    44 | {renderSongList(item.tracks)} 45 |
    46 | 47 | ); 48 | })} 49 |
    50 | ); 51 | }; 52 | 53 | let globalStartIndex = filterIndex(rankList); 54 | let officialList = rankList.slice(0, globalStartIndex); 55 | let globalList = rankList.slice(globalStartIndex); 56 | return ( 57 | 58 | 59 |
    60 |

    官方榜

    61 | {renderRankList(officialList)} 62 |

    全球榜

    63 | {renderRankList(globalList, true)} 64 | {loading ? ( 65 | 66 | 67 | 68 | ) : null} 69 |
    70 |
    71 |
    72 | ); 73 | } 74 | 75 | export default React.memo(Rank); 76 | -------------------------------------------------------------------------------- /src/app/home/rank/page.jsx: -------------------------------------------------------------------------------- 1 | import { API_BASE_URL } from "@/api/config"; 2 | import Main from "./components/main"; 3 | 4 | export default async function Rank() { 5 | const getData = async () => { 6 | const res = await fetch(`${API_BASE_URL}/toplist/detail`); 7 | if (!res.ok) { 8 | throw new Error("Failed to fetch data"); 9 | } 10 | return res.json(); 11 | }; 12 | const data = await getData(); 13 | return
    ; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/home/rank/style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import style from "@/assets/global-style"; 3 | 4 | // Props中的globalRank和tracks.length均代表是否为全球榜 5 | 6 | export const Container = styled.div` 7 | position: fixed; 8 | top: 90px; 9 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)}; 10 | width: 100%; 11 | .offical, 12 | .global { 13 | margin: 10px 5px; 14 | padding-top: 15px; 15 | font-weight: 700; 16 | font-size: ${style["font-size-m"]}; 17 | color: ${style["font-color-desc"]}; 18 | } 19 | `; 20 | 21 | export const List = styled.ul` 22 | margin-top: 10px; 23 | padding: 0 5px; 24 | display: ${(props) => (props.globalrank ? "flex" : "")}; 25 | flex-direction: row; 26 | justify-content: space-between; 27 | flex-wrap: wrap; 28 | &::after { 29 | content: ""; 30 | display: block; 31 | width: 32vw; 32 | } 33 | `; 34 | export const ListItem = styled.li` 35 | display: ${(props) => (props.tracks.length ? "flex" : "")}; 36 | padding: 3px 0; 37 | border-bottom: 1px solid ${style["border-color"]}; 38 | .img_wrapper { 39 | width: ${(props) => (props.tracks.length ? "27vw" : "32vw")}; 40 | height: ${(props) => (props.tracks.length ? "27vw" : "32vw")}; 41 | border-radius: 3px; 42 | position: relative; 43 | .decorate { 44 | position: absolute; 45 | bottom: 0; 46 | width: 100%; 47 | height: 35px; 48 | border-radius: 3px; 49 | background: linear-gradient(hsla(0, 0%, 100%, 0), hsla(0, 0%, 43%, 0.4)); 50 | } 51 | img { 52 | width: 100%; 53 | height: 100%; 54 | border-radius: 3px; 55 | } 56 | .update_frequecy { 57 | position: absolute; 58 | left: 7px; 59 | bottom: 7px; 60 | font-size: ${style["font-size-ss"]}; 61 | color: ${style["font-color-light"]}; 62 | } 63 | } 64 | `; 65 | export const SongList = styled.ul` 66 | flex: 1; 67 | display: flex; 68 | flex-direction: column; 69 | justify-content: space-around; 70 | padding: 10px 10px; 71 | > li { 72 | font-size: ${style["font-size-s"]}; 73 | color: grey; 74 | } 75 | `; 76 | -------------------------------------------------------------------------------- /src/app/home/recommend/main.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState } from "react"; 3 | import { forceCheck } from "react-lazyload"; 4 | import Slider from "@/components/slider"; 5 | import Scroll from "@/components/scroll"; 6 | import Loading from "@/components/loading"; 7 | import { EnterLoading } from "../singer/style"; 8 | import RecommendList from "../components/recommendList"; 9 | export default function Main(props) { 10 | const { bannerList, recommendList } = props; 11 | const [enterLoading, setEnterLoading] = useState(true); 12 | 13 | useEffect(() => { 14 | setEnterLoading(false); 15 | }, recommendList); 16 | const pullUp = () => { 17 | console.log("到底部了"); 18 | }; 19 | const pullDown = () => { 20 | console.log("到顶部了"); 21 | }; 22 | 23 | return ( 24 | <> 25 | 32 |
    33 | 34 | 35 |
    36 |
    37 | {enterLoading ? ( 38 | 39 | 40 | 41 | ) : null} 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/home/singer/[id]/page.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState, useCallback, useRef } from "react"; 3 | import { useParams, useRouter } from "next/navigation"; 4 | import { CSSTransition } from "react-transition-group"; 5 | import Loading from "@/components/loading"; 6 | import Scroll from "@/components/scroll"; 7 | import useGetSingerSongs from "@/hooks/useGetSingerSongs"; 8 | import Header from "@/components/header"; 9 | import SongsList from "../components/songList"; 10 | import { Container, CollectButton, SongListWrapper, ImgWrapper } from "./style"; 11 | import { EnterLoading } from "../style"; 12 | 13 | function Singer() { 14 | const route = useParams(); 15 | const router = useRouter(); 16 | const [showStatus, setShowStatus] = useState(true); 17 | const { data = {}, isLoading } = useGetSingerSongs(route?.id); 18 | const ImgWrapperRef = useRef(); 19 | const SongScrollWrapperRef = useRef(); 20 | const ScrollRef = useRef(); 21 | const HeaderRef = useRef(); 22 | const InitialHeight = useRef(0); 23 | 24 | const OFFSET = 5; 25 | const HEADER_HEIGHT = 45; 26 | 27 | useEffect(() => { 28 | let h = ImgWrapperRef.current.offsetHeight; 29 | InitialHeight.current = h; 30 | SongScrollWrapperRef.current.style.top = `${h - OFFSET}px`; 31 | ScrollRef.current.refresh(); 32 | }, []); 33 | 34 | const setShowStatusFalse = useCallback(() => { 35 | setShowStatus(false); 36 | }, []); 37 | 38 | const handleScroll = (pos) => { 39 | let height = InitialHeight.current; 40 | const newY = pos.y; 41 | const imageDOM = ImgWrapperRef.current; 42 | const headerDOM = HeaderRef.current; 43 | const minScrollY = -(height - OFFSET) + HEADER_HEIGHT; 44 | 45 | const percent = Math.abs(newY / height); 46 | //说明: 在歌手页的布局中,歌单列表其实是没有自己的背景的,layerDOM其实是起一个遮罩的作用,给歌单内容提供白色背景 47 | //因此在处理的过程中,随着内容的滚动,遮罩也跟着移动 48 | if (newY > 0) { 49 | //处理往下拉的情况,效果:图片放大,按钮跟着偏移 50 | imageDOM.style["transform"] = `scale(${1 + percent})`; 51 | } else if (newY >= minScrollY) { 52 | //往上滑动,但是还没超过Header部分 53 | imageDOM.style.paddingTop = "75%"; 54 | imageDOM.style.height = 0; 55 | imageDOM.style.zIndex = -1; 56 | } else if (newY < minScrollY) { 57 | //防止溢出的歌单内容遮住Header 58 | headerDOM.style.zIndex = 100; 59 | //此时图片高度与Header一致 60 | imageDOM.style.height = `${HEADER_HEIGHT}px`; 61 | imageDOM.style.paddingTop = 0; 62 | imageDOM.style.zIndex = 99; 63 | } 64 | }; 65 | 66 | return ( 67 | router.back()} 74 | > 75 | 76 |
    81 | 82 |
    83 |
    84 | 88 | 94 | 95 | 96 | 97 | {isLoading ? ( 98 | 99 | 100 | 101 | ) : null} 102 | 103 | 104 | ); 105 | } 106 | 107 | export default Singer; 108 | -------------------------------------------------------------------------------- /src/app/home/singer/[id]/style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import style from "@/assets/global-style"; 3 | 4 | export const Container = styled.div` 5 | position: fixed; 6 | top: 0; 7 | left: 0; 8 | right: 0; 9 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)}; 10 | width: 100%; 11 | z-index: 100; 12 | overflow: hidden; 13 | background: #f2f3f4; 14 | transform-origin: right bottom; 15 | &.fly-enter, 16 | &.fly-appear { 17 | transform: rotateZ(30deg) translate3d(100%, 0, 0); 18 | } 19 | &.fly-enter-active, 20 | &.fly-appear-active { 21 | transition: transform 0.3s; 22 | transform: rotateZ(0deg) translate3d(0, 0, 0); 23 | } 24 | &.fly-exit { 25 | transform: rotateZ(0deg) translate3d(0, 0, 0); 26 | } 27 | &.fly-exit-active { 28 | transition: transform 0.3s; 29 | transform: rotateZ(30deg) translate3d(100%, 0, 0); 30 | } 31 | `; 32 | 33 | export const ImgWrapper = styled.div` 34 | position: relative; 35 | width: 100%; 36 | height: 0; 37 | padding-top: 75%; 38 | transform-origin: top; 39 | background: url(${(props) => props?.bgurl}); 40 | background-size: cover; 41 | z-index: 50; 42 | .filter { 43 | position: absolute; 44 | top: 0; 45 | left: 0; 46 | width: 100%; 47 | height: 100%; 48 | /* : blur(20px); */ 49 | background: rgba(7, 17, 27, 0.3); 50 | } 51 | `; 52 | export const CollectButton = styled.div` 53 | position: fixed; 54 | left: 0; 55 | right: 0; 56 | margin: auto; 57 | box-sizing: border-box; 58 | width: 120px; 59 | height: 40px; 60 | margin-top: -55px; 61 | z-index: 50; 62 | background: ${style["theme-color"]}; 63 | color: ${style["font-color-light"]}; 64 | border-radius: 20px; 65 | text-align: center; 66 | font-size: 0; 67 | line-height: 40px; 68 | .iconfont { 69 | display: inline-block; 70 | margin-right: 10px; 71 | font-size: 12px; 72 | vertical-align: 1px; 73 | } 74 | .text { 75 | display: inline-block; 76 | font-size: 14px; 77 | letter-spacing: 5px; 78 | } 79 | `; 80 | 81 | export const SongListWrapper = styled.div` 82 | position: absolute; 83 | z-index: 50; 84 | top: 0; 85 | left: 0; 86 | bottom: ${(props) => (props.play ? "60px" : 0)}; 87 | right: 0; 88 | > div { 89 | position: absolute; 90 | left: 0; 91 | width: 100%; 92 | overflow: visible; 93 | } 94 | `; 95 | export const BgLayer = styled.div` 96 | position: absolute; 97 | top: 0; 98 | bottom: 0; 99 | width: 100%; 100 | background: white; 101 | border-radius: 10px; 102 | z-index: 50; 103 | `; 104 | -------------------------------------------------------------------------------- /src/app/home/singer/components/horizen-item.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, memo } from "react"; 2 | import styled from "styled-components"; 3 | import Scroll from "@/components/scroll"; 4 | import style from "@/assets/global-style"; 5 | 6 | //样式部分 7 | const List = styled.div` 8 | display: flex; 9 | align-items: center; 10 | height: 30px; 11 | justify-content: center; 12 | overflow: hidden; 13 | > span:first-of-type { 14 | display: block; 15 | flex: 0 0 auto; 16 | padding: 5px 0; 17 | color: grey; 18 | font-size: ${style["font-size-m"]}; 19 | vertical-align: middle; 20 | } 21 | `; 22 | const ListItem = styled.span` 23 | flex: 0 0 auto; 24 | font-size: ${style["font-size-m"]}; 25 | padding: 0 5px; 26 | border-radius: 10px; 27 | &.selected { 28 | color: ${style["theme-color"]}; 29 | border: 1px solid ${style["theme-color"]}; 30 | opacity: 0.8; 31 | } 32 | `; 33 | 34 | function Horizen(props) { 35 | const [refreshCategoryScroll, setRefreshCategoryScroll] = useState(false); 36 | const Category = useRef(null); 37 | const { list, oldVal, title } = props; 38 | const { handleClick } = props; 39 | 40 | useEffect(() => { 41 | let categoryDOM = Category.current; 42 | let tagElems = categoryDOM.querySelectorAll("span"); 43 | let totalWidth = 0; 44 | Array.from(tagElems).forEach((ele) => { 45 | totalWidth += ele.offsetWidth; 46 | }); 47 | totalWidth += 2; 48 | categoryDOM.style.width = `${totalWidth}px`; 49 | setRefreshCategoryScroll(true); 50 | }, [refreshCategoryScroll]); 51 | 52 | const clickHandle = (item) => { 53 | handleClick(item.key); 54 | }; 55 | return ( 56 | 57 |
    58 | 59 | {title} 60 | {list.map((item) => { 61 | return ( 62 | clickHandle(item)} 66 | > 67 | {item.name} 68 | 69 | ); 70 | })} 71 | 72 |
    73 |
    74 | ); 75 | } 76 | 77 | export default memo(Horizen); 78 | -------------------------------------------------------------------------------- /src/app/home/singer/components/main.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { memo, useRef, useEffect, useState } from "react"; 4 | import { useSelector, useDispatch } from "react-redux"; 5 | import LazyLoad, { forceCheck } from "react-lazyload"; 6 | import Link from "next/link"; 7 | import Scroll from "@/components/scroll"; 8 | import musicImg from "@/assets/music.png"; 9 | import Loading from "@/components/loading"; 10 | import { 11 | changeSingerList, 12 | changePullDownLoading, 13 | changePullUpLoading, 14 | } from "@/store/slices/singers"; 15 | import useSingerMutation from "@/hooks/useSingerMutation"; 16 | import { ListContainer, List, ListItem, EnterLoading } from "../style"; 17 | 18 | function Main(props) { 19 | const scrollRef = useRef(null); 20 | const data = useSelector((state) => state.singers.singerDes); 21 | const { 22 | songsCount = 0, 23 | singerList, 24 | pullUpLoading, 25 | pullDownLoading, 26 | category, 27 | alpha, 28 | listOffset, 29 | } = data; 30 | const { initSingerList } = props; 31 | const dispatch = useDispatch(); 32 | const { changeSinger, loadMoreSinger } = useSingerMutation(); 33 | const [enterLoading, setEnterLoading] = useState(true); 34 | 35 | useEffect(() => { 36 | dispatch(changeSingerList(initSingerList)); 37 | setEnterLoading(false); 38 | }, [initSingerList]); 39 | 40 | const pullUp = () => { 41 | console.log("到底部了"); 42 | dispatch(changePullUpLoading(true)); 43 | loadMoreSinger(category, alpha, listOffset); 44 | }; 45 | const pullDown = () => { 46 | console.log("到顶部了"); 47 | dispatch(changePullDownLoading(true)); 48 | changeSinger(category, alpha, 0); 49 | }; 50 | 51 | const renderSingerList = () => { 52 | return ( 53 | 54 | {singerList.map((item, index) => { 55 | return ( 56 | 60 | 61 |
    62 | 70 | } 71 | > 72 | music 78 | 79 |
    80 | {item.name} 81 |
    82 | 83 | ); 84 | })} 85 |
    86 | ); 87 | }; 88 | return ( 89 | <> 90 | 91 | 101 | {renderSingerList()} 102 | 103 | 104 | {enterLoading ? ( 105 | 106 | 107 | 108 | ) : null} 109 | 110 | ); 111 | } 112 | 113 | export default memo(Main); 114 | -------------------------------------------------------------------------------- /src/app/home/singer/components/songList.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import SvgIcon from "@/components/svg-icon"; 4 | import { getName } from "@/utils"; 5 | import { 6 | changeCurrentIndex, 7 | changePlayList, 8 | changeSequencePlayList, 9 | } from "@/store/slices/songs"; 10 | import { SongList, SongItem } from "../style"; 11 | 12 | function SongsList(props) { 13 | const { songs = [] } = props; 14 | const dispatch = useDispatch(); 15 | 16 | const selectItem = (e, index) => { 17 | dispatch(changeCurrentIndex(index)); 18 | dispatch(changePlayList(songs)); 19 | dispatch(changeSequencePlayList(songs)); 20 | // musicAnimation(e.nativeEvent.clientX, e.nativeEvent.clientY); 21 | }; 22 | const renderSongList = () => { 23 | return ( 24 | 25 | {songs.map((item, i) => { 26 | return ( 27 |
  • selectItem(e, i)}> 28 | {i + 1} 29 |
    30 | {item.name} 31 | 32 | {item.ar ? getName(item.ar) : getName(item.artists)} -{" "} 33 | {item.al ? item.al.name : item.album.name} 34 | 35 |
    36 |
  • 37 | ); 38 | })} 39 |
    40 | ); 41 | }; 42 | return ( 43 | 44 |
    45 |
    selectItem(e, 0)}> 46 | 47 | 48 | 播放全部 (共{songs.length}首) 49 | 50 |
    51 |
    52 | {renderSongList()} 53 |
    54 | ); 55 | } 56 | 57 | export default React.memo(SongsList); 58 | -------------------------------------------------------------------------------- /src/app/home/singer/components/top-header.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { memo } from "react"; 4 | import { useSelector, useDispatch } from "react-redux"; 5 | import { categoryTypes, alphaTypes } from "@/api/config"; 6 | import useSingerMutation from "@/hooks/useSingerMutation"; 7 | import { 8 | changeCategory, 9 | changeAlpha, 10 | changeEnterLoading, 11 | } from "@/store/slices/singers"; 12 | import { NavContainer } from "../style"; 13 | import Horizen from "./horizen-item"; 14 | function TopHeader() { 15 | const data = useSelector((state) => state.singers.singerDes); 16 | const dispatch = useDispatch(); 17 | const { category, alpha } = data; 18 | const { changeSinger } = useSingerMutation(); 19 | 20 | const handleUpdateCategory = async (newVal) => { 21 | if (category === newVal) return; 22 | dispatch(changeEnterLoading(true)); 23 | dispatch(changeCategory(newVal)); 24 | changeSinger(newVal, alpha, 0); 25 | // scrollRef.current.refresh(); 26 | }; 27 | 28 | const handleUpdateAlpha = (newVal) => { 29 | if (alpha === newVal) return; 30 | dispatch(changeEnterLoading(true)); 31 | dispatch(changeAlpha(newVal)); 32 | changeSinger(category, newVal, 0); 33 | // scrollRef.current.refresh(); 34 | }; 35 | return ( 36 | 37 | handleUpdateCategory(v)} 41 | oldVal={category} 42 | > 43 | handleUpdateAlpha(v)} 47 | oldVal={alpha} 48 | > 49 | 50 | ); 51 | } 52 | export default memo(TopHeader); 53 | -------------------------------------------------------------------------------- /src/app/home/singer/page.jsx: -------------------------------------------------------------------------------- 1 | import { API_BASE_URL } from "@/api/config"; 2 | import TopHeader from "./components/top-header"; 3 | import Main from "./components/main"; 4 | 5 | export default async function Singer() { 6 | const getData = async (count = 1) => { 7 | const res = await fetch(`${API_BASE_URL}/top/artists?offset=${count}`); 8 | if (!res.ok) { 9 | throw new Error("Failed to fetch data"); 10 | } 11 | return res.json(); 12 | }; 13 | const data = await getData(); 14 | return ( 15 |
    16 | 17 |
    18 |
    19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/home/singer/style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import style from "@/assets/global-style"; 3 | 4 | export const NavContainer = styled.div` 5 | box-sizing: border-box; 6 | position: fixed; 7 | top: 95px; 8 | width: 100%; 9 | padding: 5px; 10 | overflow: hidden; 11 | display: flex; 12 | flex-direction: column; 13 | `; 14 | 15 | export const ListContainer = styled.div` 16 | position: fixed; 17 | top: 160px; 18 | left: 0; 19 | bottom: ${(props) => (props.play ? "60px" : 0)}; 20 | overflow: hidden; 21 | width: 100%; 22 | `; 23 | 24 | export const List = styled.div` 25 | display: flex; 26 | margin: auto; 27 | flex-direction: column; 28 | overflow: hidden; 29 | .title { 30 | margin: 10px 0 10px 10px; 31 | color: ${style["font-color-desc"]}; 32 | font-size: ${style["font-size-s"]}; 33 | } 34 | `; 35 | export const ListItem = styled.div` 36 | box-sizing: border-box; 37 | display: flex; 38 | flex-direction: row; 39 | margin: 0 5px; 40 | padding: 5px 0; 41 | align-items: center; 42 | border-bottom: 1px solid ${style["border-color"]}; 43 | .img_wrapper { 44 | margin-right: 20px; 45 | img { 46 | border-radius: 3px; 47 | width: 50px; 48 | height: 50px; 49 | } 50 | } 51 | .name { 52 | font-size: ${style["font-size-m"]}; 53 | color: ${style["font-color-desc"]}; 54 | font-weight: 500; 55 | } 56 | `; 57 | export const EnterLoading = styled.div` 58 | position: fixed; 59 | left: 0; 60 | right: 0; 61 | top: 0; 62 | bottom: 0; 63 | width: 100px; 64 | height: 100px; 65 | margin: auto; 66 | `; 67 | 68 | export const SongList = styled.div` 69 | border-radius: 10px; 70 | background: #fff; 71 | ${(props) => 72 | props.showBackground 73 | ? `background: ${style["highlight-background-color"]}` 74 | : ""}; 75 | .first_line { 76 | box-sizing: border-box; 77 | padding: 10px 0; 78 | margin-left: 10px; 79 | position: relative; 80 | justify-content: space-between; 81 | border-bottom: 1px solid ${style["border-color"]}; 82 | .play_all { 83 | display: flex; 84 | align-items: center; 85 | line-height: 24px; 86 | color: ${style["font-color-desc"]}; 87 | .svg-class { 88 | margin-right: 10px; 89 | } 90 | .iconfont { 91 | font-size: 24px; 92 | margin-right: 10px; 93 | vertical-align: top; 94 | } 95 | .sum { 96 | font-size: ${style["font-size-s"]}; 97 | color: ${style["font-color-desc-v2"]}; 98 | } 99 | > span { 100 | vertical-align: top; 101 | } 102 | } 103 | .add_list, 104 | .isCollected { 105 | display: flex; 106 | align-items: center; 107 | position: absolute; 108 | right: 0; 109 | top: 0; 110 | bottom: 0; 111 | width: 130px; 112 | line-height: 34px; 113 | background: ${style["theme-color"]}; 114 | color: ${style["font-color-light"]}; 115 | font-size: 0; 116 | border-radius: 3px; 117 | vertical-align: top; 118 | .iconfont { 119 | vertical-align: top; 120 | font-size: 10px; 121 | margin: 0 5px 0 10px; 122 | } 123 | span { 124 | font-size: 14px; 125 | line-height: 34px; 126 | } 127 | } 128 | .isCollected { 129 | display: flex; 130 | background: ${style["background-color"]}; 131 | color: ${style["font-color-desc"]}; 132 | } 133 | } 134 | `; 135 | export const SongItem = styled.ul` 136 | > li { 137 | display: flex; 138 | height: 60px; 139 | align-items: center; 140 | .index { 141 | flex-basis: 60px; 142 | width: 60px; 143 | height: 60px; 144 | line-height: 60px; 145 | text-align: center; 146 | } 147 | .info { 148 | box-sizing: border-box; 149 | flex: 1; 150 | display: flex; 151 | height: 100%; 152 | padding: 5px 0; 153 | flex-direction: column; 154 | justify-content: space-around; 155 | border-bottom: 1px solid ${style["border-color"]}; 156 | ${style.noWrap()} 157 | >span { 158 | ${style.noWrap()} 159 | } 160 | > span:first-child { 161 | color: ${style["font-color-desc"]}; 162 | } 163 | > span:last-child { 164 | font-size: ${style["font-size-s"]}; 165 | color: #bba8a8; 166 | } 167 | } 168 | } 169 | `; 170 | -------------------------------------------------------------------------------- /src/app/home/style.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import styled from "styled-components"; 3 | 4 | export const Content = styled.div` 5 | position: fixed; 6 | top: 94px; 7 | left: 0; 8 | bottom: ${(props) => (props.play > 0 ? "60px" : 0)}; 9 | width: 100%; 10 | `; 11 | -------------------------------------------------------------------------------- /src/app/layout.jsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import ReduxProvider from "./ReduxProvider"; 3 | import StyledComponentsRegistry from "./registry"; 4 | import { Providers } from "./providers"; 5 | 6 | export default function RootLayout({ children }) { 7 | return ( 8 | // suppressHydrationWarning用于在客户端与服务器端渲染(SSR)内容不一致时,抑制 hydration 过程中的警告。 9 | 10 | 11 |