├── .gitignore ├── .npmignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src ├── assets │ ├── css │ │ ├── base.css │ │ └── color.css │ ├── iconfont │ │ ├── iconfont.css │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ └── iconfont.woff2 │ └── img │ │ ├── bg-page1.jpg │ │ ├── bg-page2.jpg │ │ ├── pan.png │ │ └── playing.gif ├── common │ ├── Form │ │ └── index.tsx │ ├── lyricBox │ │ ├── index.tsx │ │ └── style.ts │ ├── modal │ │ └── index.tsx │ ├── musicList │ │ ├── index.tsx │ │ └── style.ts │ └── slider │ │ ├── hooks │ │ └── useGetOffset.ts │ │ ├── implement │ │ ├── TimeSlider.tsx │ │ └── VolumeSlider.tsx │ │ ├── index.tsx │ │ └── style.ts ├── components │ ├── Player.tsx │ ├── card │ │ ├── index.tsx │ │ └── style.ts │ └── page │ │ ├── child-components │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ └── Music.tsx │ │ ├── child-views │ │ ├── Mine │ │ │ ├── hooks │ │ │ │ ├── useChangeActiveItem.ts │ │ │ │ └── useGetPlayList.ts │ │ │ └── index.tsx │ │ ├── Playing.tsx │ │ ├── Recommend.tsx │ │ └── Search.tsx │ │ ├── index.tsx │ │ └── style.ts ├── constant │ ├── index.ts │ ├── lyricBox.ts │ └── page.ts ├── hooks │ ├── useAudio.ts │ ├── useCanvas.ts │ ├── useLyric.ts │ ├── useMusic.ts │ ├── useScrollToBottom.ts │ ├── useStorage.ts │ └── useSwrDispatch.ts ├── main.tsx ├── service │ ├── config.ts │ ├── index.ts │ ├── music │ │ └── index.ts │ ├── request │ │ ├── index.ts │ │ └── type.ts │ └── user │ │ ├── index.ts │ │ └── type.ts ├── store │ ├── index.ts │ ├── music │ │ ├── asyncAction.ts │ │ ├── index.ts │ │ └── types.ts │ └── user │ │ ├── asyncAction.ts │ │ ├── index.ts │ │ └── types.ts ├── utils │ ├── curried.ts │ ├── debounce.ts │ ├── getOffset.ts │ └── index.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── windi.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index.html 3 | src 4 | tsconfig.json 5 | tsconfig.node.json 6 | vite.config.ts 7 | windi.config.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wjj-player 2 | 一个用react+vite+ts+windicss开发的音乐播放器组件,意在让每一个react项目都快速具备音乐播放功能,快来试试吧~ 3 | 4 | ## 使用方法 5 | 6 | - 安装 7 | 8 | ```js 9 | npm i wjj-player 10 | ``` 11 | 12 | - 在入口文件中引入css 13 | 14 | ```js 15 | import 'wjj-player/dist/style.css' 16 | ``` 17 | 18 | - 任意一个组件中,导入`Player`并使用 19 | 20 | ```tsx 21 | import React from 'react' 22 | import { Player } from 'wjj-player' 23 | function App() { 24 | return ( 25 |
26 | 27 |
28 | ) 29 | } 30 | 31 | export default App 32 | ``` 33 | 34 | - 功能 35 | 36 | 目前支持切换音乐,音量控制,播放进度控制等,后续会继续对这个插件进行拓展,也会考虑开发vue的版本 37 | 38 | ~~tips:最近网易云云月接口增加了验证,使用起来可能有问题,如果解决了会将这行删掉~~ 39 | 40 | ## 总览 41 | 42 | 刚进入页面时是一个小的音乐唱片在左下角,如下所示 43 | 44 | ![](https://img.jzsp66.xyz/github/1.png) 45 | 46 | 点击唱片之后可以显示音乐卡片,音乐卡片是有切换音乐、控制音量,拖拽进度条以及暂停等基本功能的。 47 | 48 | ![](https://img.jzsp66.xyz/github/2.png) 49 | 50 | 点击卡片右上角的更多按钮之后,可以进入主页面,有搜索歌曲功能和每日推荐的板块。更多内容正在加紧开发中... 51 | 52 | ![](https://img.jzsp66.xyz/github/3.png) 53 | 54 | 也可以选择我的歌单,如下所示 55 | 56 |

57 | 58 |

59 | 60 | 同时也进行了一些手机端适配(暂时没想到怎么处理歌词,就把他隐藏啦) 61 | 62 |

63 | 64 |

65 | 66 | 67 | 68 | 69 | 70 | ## 项目亮点 71 | 72 | 1、封装了滚动歌词的组件`lyricBox`,会根据当前播放进度,将对应歌词滚动到容器中间 73 | 74 | 2、封装了滚动条组件`Slider`,支持拖动和点击修改进度 75 | 76 | 3、使用portal封装了对话框组件`Modal`,同时该组件也支持函数式调用 77 | 78 | 4、使用`vite打包`成库,安装方便,即插即用 79 | 80 | 5、使用canvas获取背景图的平均RGB值,动态修改颜色防止背景图和文字颜色混杂 81 | 82 | 6、对一些数据进行了持久化处理 83 | 84 | 85 | ## TODO 86 | 87 | 1、代码优化(第一个react项目,代码逻辑可能抽离的不是很好) 88 | 89 | 2、手机端适配(虽然用了windi做了媒体查询,但是有的细节还是没做好) 90 | 91 | 3、样式优化 92 | 93 | 4、suspense(管理请求) 94 | 95 | 5、登录功能(已完成,输入uid即可) -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wjj-player", 3 | "version": "1.1.2", 4 | "private": false, 5 | "main": "./dist/index.umd.js", 6 | "module": "./dist/index.es.js", 7 | "types": "./dist/src/main.d.ts", 8 | "keywords": [ 9 | "react", 10 | "music", 11 | "music-player" 12 | ], 13 | "author": "hnustwjj", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/hnustwjj/wjj-music" 17 | }, 18 | "scripts": { 19 | "dev": "vite", 20 | "build": "vite build && npm run type", 21 | "preview": "vite preview", 22 | "type": "tsc -d" 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "peerDependencies": { 28 | "axios": ">=0.24.0", 29 | "react": ">=16.9.0", 30 | "react-dom": ">=16.9.0", 31 | "styled-components": ">=4.0.0", 32 | "swr": "^1.3.0" 33 | }, 34 | "devDependencies": { 35 | "@reduxjs/toolkit": "^1.8.2", 36 | "@types/node": "^17.0.35", 37 | "@types/react": "^18.0.0", 38 | "@types/react-dom": "^18.0.0", 39 | "@types/styled-components": "^5.1.25", 40 | "@vitejs/plugin-react": "^1.3.0", 41 | "@windicss/plugin-scrollbar": "^1.2.3", 42 | "axios": "^0.27.2", 43 | "react": "^18.0.0", 44 | "react-dom": "^18.0.0", 45 | "react-redux": "^8.0.2", 46 | "styled-components": "^5.3.5", 47 | "typescript": "^4.6.3", 48 | "vite": "^2.9.9", 49 | "vite-plugin-windicss": "^1.8.4", 50 | "windicss": "^3.5.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/assets/css/base.css: -------------------------------------------------------------------------------- 1 | @import "../iconfont/iconfont.css"; 2 | @import "./color.css"; 3 | 4 | .bg-pan { 5 | background: url(../img/pan.png); 6 | } 7 | 8 | .bg-page1 { 9 | z-index: -5; 10 | background: url(../img/bg-page1.jpg); 11 | background-repeat: no-repeat; 12 | background-position: 50%; 13 | filter: blur(12px); 14 | opacity: .7; 15 | transform: scale(1.1); 16 | background-size: cover; 17 | } 18 | 19 | .bg-page2 { 20 | z-index: -5; 21 | background-size: cover; 22 | background: url(../img/bg-page2.jpg); 23 | background-repeat: no-repeat; 24 | background-position: 50%; 25 | filter: blur(12px); 26 | opacity: .7; 27 | transform: scale(1.1); 28 | } 29 | 30 | *{ 31 | @apply select-none; 32 | } 33 | 34 | .bg-blue{ 35 | background:var(--blue) !important; 36 | } 37 | 38 | 39 | .transition{ 40 | transition:all .3s; 41 | } 42 | * { 43 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, 44 | Helvetica Neue, Lato, Roboto, PingFang SC, Microsoft YaHei, 45 | sans-serif !important; 46 | } 47 | /* 自定义滚动条样式 */ 48 | ::-webkit-scrollbar { 49 | width: 4px; 50 | } 51 | ::-webkit-scrollbar-thumb { 52 | background: transparent; 53 | border-radius: 2px; 54 | } 55 | ::-webkit-scrollbar-track { 56 | background: hsla(0,0%,53.3%,.1); 57 | } 58 | ::-webkit-scrollbar-thumb { 59 | background: hsla(0,0%,53.3%,.4); 60 | } 61 | 62 | .overflow-two-line { 63 | display:-webkit-box; 64 | -webkit-box-orient:vertical; 65 | -webkit-line-clamp:2; 66 | overflow:hidden; 67 | text-overflow:ellipsis; 68 | margin:5px; 69 | } 70 | .line-clamp-3{ 71 | overflow: hidden; 72 | display: -webkit-box; 73 | -webkit-box-orient: vertical; 74 | -webkit-line-clamp: 3; 75 | } 76 | 77 | .shadow-light{ 78 | box-shadow: 0 0 10px rgba(255, 255, 255, .15); 79 | } 80 | .close{ 81 | position:relative; 82 | width: 15px; 83 | height: 15px; 84 | cursor: pointer; 85 | transition:all .5s; 86 | } 87 | .close:hover{ 88 | transform: rotate(180deg) scale(1.1); 89 | 90 | } 91 | .close::before{ 92 | display: block; 93 | content:''; 94 | width: 100%; 95 | height: 2px; 96 | background: #fff; 97 | position:absolute; 98 | top:50%; 99 | transform: rotate(45deg); 100 | } 101 | .close::after{ 102 | display: block; 103 | content:''; 104 | width: 100%; 105 | height: 2px; 106 | background: #fff; 107 | position:absolute; 108 | top:50%; 109 | transform: rotate(-45deg); 110 | } -------------------------------------------------------------------------------- /src/assets/css/color.css: -------------------------------------------------------------------------------- 1 | 2 | html{ 3 | /* css原生也支持变量的设置 */ 4 | --white:white; 5 | --icon: white; 6 | --font:white; 7 | 8 | --deactive-color:rgba(235,235,235,0.7); 9 | --active-color:white; 10 | 11 | --song:white; 12 | --singer:white; 13 | 14 | --lyric:rgb(209, 213, 219); 15 | --lyric-active:#fff; 16 | 17 | --slider-current:#1cc3ec; 18 | --slider-buffer:rgb(219, 216, 216); 19 | --slider-all: #918f8f; 20 | 21 | --card-height:330px; 22 | --card-width:300px; 23 | 24 | --pan-index:52; 25 | --card-index:51; 26 | --page-index:50; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "iconfont"; /* Project id 3405053 */ 3 | src: url('iconfont.woff2?t=1654084091529') format('woff2'), 4 | url('iconfont.woff?t=1654084091529') format('woff'), 5 | url('iconfont.ttf?t=1654084091529') format('truetype'); 6 | } 7 | 8 | .iconfont { 9 | font-family: "iconfont" !important; 10 | font-size: 16px; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .icon-single:before { 17 | content: "\e607"; 18 | } 19 | 20 | .icon-random:before { 21 | content: "\e6a0"; 22 | } 23 | 24 | .icon-cycle:before { 25 | content: "\e70b"; 26 | } 27 | 28 | .icon-play:before { 29 | content: "\e87c"; 30 | margin-left: 5px; 31 | } 32 | 33 | .icon-pre:before { 34 | content: "\e603"; 35 | } 36 | 37 | .icon-next:before { 38 | content: "\e602"; 39 | } 40 | 41 | .icon-pause:before { 42 | content: "\ea81"; 43 | } 44 | 45 | .icon-gengduo:before { 46 | content: "\e719"; 47 | } 48 | 49 | .icon-jingyin:before { 50 | content: "\e747"; 51 | } 52 | 53 | .icon-left:before { 54 | content: "\e628"; 55 | } 56 | 57 | .icon-right:before { 58 | content: "\e642"; 59 | } 60 | 61 | .icon-laba:before { 62 | content: "\e600"; 63 | } 64 | 65 | -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnustwjj/wjj-music/45a52ba767c3543033fbedc566dd1bd9cfc5d491/src/assets/iconfont/iconfont.ttf -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnustwjj/wjj-music/45a52ba767c3543033fbedc566dd1bd9cfc5d491/src/assets/iconfont/iconfont.woff -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnustwjj/wjj-music/45a52ba767c3543033fbedc566dd1bd9cfc5d491/src/assets/iconfont/iconfont.woff2 -------------------------------------------------------------------------------- /src/assets/img/bg-page1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnustwjj/wjj-music/45a52ba767c3543033fbedc566dd1bd9cfc5d491/src/assets/img/bg-page1.jpg -------------------------------------------------------------------------------- /src/assets/img/bg-page2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnustwjj/wjj-music/45a52ba767c3543033fbedc566dd1bd9cfc5d491/src/assets/img/bg-page2.jpg -------------------------------------------------------------------------------- /src/assets/img/pan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnustwjj/wjj-music/45a52ba767c3543033fbedc566dd1bd9cfc5d491/src/assets/img/pan.png -------------------------------------------------------------------------------- /src/assets/img/playing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hnustwjj/wjj-music/45a52ba767c3543033fbedc566dd1bd9cfc5d491/src/assets/img/playing.gif -------------------------------------------------------------------------------- /src/common/Form/index.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useState } from 'react' 2 | 3 | const Form = forwardRef(function Form(props: { data: string[] }, ref) { 4 | const data = props.data 5 | //FIXME:传入泛型(是否可行,或者根绝data的类型来定义,涉及到类型体操) 6 | const [metadata, setMetadata] = useState({}) 7 | useImperativeHandle( 8 | ref, 9 | () => ({ 10 | metadata, 11 | }), 12 | [metadata] 13 | ) 14 | return ( 15 |
16 | {data.map(item => ( 17 |
18 |
19 | {item} 20 |
21 |
22 | 28 | setMetadata(pre => ({ ...pre, [item]: e.target.value })) 29 | } 30 | /> 31 |
32 |
33 | ))} 34 |
35 | ) 36 | }) 37 | export default Form 38 | -------------------------------------------------------------------------------- /src/common/lyricBox/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | 3 | import { Wrapper } from './style' 4 | import { LYRICLIST_NULL_TEXT } from '@/constant' 5 | interface lyricBox { 6 | currentLyricIndex: number 7 | lyricList: any[] 8 | lyricBoxRef: any 9 | leading?: number 10 | } 11 | //TODO:考虑添加一个drag拖拽歌词功能 12 | const LyricBox = memo((props: lyricBox) => { 13 | //TODO:在未来可能会考虑修改配色,而不是单纯的修改card的遮罩层透明度(主要是我个人CSS变量管理的不好) 14 | // const RGB = useContext(RGBContext) 15 | 16 | // 获取歌词相关信息的hook 17 | const { currentLyricIndex, lyricList, lyricBoxRef, leading } = props 18 | //样式类名 19 | const pClass = (index: number) => 20 | (currentLyricIndex === index ? 'active-lyric' : undefined) + ' transition' 21 | //样式对象 22 | const style = { padding: `${leading ?? 5}px 0` } 23 | return ( 24 | 25 | {lyricList.length ? ( 26 | lyricList.map((item, index) => ( 27 |

34 | {item.content} 35 |

36 | )) 37 | ) : ( 38 |
{LYRICLIST_NULL_TEXT}
39 | )} 40 |
41 | ) 42 | }) 43 | 44 | export default LyricBox 45 | -------------------------------------------------------------------------------- /src/common/lyricBox/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | export const Wrapper = styled.div` 3 | .active-lyric { 4 | font-weight: 600; 5 | color: var(--lyric-active); 6 | transform: scale(1.15); 7 | } 8 | ` 9 | -------------------------------------------------------------------------------- /src/common/modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { createRef, memo, PropsWithChildren } from 'react' 2 | import { createPortal } from 'react-dom' 3 | import ReactDomClient from 'react-dom/client' 4 | import Form from '../Form' 5 | 6 | //创建modal要存放的container 7 | const container = document.createElement('div') 8 | container.id = 'dialog-container' 9 | // 样式 10 | container.className = 11 | 'fixed top-0 left-0 transition bottom-0 right-0 bg-[rgba(0,0,0,.5)]' 12 | // 初始化样式 13 | container.style.zIndex = '-1' 14 | container.style.opacity = '0' 15 | document.body.appendChild(container) 16 | 17 | interface ModalProps { 18 | title?: string 19 | visible?: boolean 20 | onOk?: () => void 21 | onCancel?: () => void 22 | callback?: () => void 23 | } 24 | const Modal = memo((props: PropsWithChildren) => { 25 | const { children, title, visible, onCancel, onOk, callback } = props 26 | // 创建每一个Modal的容器,添加到container中 27 | const innerContainer = document.createElement('div') 28 | container.appendChild(innerContainer) 29 | // 初始化样式 30 | function switchStyle(show: boolean) { 31 | if (show) { 32 | container.style.zIndex = '99' 33 | container.style.opacity = '1' 34 | } else { 35 | container.style.zIndex = '-1' 36 | container.style.opacity = '0' 37 | container.innerHTML = '' 38 | } 39 | } 40 | // 初始化 41 | switchStyle(visible ?? true) 42 | const onCallback = (type: 'ok' | 'cancel') => { 43 | type === 'cancel' ? onCancel && onCancel() : onOk && onOk() 44 | // 点击的时候,container切换为false的样式 45 | switchStyle(false) 46 | setTimeout(() => { 47 | callback && callback() 48 | }, 300) 49 | } 50 | 51 | return createPortal( 52 |
53 |
54 |
{(title ?? '标题') + ' : -) '}
55 |
onCallback('cancel')} /> 56 |
57 |
58 | {Array.isArray(children) ? children?.map(item => item) : children} 59 |
60 |
61 | 67 | 73 |
74 |
, 75 | innerContainer 76 | ) 77 | }) 78 | 79 | // onOk和onCancel是为了让confirm链式调用的时候有动画 80 | export function confirm(props?: PropsWithChildren) { 81 | return new Promise((resolve, reject) => { 82 | const innerContainer = document.createElement('div') 83 | const root = ReactDomClient.createRoot(innerContainer) 84 | root.render( 85 | root.unmount()} 88 | onOk={() => setTimeout(() => resolve(null), 300)} 89 | onCancel={() => setTimeout(() => reject(null), 300)} 90 | {...props} 91 | /> 92 | ) 93 | container.appendChild(innerContainer) 94 | }) 95 | } 96 | 97 | export function alert(data: string[], props?: PropsWithChildren) { 98 | return new Promise((resolve, reject) => { 99 | const innerContainer = document.createElement('div') 100 | const root = ReactDomClient.createRoot(innerContainer) 101 | const ref = createRef() 102 | root.render( 103 | root.unmount()} 106 | onOk={() => setTimeout(() => resolve(ref.current.metadata), 300)} 107 | onCancel={() => setTimeout(() => reject(null), 300)} 108 | {...props} 109 | > 110 |
111 | try: 119151330 112 |
113 |
114 | 115 | ) 116 | }) 117 | } 118 | export default Modal 119 | -------------------------------------------------------------------------------- /src/common/musicList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect } from 'react' 2 | import { useAppSelector } from '@/store' 3 | 4 | import img from '@/assets/img/playing.gif' 5 | import { SingerSpan } from './style' 6 | import { formatTime } from '@/utils' 7 | import { MusicListItem } from '@/store/music/types' 8 | import { LIST_NULL_TEXT } from '@/constant' 9 | import useScrollToBottom from '@/hooks/useScrollToBottom' 10 | 11 | interface MusicList { 12 | source: MusicListItem[] 13 | callback?: () => void 14 | rowClick?: (item: MusicListItem) => void 15 | rowDoubleClick?: (item: MusicListItem) => void 16 | deleteClick?: (item: MusicListItem) => void 17 | } 18 | let timer: any = null 19 | const MusicList = memo((props: MusicList) => { 20 | const music = useAppSelector(state => state.music) 21 | const { rowClick, source, rowDoubleClick, deleteClick, callback } = props 22 | 23 | const { scrollRef, scrollToBottom } = useScrollToBottom() 24 | 25 | useEffect(() => { 26 | callback && callback() 27 | }, [scrollToBottom]) 28 | 29 | const { currentMusic } = music 30 | 31 | // 单击row触发事件 32 | const single = (item: MusicListItem) => { 33 | clearTimeout(timer) // 清除第二次单击事件 34 | timer = setTimeout(() => { 35 | rowClick && rowClick(item) 36 | }, 200) 37 | } 38 | 39 | // 双击row触发事件 40 | const double = (item: MusicListItem) => { 41 | clearTimeout(timer) // 清除第一次单击事件 42 | rowDoubleClick && rowDoubleClick(item) 43 | } 44 | 45 | //点击delete按钮触发事件 46 | const deleteFn = (e: Event, item: MusicListItem) => { 47 | e.stopPropagation() 48 | //TODO:删除的提示 49 | deleteClick && deleteClick(item) 50 | } 51 | 52 | return ( 53 | <> 54 | {source.length ? ( 55 |
56 |
63 | 64 | 歌曲 65 | 歌手 66 | 时长 67 |
68 |
69 | {source.map((item, index) => ( 70 |
single(item)} 79 | onDoubleClick={() => double(item)} 80 | > 81 | {/* 序号 */} 82 | 83 | {currentMusic.id === item.id ? : index + 1} 84 | 85 | {/* 歌名 */} 86 | 87 | {item.name} 88 | {deleteClick !== undefined ? ( 89 | deleteFn(e, item)} /> 90 | ) : null} 91 | 92 | {/* 歌手 */} 93 | {item.ar && item.ar[0].name} 94 | {/* 时常 */} 95 | {formatTime(item.dt ?? 0)} 96 |
97 | ))} 98 |
99 |
100 | ) : ( 101 |
102 | {LIST_NULL_TEXT} 103 |
104 | )} 105 | 106 | ) 107 | }) 108 | 109 | export default MusicList 110 | -------------------------------------------------------------------------------- /src/common/musicList/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | export const SingerSpan = styled.span` 3 | flex: 5; 4 | position: relative; 5 | .delete { 6 | width: 20px; 7 | height: 20px; 8 | position: absolute; 9 | right: 20px; 10 | top: 50%; 11 | border-radius: 50%; 12 | border: 2px solid white; 13 | transform: translateY(-50%) rotate(45deg); 14 | opacity: 0; 15 | transition: opacity 0.3s; 16 | &::before { 17 | content: ''; 18 | width: 50%; 19 | height: 2px; 20 | background-color: white; 21 | position: absolute; 22 | top: 50%; 23 | left: 50%; 24 | transform: translate(-50%, -50%); 25 | } 26 | &::after { 27 | content: ''; 28 | width: 2px; 29 | height: 50%; 30 | background-color: white; 31 | position: absolute; 32 | top: 50%; 33 | left: 50%; 34 | transform: translate(-50%, -50%); 35 | } 36 | } 37 | &:hover .delete { 38 | opacity: 1; 39 | } 40 | ` 41 | -------------------------------------------------------------------------------- /src/common/slider/hooks/useGetOffset.ts: -------------------------------------------------------------------------------- 1 | import { getElementLeft, getElementTop } from '@/utils/getOffset' 2 | //返回获取偏移量的函数 3 | export default function useGetOffset(lineRef: React.RefObject) { 4 | /** 5 | * 返回鼠标在进度条上的左侧或垂直方向的偏移量百分比 6 | * @param e 7 | * @returns 百分比 8 | */ 9 | const getOffset = (e: MouseEvent, direction: 'col' | 'row'): number => { 10 | // 鼠标点击时距离屏幕左侧的偏移量 11 | const clickOffset = direction === 'row' ? e.clientX : e.clientY 12 | 13 | // 进度条长度 14 | const allLength = 15 | direction === 'row' 16 | ? lineRef.current?.clientWidth ?? 0 17 | : lineRef.current?.clientHeight ?? 0 18 | 19 | // 进度条距离屏幕左侧的偏移量 20 | let parentOffset = 0 21 | 22 | if (lineRef.current) { 23 | // 父亲结点(此时是最外层结点)的 24 | parentOffset = 25 | direction === 'row' 26 | ? getElementLeft(lineRef.current) 27 | : getElementTop(lineRef.current) 28 | } 29 | 30 | let offset = clickOffset - parentOffset 31 | 32 | if (offset < 0) { 33 | offset = 0 34 | } 35 | 36 | if (offset > allLength) { 37 | offset = allLength 38 | } 39 | 40 | // 点击改变进度条的时候也要设置current百分比 41 | let current = allLength === 0 ? 0 : offset / allLength 42 | 43 | // 如果是竖着的Slider,那么百分比需要1-current,如果不知道为啥,可以将下面这行代码注释掉后看看效果qwq 44 | current = direction === 'col' ? 1 - current : current 45 | return current 46 | } 47 | return getOffset 48 | } 49 | -------------------------------------------------------------------------------- /src/common/slider/implement/TimeSlider.tsx: -------------------------------------------------------------------------------- 1 | import Slider from '@/common/slider' 2 | import { useMemo } from 'react' 3 | import { formatTime } from '@/utils' 4 | import type { IAudio } from '@/hooks/useAudio' 5 | import type { ILyric } from '@/hooks/useLyric' 6 | 7 | let preVolume = 0 8 | /** 9 | * 用于返回已经实现的时间和音量的Slider 10 | * @param audioInfo 音频信息 11 | * @param lyricInfo 歌词信息 12 | * @returns 13 | */ 14 | export default function (audioInfo: IAudio, lyricInfo: ILyric) { 15 | const { audioRef, duration, bufferPercent, currentTime } = audioInfo 16 | // 时间百分比 17 | const timePercent = currentTime / duration 18 | // 时间进度条改变事件 19 | const onTimeSliderChange = (percent: number) => { 20 | const time = (duration * percent).toFixed() 21 | lyricInfo.updateTime(time, true) 22 | if (audioRef.current) { 23 | audioRef.current.currentTime = parseInt(time) / 1000 24 | } 25 | } 26 | 27 | const TimeSlider = useMemo( 28 | () => ( 29 | { 33 | // 在拖动进度条时,音量设置为0 34 | if (audioRef.current) { 35 | preVolume = audioRef.current.volume 36 | audioRef.current.volume = 0 37 | } 38 | }} 39 | onMouseUp={() => { 40 | // 拖动进度条结束时,恢复音量 41 | if (audioRef.current) audioRef.current.volume = preVolume 42 | }} 43 | setValue={percent => onTimeSliderChange(percent)} 44 | value={timePercent} 45 | slot={formatTime(currentTime) + ' / ' + formatTime(duration)} 46 | /> 47 | ), 48 | [onTimeSliderChange, timePercent] 49 | ) 50 | 51 | return TimeSlider 52 | } 53 | -------------------------------------------------------------------------------- /src/common/slider/implement/VolumeSlider.tsx: -------------------------------------------------------------------------------- 1 | import Slider from '@/common/slider' 2 | import { useMemo } from 'react' 3 | 4 | import type { IAudio } from '@/hooks/useAudio' 5 | 6 | /** 7 | * 用于返回已经实现的时间和音量的Slider 8 | * @param audioInfo 音频信息 9 | * @returns 10 | */ 11 | export default function (audioInfo: IAudio) { 12 | // 音量进度条改变事件 13 | const onVolumeliderChange = (percent: number) => { 14 | audioInfo.setVolume(percent) 15 | } 16 | 17 | const VolumeSlider = useMemo( 18 | () => ( 19 | onVolumeliderChange(percent)} 23 | /> 24 | ), 25 | [onVolumeliderChange] 26 | ) 27 | return VolumeSlider 28 | } 29 | -------------------------------------------------------------------------------- /src/common/slider/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useRef, useState, useEffect } from 'react' 2 | import useGetOffset from './hooks/useGetOffset' 3 | import DivWrapper from './style' 4 | /** 5 | * setValue:回调函数,在拖动进度条或者点击进度条导致发生改变时会执行 6 | * bufferValue:可选,传入百分比,设置进度条的缓冲长度 7 | * value:外部指定的宽度百分比 8 | */ 9 | type SliderProps = { 10 | direction: 'col' | 'row' 11 | value: number 12 | bufferValue?: number 13 | slot?: string 14 | setValue: (current: number) => void 15 | onMouseDown?: () => void 16 | onMouseUp?: () => void 17 | onMouseMove?: () => void 18 | } 19 | //TODO:解决拖动时Maximun update的问题 20 | const Slider = memo((props: SliderProps) => { 21 | // value是传入的进度条百分比 22 | const { value, direction, setValue, bufferValue, slot } = props 23 | 24 | // value对应的进度条长度 25 | const [currentLength, setCurrentLength] = useState(0) 26 | 27 | // 实际进度条的div 28 | const lineRef = useRef(null) 29 | 30 | // 传入的value改变时,修改进度条宽度的副作用 31 | useEffect(() => { 32 | if (lineRef.current) { 33 | if (value !== undefined) { 34 | setCurrentLength( 35 | value * 36 | (direction === 'row' 37 | ? lineRef.current.clientWidth //获取宽度 38 | : lineRef.current.clientHeight) //获取高度 39 | ) 40 | } 41 | } 42 | }, [value]) 43 | // 返回获取偏移量百分比的函数 44 | const getOffset = useGetOffset(lineRef) 45 | 46 | // 是否松开鼠标 47 | let status = false 48 | /** 49 | * 鼠标按下的回调,添加监听事件,修改进度条长度 50 | */ 51 | const mouseDown = () => { 52 | status = true 53 | if (props.onMouseDown) props.onMouseDown() 54 | document.onmousemove = (e: any) => { 55 | if (props.onMouseMove) props.onMouseMove() 56 | if (status) { 57 | setValue(getOffset(e, direction)) 58 | } 59 | } 60 | document.onmouseup = () => { 61 | if (props.onMouseUp) props.onMouseUp() 62 | status = false 63 | document.onmousemove = null 64 | } 65 | } 66 | 67 | const widthOrHeight = (length, isPercent = false) => { 68 | if (isPercent && lineRef.current) { 69 | length = 70 | length * 71 | (direction === 'row' 72 | ? lineRef.current.clientWidth //获取宽度 73 | : lineRef.current.clientHeight) //获取高度 74 | } 75 | return direction === 'col' 76 | ? { height: length } 77 | : { width: isNaN(length) ? 0 : length } 78 | } 79 | 80 | return ( 81 | setValue(getOffset(e, direction))} 85 | > 86 |
95 | {slot} 96 |
97 | {/* 播放进度条 */} 98 |
105 | {/* 加载进度条 */} 106 |
113 | {/* 圆圈按钮 */} 114 |
mouseDown()} 127 | onClick={e => e.stopPropagation()} 128 | > 129 |
130 |
131 | 132 | ) 133 | }) 134 | 135 | export default Slider 136 | -------------------------------------------------------------------------------- /src/common/slider/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | export default styled.div` 3 | border-radius: 9999px; 4 | position: relative; 5 | cursor: pointer; 6 | background: var(--slider-all); 7 | &.col { 8 | width: 5px; 9 | height: 95%; 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | transform: rotate(-180deg); 14 | } 15 | 16 | &.row { 17 | width: 95%; 18 | height: 5px; 19 | display: flex; 20 | flex-direction: row; 21 | align-items: center; 22 | } 23 | 24 | .button { 25 | div { 26 | transition: all 0.5s; 27 | } 28 | &:hover > div { 29 | width: 10px; 30 | height: 10px; 31 | } 32 | } 33 | .line { 34 | transition: none; 35 | } 36 | ` 37 | -------------------------------------------------------------------------------- /src/components/Player.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, memo, useState } from 'react' 2 | import { Provider } from 'react-redux' 3 | import Card from './card' 4 | import Page from './page' 5 | 6 | import useMusicInfo from '../hooks/useMusic' 7 | import useLyric from '../hooks/useLyric' 8 | import useAudio from '../hooks/useAudio' 9 | import store from '@/store' 10 | import getImpTimeSlider from '../common/slider/implement/TimeSlider' 11 | import getImpVolumeSlider from '../common/slider/implement/VolumeSlider' 12 | import useCanvas from '@/hooks/useCanvas' 13 | import { createPortal } from 'react-dom' 14 | 15 | export const RGBContext = createContext({ 16 | R: 0, 17 | G: 0, 18 | B: 0, 19 | average: 0, 20 | }) 21 | const Player = memo(() => { 22 | // page是否显示 23 | const [pageActive, setPageActive] = useState(false) 24 | // 获取音乐信息的Hook 25 | const musicInfo = useMusicInfo() 26 | // 获取歌词信息的Hook 27 | const lyricInfo = useLyric() 28 | // 为了控制page页面的LyricBox,需要两个LyricBoxRef,所以再调用一次 29 | const lyricInfo2 = useLyric() 30 | // 获取图片RGB平均值 31 | const { CanvasRef, ImgRef, RGB } = useCanvas() 32 | // 获取音频信息的Hook 33 | const audioInfo = useAudio() 34 | const { audioRef, canplay, audioTimeUpdate, onEnd, onError } = audioInfo 35 | // 获取时间进度条 36 | const TimeSlider = getImpTimeSlider(audioInfo, lyricInfo) 37 | // 获取音乐进度条 38 | const VolumeSlider = getImpVolumeSlider(audioInfo) 39 | return createPortal( 40 | 41 |
42 | 49 |
84 |
, 85 | document.getElementById('root') as Element 86 | ) 87 | }) 88 | export default () => }> 89 | -------------------------------------------------------------------------------- /src/components/card/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useContext, useState } from 'react' 2 | 3 | import { imgUrl } from '@/utils' 4 | import LyricBox from '../../common/lyricBox' 5 | import { PanWrapper, CardWrapper } from './style' 6 | import { RGBContext } from '../Player' 7 | 8 | import type { ILyric } from '@/hooks/useLyric' 9 | import type { IMusicInfo } from '../../hooks/useMusic' 10 | import type { IAudio } from '../../hooks/useAudio' 11 | const Card = memo( 12 | (props: { 13 | changePageActive: () => void 14 | TimeSlider: () => JSX.Element 15 | VolumeSlider: () => JSX.Element 16 | musicInfo: IMusicInfo 17 | lyricInfo: ILyric 18 | audioInfo: IAudio 19 | ImgRef: React.RefObject 20 | }) => { 21 | const { 22 | TimeSlider, 23 | changePageActive, 24 | musicInfo, 25 | lyricInfo, 26 | audioInfo, 27 | VolumeSlider, 28 | ImgRef, 29 | } = props 30 | const RGB = useContext(RGBContext) 31 | // 是否点击了pan显示card 32 | const [active, setPanActive] = useState(false) 33 | // 获取音乐信息相关 34 | const { al, singers, name: songName, currentMusic } = musicInfo 35 | // 获取歌词相关信息 36 | const { currentLyricIndex, lyricList, lyricBoxRef } = lyricInfo 37 | // 获取音频相关信息 38 | const { 39 | switchMusicStaus, 40 | isPlaying, 41 | switchMusic, 42 | volume, 43 | changeJingyin, 44 | currentOrder, 45 | } = audioInfo 46 | const BG_STYLE = { 47 | backgroundImage: `url(${imgUrl(300, al?.picUrl)})`, 48 | } 49 | const content = ( 50 | <> 51 |
52 | {/* 歌名 */} 53 |
62 | {songName} 63 |
64 | {/* 歌手 */} 65 |

72 | 歌手:{singers} 73 |

74 | {/* 歌词 */} 75 |
76 | 81 |
82 | {/* 控制栏 */} 83 |
91 |
switchMusicStaus()} 98 | > 99 | 104 |
105 |
106 | {TimeSlider} 107 |
108 |
117 | changeJingyin()} 123 | /> 124 |
134 | {VolumeSlider} 135 |
136 |
137 |
138 |
139 | 140 | {/* 切换歌曲的箭头 */} 141 | switchMusic('pre', currentOrder)} 144 | /> 145 | switchMusic('next', currentOrder)} 148 | /> 149 | 150 | ) 151 | return ( 152 | <> 153 | 154 |
setPanActive(!active)} 161 | > 162 | 167 |
168 |
169 | 173 | {/* 三张背景蒙版 */} 174 | {[5, 2].map(item => ( 175 |
185 | ))} 186 |
160 ? 'bg-[rgba(0,0,0,.35)]' : 'bg-[rgba(0,0,0,.1)]' 189 | } 190 | absolute='~' 191 | rounded='md' 192 | h='full' 193 | w='full' 194 | /> 195 | {!currentMusic.initFlag ? ( 196 | content 197 | ) : ( 198 |
206 | 什么都没有哦~ 207 |
208 | 快点击卡片右上角的按钮~ 209 |
210 | 去选择你喜欢的音乐吧~ 211 |
212 | )} 213 | {/* 更多按钮 */} 214 | changePageActive()} 221 | /> 222 | 223 | 224 | ) 225 | } 226 | ) 227 | 228 | export default Card 229 | -------------------------------------------------------------------------------- /src/components/card/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | export const PanWrapper = styled.div` 3 | z-index: var(--pan-index); 4 | transition: all 0.5s; 5 | position: fixed; 6 | left: 3px; 7 | bottom: 50px; 8 | cursor: pointer; 9 | transform: translateX(-50%); 10 | &.active { 11 | transform: translate(25px, -215px); 12 | } 13 | &:hover.deactive { 14 | transform: translateX(0); 15 | } 16 | 17 | @keyframes cycle { 18 | from { 19 | transform: rotate(0); 20 | } 21 | to { 22 | transform: rotate(360deg); 23 | } 24 | } 25 | .bg-pan { 26 | background-size: 100%; 27 | animation: cycle infinite 10s linear; 28 | &:hover { 29 | box-shadow: 0 0 15px white; 30 | } 31 | &.pause { 32 | /* 暂停动画 */ 33 | animation-play-state: paused; 34 | } 35 | } 36 | ` 37 | 38 | export const CardWrapper = styled.div` 39 | height: var(--card-height); 40 | width: var(--card-width); 41 | position: fixed; 42 | left: 20px; 43 | bottom: 20px; 44 | display: flex; 45 | flex-direction: column; 46 | align-items: center; 47 | border-radius: 0.375rem; 48 | z-index: var(--card-index); 49 | transform: scale(0) translate(-500px, 500px); 50 | opacity: 0; 51 | background-size: cover; 52 | background-repeat: no-repeat; 53 | &.active { 54 | transform: scale(1) translate(0, 0); 55 | opacity: 1; 56 | } 57 | 58 | .arrow { 59 | position: absolute; 60 | top: 50%; 61 | transform: translateY(-50%); 62 | cursor: pointer; 63 | font-size: 20px; 64 | 65 | &:hover { 66 | transform: translateY(-50%) scale(1.2); 67 | } 68 | } 69 | .blur-8px { 70 | filter: blur(8px); 71 | } 72 | .blur-5px { 73 | filter: blur(5px); 74 | } 75 | .blur-2px { 76 | filter: blur(2px); 77 | } 78 | 79 | .volume-slider-hover { 80 | &:hover + div { 81 | opacity: 1; 82 | } 83 | } 84 | ` 85 | -------------------------------------------------------------------------------- /src/components/page/child-components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { IAudio } from '@/hooks/useAudio' 2 | import React, { memo } from 'react' 3 | const Footer = memo( 4 | (props: { 5 | audioInfo: IAudio 6 | TimeSlider: () => JSX.Element 7 | VolumeSlider: () => JSX.Element 8 | }) => { 9 | // 获取音频相关信息 10 | const { 11 | switchMusic, 12 | switchMusicStaus, 13 | changeJingyin, 14 | switchOrder, 15 | isPlaying, 16 | volume, 17 | currentOrder, 18 | } = props.audioInfo 19 | return ( 20 |
21 |
22 |

switchOrder()} 25 | /> 26 |

switchMusic('pre')} 29 | /> 30 |

switchMusicStaus()} 35 | /> 36 |

switchMusic('next')} 39 | /> 40 |

41 | changeJingyin()} 46 | /> 47 |
57 | {props.VolumeSlider} 58 |
59 |
60 |
61 |
62 | {props.TimeSlider} 63 |
64 |
65 | ) 66 | } 67 | ) 68 | 69 | export default Footer 70 | -------------------------------------------------------------------------------- /src/components/page/child-components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useAppDispatch, useAppSelector } from '@/store' 2 | import { getUserInfoAction } from '@/store/user' 3 | import React, { memo, useEffect } from 'react' 4 | import { alert, confirm } from '@/common/modal' 5 | import useStorage from '@/hooks/useStorage' 6 | import { changeUid } from '@/store/user' 7 | const Header = memo(() => { 8 | const dispatch = useAppDispatch() 9 | const { uid, userInfo } = useAppSelector(state => state.user) 10 | useEffect(() => { 11 | dispatch(getUserInfoAction(uid)) 12 | }, [dispatch, uid]) 13 | const storage = useStorage() 14 | // 退出登录 15 | const logout = () => 16 | confirm({ 17 | children:
您确定要退出嘛~
, 18 | title: '提示', 19 | }).then(() => { 20 | storage.removeItem('uid') 21 | dispatch(changeUid(0)) 22 | }) 23 | 24 | // 登录 25 | const login = () => 26 | alert(['uid'], { 27 | title: '提示', 28 | }).then((res: { uid: number }) => { 29 | const uid = res.uid ?? 0 30 | dispatch(changeUid(uid)) 31 | storage.setItem('uid', uid) 32 | }) 33 | return ( 34 |
40 | 44 | 勾勾的音乐组件 45 | 46 |
53 | {uid !== 0 ? ( 54 | {userInfo.nickname} 62 | ) : ( 63 | 64 | )} 65 |
66 |
67 | ) 68 | }) 69 | 70 | export default Header 71 | -------------------------------------------------------------------------------- /src/components/page/child-components/Music.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { PAGE_SINGER_NULL_TEXT, PAGE_SONG_NULL_TEXT } from '@/constant' 3 | import { ILyric } from '@/hooks/useLyric' 4 | import LyricBox from '@/common/lyricBox' 5 | import { IMusicInfo } from '@/hooks/useMusic' 6 | const Music = memo((props: { lyricInfo: ILyric; musicInfo: IMusicInfo }) => { 7 | // 获取音乐信息相关 8 | const { singers, name: songName } = props.musicInfo 9 | // 获取歌词相关信息 10 | const { currentLyricIndex, lyricList, lyricBoxRef } = props.lyricInfo 11 | return ( 12 | 47 | ) 48 | }) 49 | 50 | export default Music 51 | -------------------------------------------------------------------------------- /src/components/page/child-views/Mine/hooks/useChangeActiveItem.ts: -------------------------------------------------------------------------------- 1 | import { getAllMusic, getPlayListDetail } from '@/service/music' 2 | import { PlayingListItem } from '@/store/user/types' 3 | import { MusicListItem } from '@/store/music/types' 4 | import { useState, useEffect, useMemo } from 'react' 5 | import { useAppDispatch } from '@/store' 6 | type Detail = { 7 | tracks: MusicListItem[] 8 | trackCount: number 9 | trackIds?: string[] 10 | } 11 | 12 | export default function () { 13 | // 当前点击的歌单 14 | const [activeItem, setActiveItem] = useState(null) 15 | const [detail, setDetail] = useState() 16 | const [tracks, setTracks] = useState() 17 | // const dispatch = useAppDispatch() 18 | 19 | useEffect(() => { 20 | activeItem?.id && 21 | getPlayListDetail(activeItem.id).then(res => { 22 | // 先请求到所有trackIds 23 | setDetail(res.playlist) 24 | return getAllMusic(res.playlist.trackIds).then(res => { 25 | // 再根据trackIds获取所有的songs 26 | setTracks(res.songs) 27 | }) 28 | }) 29 | }, [activeItem]) 30 | 31 | const [showLength, setShowLength] = useState(10) 32 | 33 | // 滚动到底部时触发的回调函数 34 | const showMore = async () => { 35 | if (detail && detail.trackCount > showLength) { 36 | setShowLength(showLength + 10) 37 | } 38 | } 39 | 40 | // 截取tracks 41 | const computedTracks = useMemo(() => { 42 | return tracks?.slice(0, showLength) ?? [] 43 | }, [showLength, tracks]) 44 | 45 | return { activeItem, detail, setActiveItem, showMore, computedTracks } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/page/child-views/Mine/hooks/useGetPlayList.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from '@/store' 2 | import useSWR from 'swr' 3 | import { getPlayList } from '@/service/user' 4 | const fetchePlayList = uid => { 5 | return getPlayList(Number(uid)).then( 6 | res => new Promise(resolve => setTimeout(resolve, 500, res)) 7 | ) 8 | } 9 | export default function () { 10 | // 歌单数据 11 | const { uid } = useAppSelector(state => state.user) 12 | // Suspense优化??? 13 | // 最终决定不放在store中了 14 | const res = useSWR(uid + '', fetchePlayList, { suspense: true }) 15 | const playlist = res.data.playlist 16 | return playlist 17 | } 18 | -------------------------------------------------------------------------------- /src/components/page/child-views/Mine/index.tsx: -------------------------------------------------------------------------------- 1 | import MusicList from '@/common/musicList' 2 | import { 3 | LIST_NULL_TEXT, 4 | PAGE_MINE_DESC_NULL_TEXT, 5 | PAGE_MINE_TAGS_NULL_TEXT, 6 | } from '@/constant' 7 | import { pushPlayingMusicList, switchCurrentMusic } from '@/store/music' 8 | import { useAppDispatch } from '@/store' 9 | import { MusicListItem } from '@/store/music/types' 10 | import { formatCount, parseTime } from '@/utils' 11 | import React, { memo } from 'react' 12 | import useGetPlayList from './hooks/useGetPlayList' 13 | import useChangeActiveItem from './hooks/useChangeActiveItem' 14 | 15 | const Mine = memo(() => { 16 | const dispatch = useAppDispatch() 17 | const playList = useGetPlayList() 18 | const { setActiveItem, activeItem, computedTracks, showMore } = 19 | useChangeActiveItem() 20 | // 点击歌单详情列表的歌曲添加到playing中 21 | const pushIntoPlayingMusicList = (item: MusicListItem) => { 22 | dispatch(switchCurrentMusic(item)) 23 | dispatch(pushPlayingMusicList(item)) 24 | //TODO:push成功的dialog 25 | } 26 | 27 | return !playList?.length ? ( 28 |
29 | {LIST_NULL_TEXT} 30 |
31 | ) : !activeItem ? ( 32 |
33 | {playList.map(item => ( 34 |
setActiveItem(item)} 38 | p='10px' 39 | flex='~ col' 40 | justify='center' 41 | relative='~' 42 | > 43 |
44 | 53 |
54 |
64 | {formatCount(item.playCount ?? 0, 0)} 65 |
66 |
70 | {item.description ?? '咋那么懒,连个描述都没~'} 71 |
72 |
73 | ))} 74 |
75 | ) : ( 76 |
77 |
78 |
79 | 86 |
87 |
88 |
89 |

{activeItem.name}

90 | 97 |
98 |

99 | 创建时间: 100 | {parseTime(activeItem.createTime ?? 0)} 101 |

102 |

歌单作者:{activeItem.creator?.nickname}

103 |

播放量:{formatCount(activeItem.playCount ?? 0, 2)}

104 |
105 | 标签: 106 | {activeItem?.tags?.length 107 | ? activeItem?.tags?.map((item, index) => ( 108 |

109 | {item} 110 |

111 | )) 112 | : PAGE_MINE_TAGS_NULL_TEXT} 113 |
114 |
115 |
116 | 介绍: 117 |
118 |
119 | {activeItem.description 120 | ?.split('\n') 121 | .map((item, index) =>

{item}

) ?? 122 | PAGE_MINE_DESC_NULL_TEXT} 123 |
124 |
125 |
126 |
127 |
128 | showMore()} 132 | /> 133 |
134 |
135 | ) 136 | }) 137 | 138 | export default Mine 139 | -------------------------------------------------------------------------------- /src/components/page/child-views/Playing.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { useAppDispatch, useAppSelector } from '@/store' 3 | import { 4 | initialCurrentMusic, 5 | removeFromPlayingMusicList, 6 | switchCurrentMusic, 7 | } from '@/store/music' 8 | import MusicList from '@/common/musicList' 9 | import useAudio from '@/hooks/useAudio' 10 | import type { MusicListItem } from '@/store/music/types' 11 | const Playing = memo(() => { 12 | const { currentMusic, playingMusicList } = useAppSelector( 13 | state => state.music 14 | ) 15 | 16 | const dispatch = useAppDispatch() 17 | 18 | const { switchMusic } = useAudio() 19 | 20 | // 点击列表音乐时,切换音乐 21 | const rowClick = (item: MusicListItem) => { 22 | dispatch(switchCurrentMusic(item)) 23 | } 24 | 25 | const remove = (item: MusicListItem) => { 26 | // 如果是相同的,就先跳到下一首,再删除 27 | if (currentMusic === item) { 28 | //最后一个元素,重置currentMusic 29 | playingMusicList.length === 1 30 | ? dispatch(switchCurrentMusic(initialCurrentMusic)) 31 | : switchMusic('next') 32 | } 33 | dispatch(removeFromPlayingMusicList(item)) 34 | } 35 | 36 | return ( 37 | 42 | ) 43 | }) 44 | 45 | export default Playing 46 | -------------------------------------------------------------------------------- /src/components/page/child-views/Recommend.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect } from 'react' 2 | import { useAppDispatch, useAppSelector } from '@/store' 3 | import { 4 | fetchHotRecommend, 5 | pushPlayingMusicList, 6 | switchCurrentMusic, 7 | } from '@/store/music' 8 | import MusicList from '@/common/musicList' 9 | import type { MusicListItem } from '@/store/music/types' 10 | 11 | const Recommend = memo(() => { 12 | // 修改音乐 13 | const dispatch = useAppDispatch() 14 | const { dailyMusicList } = useAppSelector(state => state.music) 15 | useEffect(() => { 16 | // 请求热榜推荐歌曲的数据 17 | if (!dailyMusicList.length) dispatch(fetchHotRecommend()) 18 | }, [dispatch]) 19 | const pushIntoPlayingMusicList = (item: MusicListItem) => { 20 | dispatch(switchCurrentMusic(item)) 21 | dispatch(pushPlayingMusicList(item)) 22 | //TODO:push成功的dialog 23 | // alert('push成功') 24 | } 25 | return ( 26 | 27 | ) 28 | }) 29 | 30 | export default Recommend 31 | -------------------------------------------------------------------------------- /src/components/page/child-views/Search.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect, useState } from 'react' 2 | import { getMusic, search } from '@/service/music' 3 | import { formatTime } from '@/utils' 4 | import { pushPlayingMusicList } from '@/store/music' 5 | import { useAppDispatch } from '@/store' 6 | import { LIST_NULL_TEXT } from '@/constant' 7 | import useScrollToBottom from '@/hooks/useScrollToBottom' 8 | 9 | const LIMIT = 30 10 | 11 | const Search = memo(() => { 12 | const dispatch = useAppDispatch() 13 | const [isFocus, setIsFocus] = useState(false) 14 | // 保存表单数据 15 | const [value, setValue] = useState('') 16 | const [dataList, setDataList] = useState([] as any[]) 17 | const [songCount, setSongCount] = useState(0) 18 | 19 | useEffect(() => { 20 | // 防抖,获取搜索结果 21 | const timer = setTimeout(() => { 22 | search(value).then(res => { 23 | setDataList(res?.result?.songs ?? ([] as any[])) 24 | setSongCount(res?.result?.songCount) 25 | }) 26 | }, 300) 27 | return () => clearTimeout(timer) 28 | }, [value]) 29 | 30 | // 点击歌曲,添加到正在播放列表中 31 | const add = async (item: any) => { 32 | const res = await getMusic(item.id) 33 | dispatch(pushPlayingMusicList(res.songs[0])) 34 | } 35 | 36 | const showMore = async e => { 37 | const { scrollTop, scrollHeight, clientHeight } = e.target 38 | if (scrollTop + clientHeight >= scrollHeight) { 39 | if (dataList.length < songCount) { 40 | let offset = ~~(dataList.length / LIMIT) 41 | let limit = LIMIT 42 | if ((offset + 1) * LIMIT > songCount) { 43 | limit = songCount - offset * LIMIT 44 | } 45 | const res = await search(value, offset, limit) 46 | setDataList([...dataList, ...(res?.result?.songs ?? [])]) 47 | } 48 | } else { 49 | console.log('还没滚动到底部') 50 | } 51 | } 52 | 53 | return ( 54 |
55 |
63 | setIsFocus(true)} 74 | onBlur={() => setIsFocus(false)} 75 | value={value} 76 | onChange={e => setValue(e.target.value)} 77 | /> 78 |
79 | {dataList.length ? ( 80 |
81 |
88 | 89 | 歌曲 90 | 歌手 91 | 时长 92 |
93 |
showMore(e)}> 94 | {dataList.map((item, index) => ( 95 |
add(item)} 104 | > 105 | {/* 序号 */} 106 | 107 | {index + 1} 108 | 109 | {/* 歌名 */} 110 | {item.name} 111 | {/* 歌手 */} 112 | 113 | {item.artists && item.artists[0].name} 114 | 115 | {/* 时常 */} 116 | {formatTime(item.duration ?? 0)} 117 |
118 | ))} 119 |
120 |
121 | ) : ( 122 |
123 | {LIST_NULL_TEXT} 124 |
125 | )} 126 |
127 | ) 128 | }) 129 | 130 | export default Search 131 | -------------------------------------------------------------------------------- /src/components/page/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState, Suspense } from 'react' 2 | 3 | import { imgUrl } from '@/utils' 4 | import Mine from './child-views/Mine' 5 | import Search from './child-views/Search' 6 | import Recommend from './child-views/Recommend' 7 | import Header from './child-components/Header' 8 | import DivWrapper, { NavButton } from './style' 9 | import Playing from './child-views/Playing' 10 | import Footer from './child-components/Footer' 11 | import Music from './child-components/Music' 12 | 13 | import type { IMusicInfo } from '@/hooks/useMusic' 14 | import type { ILyric } from '@/hooks/useLyric' 15 | import type { IAudio } from '@/hooks/useAudio' 16 | //TODO:手机端兼容 17 | //TODO:使背景切换更自然 18 | const navList = [ 19 | { title: '正在播放', element: }, 20 | { title: '每日推荐', element: }, 21 | { title: '搜索', element: }, 22 | { title: '我的歌单', element: }, 23 | ] 24 | const Page = memo( 25 | (props: { 26 | TimeSlider: () => JSX.Element 27 | VolumeSlider: () => JSX.Element 28 | musicInfo: IMusicInfo 29 | lyricInfo: ILyric 30 | audioInfo: IAudio 31 | }) => { 32 | // 切换content 33 | const [currentIndex, setCurrentIndex] = useState(0) 34 | const { musicInfo, lyricInfo, audioInfo, TimeSlider, VolumeSlider } = props 35 | // 获取音乐信息相关 36 | const { al } = musicInfo 37 | 38 | return ( 39 | 40 | {/* 两张背景蒙版 */} 41 |
49 |
55 |
56 | {/* 内容 */} 57 |
64 |
65 | {/* 导航栏 */} 66 | 77 |
78 | 87 | loading... 88 |
89 | } 90 | > 91 | {/* 达到类似路由的效果 */} 92 | {navList[currentIndex].element} 93 | 94 |
95 |
96 | 97 |
98 |