├── .gitignore ├── README.md ├── build-sw.js ├── package.json ├── public ├── index.html ├── manifest.json └── phoslogo.png ├── src ├── App.css ├── App.js ├── App.test.js ├── components │ ├── BasePlayer.js │ ├── Control │ │ ├── ControlCard.js │ │ ├── PhosCacheSlider.js │ │ ├── PhosSlider.js │ │ ├── ProcessSlider.js │ │ └── VolumeCard.js │ ├── Msg.js │ ├── NowPlaylist.js │ ├── PhosPlayerContext.js │ ├── Search.js │ ├── Settings.js │ ├── SiderList │ │ ├── AlbumList.js │ │ ├── ArtistList.js │ │ ├── Playlist.js │ │ └── SiderList.js │ ├── SongList.js │ └── utils.js ├── index.css ├── index.js ├── logo.svg ├── serviceWorker.js ├── static │ ├── logo_163.jpg │ ├── logo_notion.png │ └── logo_ytb.png └── sw.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phos 2 | [![Netlify Status](https://api.netlify.com/api/v1/badges/9a44cb22-69a9-4348-a6ec-80ab49dc46f4/deploy-status)](https://app.netlify.com/sites/phos-music/deploys) 3 | 4 | PWA Music Player 5 | ![](https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F24198614-499c-4e18-9528-f8e59e2c02f4%2FXnip2019-08-14_02-39-58.png?table=block&id=8222a684-8e35-42e2-bfae-e53a1fbe3607&width=3840&cache=v2) 6 | 7 | Data in your Notion's database 8 | ![](https://www.notion.so/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F5881955e-4359-48ea-913e-ccdc8de05a80%2FXnip2019-08-14_02-59-53.png?table=block&id=422d6fbd-49e0-4b1f-81d8-54394473869c&width=3460&cache=v2) 9 | # Roadmap 10 | https://www.notion.so/Phos-03cdeac68f97487ab09b29b5c2882f1b 11 | # Document 12 | https://www.notion.so/music-9a31e68f8f004daaa5e79102ffd843d7 -------------------------------------------------------------------------------- /build-sw.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const fs = require('fs') 3 | 4 | let swPath = path.resolve(__dirname, 'src', 'sw.js') 5 | var customSwConfig = fs.readFileSync(swPath, 'utf8'); 6 | 7 | const CRA_BUILD_SW_PATH = path.resolve(__dirname, 'build', 'service-worker.js') 8 | 9 | try { 10 | fs.appendFileSync(CRA_BUILD_SW_PATH, customSwConfig); 11 | console.log('sw build ok😀'); 12 | } catch (err) { 13 | /* Handle the error */ 14 | console.log(err) 15 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phos", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.3.1", 7 | "@material-ui/icons": "^4.2.1", 8 | "lodash": "^4.17.15", 9 | "notabase": "^0.8.5", 10 | "react": "^16.9.0", 11 | "react-dom": "^16.9.0", 12 | "react-player": "^1.11.2", 13 | "react-scripts": "3.0.1", 14 | "react-window": "^1.8.5" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build && node build-sw.js", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | }, 37 | "devDependencies": { 38 | "serve": "^11.2.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Phos 25 | 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Phos", 3 | "name": "Phos", 4 | "icons": [ 5 | { 6 | "src": "phoslogo.png", 7 | "sizes": "256x256", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#38d4c9", 14 | "background_color": "#ffffff" 15 | } -------------------------------------------------------------------------------- /public/phoslogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayneyao/phos/ebc20a7fbf2dd96bb8d4b1c516382d9d7ada8e2b/public/phoslogo.png -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #38d4c9 3 | } -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './App.css'; 3 | import SongList from './components/SongList' 4 | import SiderList from './components/SiderList/SiderList' 5 | 6 | import Notabase from 'notabase' 7 | import BasePlayer from './components/BasePlayer' 8 | import Settings from './components/Settings' 9 | import Hidden from '@material-ui/core/Hidden'; 10 | import SettingsIcon from '@material-ui/icons/Settings'; 11 | import LinearProgress from '@material-ui/core/LinearProgress'; 12 | 13 | import Msg from './components/Msg'; 14 | import { PhosPlayerContext } from "./components/PhosPlayerContext"; 15 | import { makeStyles } from '@material-ui/core/styles'; 16 | import { createMuiTheme } from '@material-ui/core/styles'; 17 | import { ThemeProvider } from '@material-ui/styles'; 18 | 19 | 20 | const FREE_PUBLIC_PROXY = 'https://notion.gine.workers.dev' 21 | const theme = createMuiTheme({ 22 | palette: { 23 | primary: { main: '#38d4c9' }, // phos color 24 | }, 25 | }); 26 | 27 | 28 | const useStyles = makeStyles(theme => ({ 29 | root: { 30 | }, 31 | contentWrapper: { 32 | [theme.breakpoints.down('sm')]: { 33 | width: '100%', 34 | }, 35 | [theme.breakpoints.up('sm')]: { 36 | display: 'flex', 37 | }, 38 | height: 'calc(100vh - 90px)' 39 | }, 40 | playlist: { 41 | [theme.breakpoints.down('sm')]: { 42 | width: '100%', 43 | }, 44 | [theme.breakpoints.up('sm')]: { 45 | width: '25%', 46 | }, 47 | // height: '100%', 48 | // maxWidth: 400, 49 | // backgroundColor: theme.palette.background.paper, 50 | margin: '0 auto' 51 | }, 52 | playlistContent: { 53 | [theme.breakpoints.down('sm')]: { 54 | width: '100%', 55 | }, 56 | [theme.breakpoints.up('sm')]: { 57 | width: '75%', 58 | }, 59 | height: '100%', 60 | // maxWidth: 1200, 61 | // backgroundColor: theme.palette.background.paper, 62 | margin: '0 auto', 63 | [theme.breakpoints.down('sm')]: { 64 | marginTop: 42 65 | } 66 | }, 67 | setting: { 68 | position: 'absolute', 69 | top: 6, 70 | right: 6, 71 | color: '#aaa' 72 | }, 73 | listTitleWrapper: { 74 | width: '100%', 75 | textAlign: 'center', 76 | [theme.breakpoints.down('xs')]: { 77 | position: 'fixed', 78 | top: 0, 79 | zIndex: 10 80 | }, 81 | } 82 | })); 83 | 84 | 85 | function PhosPlayer() { 86 | const { state, dispatch } = React.useContext(PhosPlayerContext) 87 | const { loading, background, color, opacity } = state 88 | // console.log(opacity) 89 | // const background = localStorage.getItem("style.background") 90 | // const color = localStorage.getItem("style.color") || '#000' 91 | 92 | React.useEffect(() => { 93 | let proxyUrl = localStorage.getItem("security.proxyUrl") 94 | let authCode = localStorage.getItem("security.authCode") 95 | 96 | let proxy = { 97 | url: FREE_PUBLIC_PROXY 98 | } 99 | if (proxyUrl) { 100 | proxy = { 101 | url: proxyUrl, 102 | authCode 103 | } 104 | } 105 | let nb = new Notabase({ proxy }) 106 | 107 | const fetchData = async () => { 108 | let hash = window.location.hash 109 | let phosConfigURL 110 | if (hash) { 111 | // 处理微信跳转过来的链接 112 | let fuckWechat; 113 | [phosConfigURL, fuckWechat] = hash.slice(1).split('?') 114 | 115 | if (phosConfigURL.includes("@")) { 116 | // 分享音乐 todo 117 | let shareSongId; 118 | [phosConfigURL, shareSongId] = phosConfigURL.split("@") 119 | } 120 | } else { 121 | // 处理个人数据时候 122 | phosConfigURL = localStorage.getItem("phosConfigURL") 123 | } 124 | if (phosConfigURL) { 125 | let config = await nb.fetch(phosConfigURL) 126 | console.log(config, nb) 127 | let db = await nb.fetchAll({ 128 | songs: config.rows.find(i => i.name === "songs")._raw.properties[config.propsKeyMap['url'].key][0][1][0][1], 129 | albums: config.rows.find(i => i.name === "albums")._raw.properties[config.propsKeyMap['url'].key][0][1][0][1], 130 | artists: config.rows.find(i => i.name === "artists")._raw.properties[config.propsKeyMap['url'].key][0][1][0][1], 131 | history: config.rows.find(i => i.name === "history")._raw.properties[config.propsKeyMap['url'].key][0][1][0][1], 132 | }) 133 | 134 | dispatch({ 135 | type: 'loadData', 136 | payload: { 137 | data: db 138 | } 139 | }) 140 | 141 | dispatch({ type: 'loading' }) 142 | 143 | } else { 144 | dispatch({ 145 | type: 'setPlayerConfig', 146 | payload: { 147 | name: 'openSettings', 148 | value: true 149 | } 150 | }) 151 | } 152 | } 153 | fetchData() 154 | }, []) 155 | 156 | const classes = useStyles() 157 | 158 | return ( 159 | 160 |
168 |
169 |
170 |
171 | { 172 | loading && 173 | } 174 |
175 |
176 |
177 | 178 | 179 | { 181 | dispatch({ 182 | type: 'setPlayerConfig', 183 | payload: { 184 | name: 'openSettings', 185 | value: true 186 | } 187 | }) 188 | } 189 | }> 190 | settings 191 | 192 | 193 |
194 |
195 |
196 | 197 |
198 |
199 | 200 | 201 | 202 | 203 |
204 |
205 | ); 206 | } 207 | 208 | export default PhosPlayer; -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/BasePlayer.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ReactPlayer from "react-player"; 3 | import { PhosPlayerContext } from "./PhosPlayerContext"; 4 | 5 | import ControlCard from "./Control/ControlCard"; 6 | import { throttle } from "lodash"; 7 | 8 | export default function Player() { 9 | const { state, dispatch } = React.useContext(PhosPlayerContext); 10 | const player = React.useRef(null); 11 | const [hiddenPlayer, setHiddenPlayer] = useState(false); 12 | const { playing, url, volume, repeat } = state; 13 | const _onProgress = playingState => { 14 | if (!hiddenPlayer && playingState.played > 0) { 15 | // 开始播放隐藏播放器 16 | setHiddenPlayer(true); 17 | } else if (playingState.played === 0) { 18 | setHiddenPlayer(false); 19 | } 20 | dispatch({ 21 | type: "updatePlayingState", 22 | payload: { 23 | playingState 24 | } 25 | }); 26 | }; 27 | const onProgress = throttle(_onProgress, 1000); 28 | 29 | const onEnded = () => { 30 | setHiddenPlayer(false); 31 | const { 32 | currentPlaySong, 33 | data: { history } 34 | } = state; 35 | try { 36 | if (!currentPlaySong.length) { 37 | currentPlaySong.length = player.current.getDuration(); 38 | console.log(`已自动更新:${currentPlaySong.title} 歌曲时长`); 39 | } 40 | } catch (error) { 41 | console.log(`无法更新:${currentPlaySong.title} 歌曲时长`); 42 | console.log(error); 43 | } 44 | 45 | try { 46 | // 47 | history.addRow({ 48 | song: [currentPlaySong] 49 | }); 50 | console.log(`更新听歌记录成功`); 51 | } catch (error) { 52 | console.log(`更新听歌记录失败`); 53 | } 54 | 55 | dispatch({ 56 | type: "next" 57 | }); 58 | }; 59 | const onBuffer = () => { 60 | dispatch({ 61 | type: "setPlayerConfig", 62 | payload: { 63 | name: "isBufferEnd", 64 | value: false 65 | } 66 | }); 67 | }; 68 | 69 | const onBufferEnd = () => { 70 | dispatch({ 71 | type: "setPlayerConfig", 72 | payload: { 73 | name: "isBufferEnd", 74 | value: true 75 | } 76 | }); 77 | }; 78 | 79 | const seekTo = seconds => { 80 | player.current.seekTo(seconds); 81 | }; 82 | let showPlayer = 83 | url && url.startsWith("https://www.youtube.com") && !hiddenPlayer 84 | ? { display: "block" } 85 | : { display: "none" }; 86 | return ( 87 | <> 88 | 102 | 103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/components/Control/ControlCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles, useTheme } from '@material-ui/core/styles'; 3 | import Card from '@material-ui/core/Card'; 4 | import CardContent from '@material-ui/core/CardContent'; 5 | import CardMedia from '@material-ui/core/CardMedia'; 6 | import IconButton from '@material-ui/core/IconButton'; 7 | import Typography from '@material-ui/core/Typography'; 8 | import Hidden from '@material-ui/core/Hidden'; 9 | 10 | // icon 11 | import SettingsIcon from '@material-ui/icons/Settings'; 12 | import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlayOutlined'; 13 | import SkipPreviousIcon from '@material-ui/icons/SkipPrevious'; 14 | import PlayIcon from '@material-ui/icons/PlayCircleOutline'; 15 | import PauseIcon from '@material-ui/icons/Pause'; 16 | import ShuffleIcon from '@material-ui/icons/Shuffle'; 17 | import RepeatIcon from '@material-ui/icons/Repeat'; 18 | import RepeatOneIcon from '@material-ui/icons/RepeatOne'; 19 | import SkipNextIcon from '@material-ui/icons/SkipNext'; 20 | 21 | 22 | import { parseImageUrl } from 'notabase/src/utils' 23 | import VolumeCard from './VolumeCard' 24 | import { PhosPlayerContext } from '../PhosPlayerContext' 25 | import ProcessSlider from './ProcessSlider' 26 | import { getSongArtists } from '../utils' 27 | 28 | const shuffleArray = (arr) => { 29 | let i = arr.length; 30 | while (i) { 31 | let j = Math.floor(Math.random() * i--); 32 | [arr[j], arr[i]] = [arr[i], arr[j]]; 33 | } 34 | } 35 | 36 | const useStyles = makeStyles(theme => ({ 37 | card: { 38 | display: 'flex', 39 | position: 'fixed', 40 | bottom: 0, 41 | width: '100%', 42 | // background: 'none' 43 | }, 44 | playControls: { 45 | [theme.breakpoints.down('sm')]: { 46 | width: '100%', 47 | }, 48 | [theme.breakpoints.up('sm')]: { 49 | width: '50%', 50 | }, 51 | display: 'flex', 52 | flexDirection: 'column', 53 | margin: '0 auto' 54 | }, 55 | songDetails: { 56 | display: 'flex', 57 | width: '25%', 58 | }, 59 | cover: { 60 | minWidth: 100, 61 | height: '100%', 62 | maxWidth: 100, 63 | }, 64 | content: { 65 | overflow: 'auto' 66 | }, 67 | controlBtn: { 68 | margin: '0 auto', 69 | [theme.breakpoints.down('xs')]: { 70 | display: 'flex', 71 | justifyContent: 'space-between', 72 | width: '100%' 73 | }, 74 | }, 75 | playIcon: { 76 | height: 38, 77 | width: 38, 78 | }, 79 | processSlider: { 80 | maxWidth: '100%', 81 | }, 82 | volumeWrapper: { 83 | width: '25%', 84 | }, 85 | volume: { 86 | position: 'absolute', 87 | top: 'calc(50% - 20px)', 88 | right: '70px', 89 | }, 90 | currentPlaylist: { 91 | position: 'absolute', 92 | bottom: 'calc(50% - 8px)', 93 | right: '40px', 94 | color: '#aaa' 95 | }, 96 | active: { 97 | color: theme.palette.primary.main 98 | }, 99 | settings: { 100 | position: 'absolute', 101 | bottom: 'calc(50% - 8px)', 102 | right: '10px', 103 | color: '#aaa' 104 | } 105 | })); 106 | 107 | export default function MediaControlCard(props) { 108 | const classes = useStyles() 109 | const theme = useTheme() 110 | const { state, dispatch } = React.useContext(PhosPlayerContext) 111 | const { playing, repeat, shuffle, currentPlaylist, currentPlaySong, showNowPlaylist } = state 112 | let _currentPlaylist = currentPlaylist 113 | 114 | const getCover = () => { 115 | if (currentPlaySong && currentPlaySong.title && currentPlaySong.album && currentPlaySong.album[0] && currentPlaySong.album[0].cover) { 116 | return parseImageUrl(currentPlaySong.album[0].cover[0] || currentPlaySong.album[0].cover_163, 80) 117 | } else if (currentPlaySong.album && currentPlaySong.album[0] && currentPlaySong.album[0].cover_163) { 118 | return currentPlaySong.album[0].cover_163 119 | } else { 120 | return 'https://upload.wikimedia.org/wikipedia/commons/e/ec/Record-Album-02.jpg' 121 | } 122 | } 123 | return ( 124 |
125 | 126 | 127 |
128 | { 129 | currentPlaySong.title && 134 | } 135 | 136 | 137 | 138 | {currentPlaySong.title} 139 | 140 | 141 | {currentPlaySong.title && getSongArtists(currentPlaySong)} 142 | 143 | 144 |
145 |
146 |
147 | 148 |
149 | 150 |
151 |
152 |
153 | { 155 | dispatch({ 156 | type: 'setPlayerConfig', 157 | payload: { 158 | name: 'shuffle', 159 | value: !shuffle 160 | } 161 | }) 162 | 163 | if (!shuffle) { 164 | // 打乱当前播放列表 165 | shuffleArray(_currentPlaylist) 166 | 167 | dispatch({ 168 | type: 'setPlayerConfig', 169 | payload: { 170 | name: 'currentPlaylist', 171 | value: _currentPlaylist 172 | } 173 | }) 174 | } 175 | } 176 | }> 177 | 178 | 179 | 180 | { 182 | dispatch({ 183 | type: 'prev' 184 | }) 185 | } 186 | }> 187 | {theme.direction === 'rtl' ? : } 188 | 189 | dispatch({ type: 'play' })}> 190 | { 191 | playing ? : 192 | } 193 | 194 | 195 | { 197 | dispatch({ 198 | type: 'next' 199 | }) 200 | } 201 | }> 202 | {theme.direction === 'rtl' ? : } 203 | 204 | { 205 | dispatch({ 206 | type: 'setRepeat' 207 | }) 208 | }}> 209 | { 210 | repeat === 'one' ? : 211 | } 212 | 213 |
214 | 215 |
216 | 217 |
218 |
219 |
220 | 221 |
222 |
223 | 224 |
225 |
226 | { 229 | dispatch({ 230 | type: 'set', 231 | payload: { 232 | showNowPlaylist: !showNowPlaylist, 233 | } 234 | }) 235 | } 236 | }> 237 | settings 238 | 239 |
240 |
241 | { 243 | dispatch({ 244 | type: 'setPlayerConfig', 245 | payload: { 246 | name: 'openSettings', 247 | value: true 248 | } 249 | }) 250 | } 251 | }> 252 | settings 253 | 254 |
255 |
256 |
257 |
258 |
259 | 260 | ); 261 | } 262 | -------------------------------------------------------------------------------- /src/components/Control/PhosCacheSlider.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Slider from '@material-ui/core/Slider'; 3 | import { withStyles, makeStyles } from '@material-ui/core/styles'; 4 | 5 | const PhosSlider = withStyles(({ palette }) => ({ 6 | root: { 7 | color: palette.primary.main, 8 | height: 2, 9 | }, 10 | thumb: { 11 | display: 'none' 12 | }, 13 | active: {}, 14 | valueLabel: { 15 | left: 'calc(-50% + 4px)', 16 | }, 17 | track: { 18 | height: 4, 19 | borderRadius: 2, 20 | }, 21 | rail: { 22 | height: 4, 23 | borderRadius: 2, 24 | backgroundColor: 'rgba(0,0,0,.5)' 25 | } 26 | }))(Slider) 27 | 28 | export default PhosSlider -------------------------------------------------------------------------------- /src/components/Control/PhosSlider.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Slider from '@material-ui/core/Slider'; 3 | import { withStyles, makeStyles } from '@material-ui/core/styles'; 4 | 5 | const PhosSlider = withStyles(({ palette }) => ({ 6 | root: { 7 | color: '#25a79f', 8 | height: 2, 9 | }, 10 | thumb: { 11 | height: 8, 12 | width: 8, 13 | // backgroundColor: '#fff', 14 | border: '2px solid currentColor', 15 | marginTop: -2, 16 | // marginLeft: 0, 17 | '&:focus,&:hover,&$active': { 18 | boxShadow: 'inherit', 19 | }, 20 | }, 21 | active: {}, 22 | valueLabel: { 23 | left: 'calc(-50% + 4px)', 24 | }, 25 | track: { 26 | height: 4, 27 | borderRadius: 2, 28 | }, 29 | rail: { 30 | height: 4, 31 | borderRadius: 2, 32 | backgroundColor: '#b7f0eb' 33 | } 34 | }))(Slider) 35 | 36 | export default PhosSlider -------------------------------------------------------------------------------- /src/components/Control/ProcessSlider.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import { PhosPlayerContext } from '../PhosPlayerContext' 5 | import PhosSlider from './PhosSlider' 6 | import PhosCacheSlider from './PhosCacheSlider' 7 | import Hidden from '@material-ui/core/Hidden'; 8 | 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | card: { 12 | display: 'flex', 13 | }, 14 | process: { 15 | maxWidth: 800, 16 | [theme.breakpoints.down('sm')]: { 17 | width: '100%', 18 | }, 19 | [theme.breakpoints.up('sm')]: { 20 | width: '100%', 21 | }, 22 | margin: '0 10px', 23 | position: 'relative' 24 | 25 | }, 26 | second: { 27 | display: 'flex', 28 | justifyContent: 'space-between', 29 | paddingTop: 20 30 | }, 31 | time: { 32 | display: 'block', 33 | width: '10%', 34 | padding: 3 35 | }, 36 | timeLeft: { 37 | textAlign: 'end' 38 | } 39 | })) 40 | 41 | const seconds2Minutes = (time) => { 42 | let minutes = Math.floor(time / 60) 43 | let seconds = parseInt(time % 60) 44 | if (seconds < 10) { 45 | seconds = `0${seconds}` 46 | } 47 | return `${minutes}:${(seconds + '').slice(0, 2)}` 48 | } 49 | 50 | 51 | export default function ProcessSlider(props) { 52 | const { state, dispatch } = React.useContext(PhosPlayerContext) 53 | const { playingState: { playedSeconds, loadedSeconds }, currentPlaySong, isBufferEnd } = state 54 | const classes = useStyles() 55 | const { length } = currentPlaySong 56 | let len = length ? seconds2Minutes(length) : '00:00' 57 | 58 | return
59 | 60 |
61 | {playedSeconds ? seconds2Minutes(playedSeconds) : '00:00'} 62 |
63 |
64 |
65 | { 71 | props.seekTo(v) 72 | }} 73 | style={{ 74 | position: 'absolute' 75 | }} 76 | /> 77 | { 83 | props.seekTo(v) 84 | }} 85 | style={{ 86 | position: 'absolute' 87 | }} 88 | /> 89 | 90 |
91 |
92 | {playedSeconds ? seconds2Minutes(playedSeconds) : '00:00'} 93 |
94 |
95 | {len} 96 |
97 |
98 |
99 |
100 | 101 |
102 | {len} 103 |
104 |
105 |
106 | } -------------------------------------------------------------------------------- /src/components/Control/VolumeCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import VolumeUp from '@material-ui/icons/VolumeUp'; 5 | import VolumeOff from '@material-ui/icons/VolumeOff'; 6 | import { PhosPlayerContext } from '../PhosPlayerContext' 7 | 8 | import PhosSlider from './PhosSlider' 9 | 10 | const useStyles = makeStyles({ 11 | root: { 12 | width: 150, 13 | height: 40 14 | }, 15 | }); 16 | 17 | export default function InputSlider() { 18 | const classes = useStyles(); 19 | const { state, dispatch } = React.useContext(PhosPlayerContext) 20 | const { volume } = state 21 | const handleSliderChange = (event, newValue) => { 22 | dispatch({ 23 | type: 'setVolume', 24 | payload: { 25 | volume: newValue 26 | } 27 | }) 28 | } 29 | 30 | const muteOrOpen = () => { 31 | if (volume !== 0) { 32 | dispatch({ 33 | type: 'setVolume', 34 | payload: { 35 | volume: 0 36 | } 37 | }) 38 | } else { 39 | dispatch({ 40 | type: 'setVolume', 41 | payload: { 42 | volume: 1 43 | } 44 | }) 45 | } 46 | } 47 | const isMute = volume === 0 48 | 49 | return ( 50 |
51 | 52 | 53 | { 54 | isMute ? : 55 | } 56 | 57 | 58 | 65 | 66 | 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Msg.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Snackbar from '@material-ui/core/Snackbar'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import CloseIcon from '@material-ui/icons/Close'; 6 | import { PhosPlayerContext } from "./PhosPlayerContext"; 7 | 8 | const useStyles = makeStyles(theme => ({ 9 | close: { 10 | padding: theme.spacing(0.5), 11 | }, 12 | })); 13 | 14 | export default function SimpleSnackbar() { 15 | const classes = useStyles(); 16 | const { state, dispatch } = useContext(PhosPlayerContext) 17 | 18 | const { msg, msgOpen } = state 19 | 20 | 21 | const handleClose = (event, reason) => { 22 | if (reason === 'clickaway') { 23 | return; 24 | } 25 | dispatch({ 26 | type: 'closeMsg' 27 | }) 28 | }; 29 | 30 | return ( 31 |
32 | {msg}} 44 | action={[ 45 | 52 | 53 | , 54 | ]} 55 | /> 56 |
57 | ); 58 | } -------------------------------------------------------------------------------- /src/components/NowPlaylist.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayneyao/phos/ebc20a7fbf2dd96bb8d4b1c516382d9d7ada8e2b/src/components/NowPlaylist.js -------------------------------------------------------------------------------- /src/components/PhosPlayerContext.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from 'react'; 2 | 3 | const PhosPlayerContext = React.createContext(); 4 | 5 | const NOTION_BASE = "https://notion.so" 6 | 7 | const initState = { 8 | 9 | // data 10 | data: {}, // 音乐数据 11 | currentPlaySong: {}, // 当前播放的音乐 12 | currentPlaylist: [], // 不会被存储的当前播放列表 13 | allPlaylist: [], // 所有的播放列表 14 | 15 | // 下面字段会影响歌曲列表的显示。filter 16 | playlistName: undefined, // 默认为空,显示全部歌曲 17 | artistName: undefined, // 默认为空,显示全部歌手的歌曲 18 | albumName: undefined, // 默认为空,显示全部专辑的歌曲 19 | filterBy: undefined, // 默认为空,不会过滤歌曲 20 | showNowPlaylist: false, // 是否显示当前播放列表 21 | 22 | searchWord: undefined, // 默认为空,不会过滤歌曲 23 | searchType: 'so', // 默认为常规搜索 [so,pl,ar,al] 歌曲(常规)/歌单/艺人/专辑 24 | 25 | // player 26 | url: '', // 27 | volume: 1, // 0-1 音量 28 | muted: false, // 29 | playing: false, // 是否播放 30 | isReady: false, // 当前歌曲是否加载完毕,可以播放 31 | isBufferEnd: false, //当前播放的歌曲缓存是否结束 32 | 33 | shuffle: false, 34 | repeat: 'none', // ['none','one','list'] 不循环 | 单曲循环 | 列表循环 35 | 36 | // player state 37 | phosColor: "#38d4c9", 38 | playingState: {}, 39 | openSettings: false, // 是否打开配置 40 | loading: true, 41 | 42 | // player style 43 | background: localStorage.getItem("style.background"), 44 | color: localStorage.getItem("style.color"), 45 | opacity: localStorage.getItem("style.opacity"), 46 | 47 | // msg 48 | msg: '', 49 | msgOpen: false 50 | } 51 | 52 | 53 | 54 | const getPlaylist = (songs) => { 55 | return songs ? songs.schema.playlist.options.map(o => o.value) : [] 56 | } 57 | 58 | // reducer 59 | 60 | function getSongSourceFileAndArtists(song) { 61 | let songSourceFile 62 | switch (song.source) { 63 | case "file": 64 | songSourceFile = `${NOTION_BASE}/signed/${encodeURIComponent(song.file[0]).replace("s3.us-west", "s3-us-west")}` 65 | break 66 | case "163": 67 | songSourceFile = song.file[0] //song.file.find(s => s.startsWith("https://music.163.com")) || `https://music.163.com/song/media/outer/url?id=${song.id_163}.mp3` 68 | break 69 | case "ytb": 70 | songSourceFile = song.file[0] 71 | break 72 | default: 73 | songSourceFile = song.file[0] 74 | if (song.file[0].startsWith("https://s3")){ 75 | songSourceFile = `${NOTION_BASE}/signed/${encodeURIComponent(song.file[0]).replace("s3.us-west", "s3-us-west")}` 76 | } 77 | } 78 | 79 | let artists = `${song.artist && song.artist.length ? song.artist.filter(i => !!i).map(a => a.name).join(",") : '未知'}` 80 | 81 | return [songSourceFile, artists] 82 | } 83 | 84 | function phosReducer(state, action) { 85 | const { currentPlaySong, currentPlaylist, showNowPlaylist } = state 86 | switch (action.type) { 87 | case 'closeMsg': 88 | return { 89 | ...state, 90 | msg: '', 91 | msgOpen: false 92 | } 93 | case 'showMsg': 94 | return { 95 | ...state, 96 | msg: action.payload.msg, 97 | msgOpen: true 98 | } 99 | case 'loadData': 100 | return { 101 | ...state, 102 | data: action.payload.data, 103 | allPlaylist: getPlaylist(action.payload.data.songs) 104 | } 105 | case 'play': 106 | if (state.currentPlaySong.title) { 107 | return { 108 | ...state, 109 | playing: !state.playing 110 | } 111 | } else { 112 | return state 113 | } 114 | case 'loading': 115 | return { 116 | ...state, 117 | loading: !state.loading 118 | } 119 | case 'addOneSongToCurrentPlaylist': 120 | if (action.payload.song.file || action.payload.song.id_163) { 121 | let _currentPlaylist = [...currentPlaylist, action.payload.song] 122 | return { 123 | ...state, 124 | currentPlaylist: _currentPlaylist 125 | } 126 | } else { 127 | return state 128 | } 129 | case 'playOneSong': 130 | if (action.payload.song.file || action.payload.song.id_163) { 131 | // 当前播放列表名称 132 | const { playlistName } = state 133 | let _currentPlaylist = showNowPlaylist ? currentPlaylist : [...currentPlaylist, action.payload.song] 134 | let songsCanPlay = state.data.songs.rows.filter(song => !!song.file || !!song.id_163) 135 | // if (!playlistName) { 136 | // // 全部歌曲列表 > 当前播放列表 137 | // _currentPlaylist = songsCanPlay 138 | // } else { 139 | // // 点击的歌单 > 当前播放列表 140 | // _currentPlaylist = songsCanPlay.filter(song => song.playlist && song.playlist.includes(playlistName)) 141 | // } 142 | 143 | 144 | let [songSourceFile, artists] = getSongSourceFileAndArtists(action.payload.song) 145 | document.title = `${action.payload.song.title} - ${artists}` 146 | 147 | return { 148 | ...state, 149 | currentPlaySong: action.payload.song, 150 | url: songSourceFile, 151 | isReady: false, 152 | playing: true, 153 | isBufferEnd: false, 154 | // currentPlaylist: _currentPlaylist 155 | } 156 | } else { 157 | return state 158 | } 159 | 160 | case 'changeVolume': 161 | return { 162 | ...state, 163 | volume: action.payload.volume 164 | } 165 | case 'updatePlayingState': 166 | return { 167 | ...state, 168 | playingState: action.payload.playingState 169 | } 170 | case 'setVolume': 171 | return { 172 | ...state, 173 | volume: action.payload.volume 174 | } 175 | case 'setPlaylistName': 176 | return { 177 | ...state, 178 | playlistName: action.payload.playlistName, 179 | filterBy: 'playlistName', 180 | showNowPlaylist: false 181 | } 182 | case 'setArtistName': 183 | return { 184 | ...state, 185 | artistName: action.payload.artistName, 186 | filterBy: 'artistName', 187 | showNowPlaylist: false 188 | } 189 | case 'setAlbumName': 190 | return { 191 | ...state, 192 | albumName: action.payload.albumName, 193 | filterBy: 'albumName', 194 | showNowPlaylist: false 195 | } 196 | case 'set': 197 | // 更新任意状态 198 | return { 199 | ...state, 200 | ...action.payload 201 | } 202 | case 'setPlayerConfig': 203 | // 配置基础 player 参数 204 | return { 205 | ...state, 206 | [action.payload.name]: action.payload.value 207 | } 208 | case 'setRepeat': 209 | let repeatStateList = ['none', 'list', 'one'] 210 | let newRepeatIndex = (repeatStateList.indexOf(state.repeat) + 1) % repeatStateList.length 211 | let repeat = repeatStateList[newRepeatIndex] 212 | return { 213 | ...state, 214 | repeat 215 | } 216 | case 'prev': 217 | //上一曲 218 | if (currentPlaySong.title && currentPlaylist && currentPlaylist.length) { 219 | let prevSongIndex 220 | if (currentPlaylist.findIndex(i => i.title === currentPlaySong.title) === 0) { 221 | prevSongIndex = currentPlaylist.length - 1 222 | } else { 223 | prevSongIndex = (currentPlaylist.findIndex(i => i.title === currentPlaySong.title) - 1) % currentPlaylist.length 224 | } 225 | let prevSong = currentPlaylist[prevSongIndex] 226 | let [songSourceFile, artists] = getSongSourceFileAndArtists(prevSong) 227 | document.title = `${prevSong.title} - ${artists}` 228 | return { 229 | ...state, 230 | currentPlaySong: prevSong, 231 | url: songSourceFile, 232 | } 233 | } else { 234 | return state 235 | } 236 | case 'next': 237 | //下一曲 238 | if (currentPlaySong.title && currentPlaylist && currentPlaylist.length) { 239 | let nextSongIndex = (currentPlaylist.findIndex(s => s.title === currentPlaySong.title) + 1) % currentPlaylist.length 240 | let nextSong = currentPlaylist[nextSongIndex] 241 | let [songSourceFile, artists] = getSongSourceFileAndArtists(nextSong) 242 | document.title = `${nextSong.title} - ${artists}` 243 | 244 | return { 245 | ...state, 246 | currentPlaySong: nextSong, 247 | url: songSourceFile, 248 | } 249 | } else { 250 | return state 251 | } 252 | default: 253 | return state 254 | } 255 | } 256 | 257 | 258 | const PhosPlayerProvider = (props) => { 259 | const [state, dispatch] = useReducer(phosReducer, initState); 260 | 261 | return ( 262 | 263 | {props.children} 264 | 265 | ); 266 | } 267 | 268 | export { PhosPlayerContext, PhosPlayerProvider }; -------------------------------------------------------------------------------- /src/components/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@material-ui/core/AppBar'; 3 | import Toolbar from '@material-ui/core/Toolbar'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import InputBase from '@material-ui/core/InputBase'; 7 | import { fade, makeStyles } from '@material-ui/core/styles'; 8 | import MenuIcon from '@material-ui/icons/Menu'; 9 | import SearchIcon from '@material-ui/icons/Search'; 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | root: { 13 | flexGrow: 1, 14 | }, 15 | menuButton: { 16 | marginRight: theme.spacing(2), 17 | }, 18 | title: { 19 | flexGrow: 1, 20 | display: 'none', 21 | [theme.breakpoints.up('sm')]: { 22 | display: 'block', 23 | }, 24 | }, 25 | search: { 26 | position: 'relative', 27 | borderRadius: theme.shape.borderRadius, 28 | backgroundColor: fade(theme.palette.common.white, 0.15), 29 | '&:hover': { 30 | backgroundColor: fade(theme.palette.common.white, 0.25), 31 | }, 32 | marginLeft: 0, 33 | width: '100%', 34 | [theme.breakpoints.up('sm')]: { 35 | marginLeft: theme.spacing(1), 36 | width: 'auto', 37 | }, 38 | }, 39 | searchIcon: { 40 | width: theme.spacing(7), 41 | height: '100%', 42 | position: 'absolute', 43 | pointerEvents: 'none', 44 | display: 'flex', 45 | alignItems: 'center', 46 | justifyContent: 'center', 47 | }, 48 | inputRoot: { 49 | color: 'inherit', 50 | }, 51 | inputInput: { 52 | padding: theme.spacing(1, 1, 1, 7), 53 | transition: theme.transitions.create('width'), 54 | width: '100%', 55 | [theme.breakpoints.up('sm')]: { 56 | width: 120, 57 | '&:focus': { 58 | width: 200, 59 | }, 60 | }, 61 | }, 62 | })); 63 | 64 | export default function SearchAppBar() { 65 | const classes = useStyles(); 66 | 67 | return ( 68 |
69 | 70 |
71 |
72 | 73 |
74 | 82 |
83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/Settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import TextField from '@material-ui/core/TextField'; 4 | import Dialog from '@material-ui/core/Dialog'; 5 | import DialogActions from '@material-ui/core/DialogActions'; 6 | import DialogContent from '@material-ui/core/DialogContent'; 7 | import DialogContentText from '@material-ui/core/DialogContentText'; 8 | import { PhosPlayerContext } from "./PhosPlayerContext"; 9 | import Slider from '@material-ui/core/Slider'; 10 | 11 | 12 | export default function FormDialog() { 13 | const { state, dispatch } = React.useContext(PhosPlayerContext); 14 | const { openSettings } = state 15 | 16 | let [phosConfigURL, setPhosConfigURL] = React.useState(localStorage.getItem("phosConfigURL")) 17 | let [proxyUrl, setProxyUrl] = React.useState(localStorage.getItem("security.proxyUrl")) 18 | let [authCode, setAuthCode] = React.useState(localStorage.getItem("security.authCode")) 19 | let [background, setBackground] = React.useState(localStorage.getItem("style.background")) 20 | let [color, setColor] = React.useState(localStorage.getItem("style.color")) 21 | let [opacity, setOpacity] = React.useState(localStorage.getItem("style.opacity")) 22 | let [musicURL163, setMusicURL163] = React.useState('') 23 | 24 | 25 | function import163(url163) { 26 | let { data: { songs, albums, artists } } = state 27 | let [so, al, ar] = [songs, albums, artists] 28 | let url = new URL(url163) 29 | let id = url.searchParams.get("id") 30 | let apiURL = `https://music.aityp.com/song/detail?ids=${id}` 31 | fetch(apiURL).then(res => { 32 | return res.json() 33 | }).then(data => { 34 | let song = data.songs[0] 35 | let songAr = song.ar.map(ar => ar.name) 36 | let songAl = song.al 37 | 38 | let oldAl = al.rows.filter(al => al.name === song.al.name) 39 | let oldSo = so.rows.filter(s => s.title === song.name) 40 | let oldAr = ar.rows.filter(ar => songAr.includes(ar.name)) 41 | 42 | let newAl 43 | let newAr 44 | 45 | if (!oldAl.length) { 46 | newAl = [al.addRow({ 47 | name: songAl.name, 48 | cover: [songAl.picUrl] 49 | })] 50 | } else { 51 | newAl = oldAl 52 | } 53 | 54 | if (!oldAr.length) { 55 | newAr = songAr.map(arName => { 56 | return ar.addRow({ 57 | name: arName 58 | }) 59 | }) 60 | } else { 61 | // fixme 62 | newAr = oldAr 63 | } 64 | 65 | if (!oldSo.length) { 66 | so.addRow({ 67 | title: song.name, 68 | file: [`https://music.163.com/song/media/outer/url?id=${song.id}.mp3`], 69 | source: '163', 70 | artist: newAr, 71 | album: newAl 72 | }) 73 | dispatch({ 74 | type: 'showMsg', 75 | payload: { 76 | msg: '添加成功,重载数据。' 77 | } 78 | }) 79 | setTimeout(() => { 80 | window.location.reload() 81 | }, (1000)); 82 | } else { 83 | dispatch({ 84 | type: 'showMsg', 85 | payload: { 86 | msg: '歌曲已存在,无需再次添加。' 87 | } 88 | }) 89 | } 90 | }) 91 | } 92 | function handleClose() { 93 | dispatch({ 94 | type: 'setPlayerConfig', 95 | payload: { 96 | name: 'openSettings', 97 | value: false 98 | } 99 | }) 100 | } 101 | 102 | const handleSave = () => { 103 | localStorage.setItem("phosConfigURL", phosConfigURL) 104 | localStorage.setItem("security.proxyUrl", proxyUrl || '') 105 | localStorage.setItem("security.authCode", authCode || '') 106 | localStorage.setItem("style.background", background || '') 107 | localStorage.setItem("style.color", color || '') 108 | localStorage.setItem("style.opacity", opacity || 0.5) 109 | handleClose() 110 | // 清理分享链接,访问自己的配置 111 | window.location.href = window.location.origin 112 | } 113 | return ( 114 |
115 | 120 | 121 | 122 | 新版添加搜索功能,移动端暂未适配! 123 | 124 |

基础配置(访问公开数据)

125 | setPhosConfigURL(e.target.value)} 132 | fullWidth 133 | required 134 | /> 135 |

高级配置(访问私密数据)

136 | setProxyUrl(e.target.value)} 143 | fullWidth 144 | /> 145 | setAuthCode(e.target.value)} 152 | fullWidth 153 | /> 154 |

风格配置

155 | { 162 | setBackground(e.target.value) 163 | dispatch({ 164 | type: 'set', 165 | payload: { 166 | background: e.target.value 167 | } 168 | }) 169 | }} 170 | fullWidth 171 | /> 172 | { 181 | setOpacity(v) 182 | dispatch({ 183 | type: 'set', 184 | payload: { 185 | opacity: v 186 | } 187 | }) 188 | }} 189 | /> 190 | { 197 | setColor(e.target.value) 198 | dispatch({ 199 | type: 'set', 200 | payload: { 201 | color: e.target.value 202 | } 203 | }) 204 | }} 205 | fullWidth 206 | /> 207 |

导入音乐

208 |
209 | { 216 | setMusicURL163(e.target.value) 217 | }} 218 | fullWidth 219 | /> 220 | 223 |
224 |
225 | 226 | 227 | 用 Phos 管理自己的音乐/播客? help? 228 | 229 | 230 | 231 | 232 | 235 | 238 | 239 |
240 |
241 | ); 242 | } 243 | -------------------------------------------------------------------------------- /src/components/SiderList/AlbumList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemText from '@material-ui/core/ListItemText'; 6 | import { FixedSizeList } from 'react-window'; 7 | import { PhosPlayerContext } from '../PhosPlayerContext' 8 | import Button from '@material-ui/core/Button'; 9 | import Menu from '@material-ui/core/Menu'; 10 | import MenuItem from '@material-ui/core/MenuItem'; 11 | import Hidden from '@material-ui/core/Hidden'; 12 | 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | root: { 16 | width: '100%', 17 | height: '100%', 18 | // backgroundColor: theme.palette.background.paper, 19 | margin: '0 auto' 20 | }, 21 | col: { 22 | width: '100%' 23 | }, 24 | selected: { 25 | color: theme.palette.primary.main, 26 | borderLeft: `4px solid ${theme.palette.primary.main}` 27 | } 28 | })); 29 | 30 | function Row(props) { 31 | const { state, dispatch } = React.useContext(PhosPlayerContext) 32 | const { data, index, style } = props; 33 | const { albumName } = state 34 | let album = data[index] 35 | const classes = useStyles(); 36 | const isSelected = album.name === albumName 37 | return ( 38 | { 42 | dispatch({ 43 | type: 'setAlbumName', 44 | payload: { 45 | albumName: album.name === "全部专辑" ? undefined : album.name 46 | } 47 | }) 48 | } 49 | }> 50 | 51 | 52 | ); 53 | } 54 | 55 | Row.propTypes = { 56 | index: PropTypes.number.isRequired, 57 | style: PropTypes.object.isRequired, 58 | }; 59 | 60 | export default function VirtualizedList() { 61 | 62 | // > sm menu 63 | const { state, dispatch } = React.useContext(PhosPlayerContext) 64 | const { data, albumName, searchWord, searchType } = state 65 | const classes = useStyles(); 66 | let albums = data.albums && data.albums.rows || [] 67 | const height = window.innerHeight - 191 // 68 | 69 | if (searchType === 'al') { 70 | albums = albums.filter(s => s.name.includes(searchWord)) 71 | } 72 | 73 | return ( 74 |
75 | 76 |
77 | {/* 78 | 79 | */} 80 | { 84 | dispatch({ 85 | type: 'setAlbumName', 86 | payload: { 87 | albumName: undefined 88 | } 89 | }) 90 | } 91 | }> 92 | 93 | 94 | 101 | {Row} 102 | 103 |
104 |
105 |
106 | ) 107 | } -------------------------------------------------------------------------------- /src/components/SiderList/ArtistList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemText from '@material-ui/core/ListItemText'; 6 | import { FixedSizeList } from 'react-window'; 7 | import { PhosPlayerContext } from '../PhosPlayerContext' 8 | import Button from '@material-ui/core/Button'; 9 | import Menu from '@material-ui/core/Menu'; 10 | import MenuItem from '@material-ui/core/MenuItem'; 11 | import Hidden from '@material-ui/core/Hidden'; 12 | 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | root: { 16 | width: '100%', 17 | height: '100%', 18 | // backgroundColor: theme.palette.background.paper, 19 | margin: '0 auto' 20 | }, 21 | col: { 22 | width: '100%' 23 | }, 24 | selected: { 25 | color: theme.palette.primary.main, 26 | borderLeft: `4px solid ${theme.palette.primary.main}` 27 | } 28 | })); 29 | 30 | function Row(props) { 31 | const { state, dispatch } = React.useContext(PhosPlayerContext) 32 | const { data, index, style } = props; 33 | const { artistName } = state 34 | let artist = data[index] 35 | const classes = useStyles(); 36 | const isSelected = artist.name === artistName 37 | return ( 38 | { 42 | dispatch({ 43 | type: 'setArtistName', 44 | payload: { 45 | artistName: artist.name === "全部艺人" ? undefined : artist.name 46 | } 47 | }) 48 | } 49 | }> 50 | 51 | 52 | ); 53 | } 54 | 55 | Row.propTypes = { 56 | index: PropTypes.number.isRequired, 57 | style: PropTypes.object.isRequired, 58 | }; 59 | 60 | export default function VirtualizedList() { 61 | const { state, dispatch } = React.useContext(PhosPlayerContext) 62 | const { data, artistName, searchWord, searchType } = state 63 | const classes = useStyles(); 64 | let artists = data.artists && data.artists.rows || [] 65 | 66 | const height = window.innerHeight - 191 // 67 | if (searchType === 'ar') { 68 | artists = artists.filter(s => s && s.name.includes(searchWord)) 69 | } 70 | return ( 71 |
72 | 73 |
74 | {/* 75 | 76 | */} 77 | { 81 | dispatch({ 82 | type: 'setArtistName', 83 | payload: { 84 | artistName: undefined 85 | } 86 | }) 87 | } 88 | }> 89 | 90 | 91 | 98 | {Row} 99 | 100 |
101 |
102 |
103 | ) 104 | } -------------------------------------------------------------------------------- /src/components/SiderList/Playlist.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemText from '@material-ui/core/ListItemText'; 6 | import { FixedSizeList } from 'react-window'; 7 | import { PhosPlayerContext } from '../PhosPlayerContext' 8 | import Button from '@material-ui/core/Button'; 9 | import Menu from '@material-ui/core/Menu'; 10 | import MenuItem from '@material-ui/core/MenuItem'; 11 | import Hidden from '@material-ui/core/Hidden'; 12 | 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | root: { 16 | width: '100%', 17 | height: '100%', 18 | // backgroundColor: theme.palette.background.paper, 19 | margin: '0 auto' 20 | }, 21 | col: { 22 | width: '100%' 23 | }, 24 | selected: { 25 | color: theme.palette.primary.main, 26 | borderLeft: `4px solid ${theme.palette.primary.main}` 27 | } 28 | })); 29 | 30 | function Row(props) { 31 | const { state, dispatch } = React.useContext(PhosPlayerContext) 32 | const { data, index, style } = props; 33 | const { playlistName } = state 34 | let playlist = data[index] 35 | const classes = useStyles(); 36 | const isSelected = playlist.value === playlistName && !state.showNowPlaylist 37 | return ( 38 | { 42 | dispatch({ 43 | type: 'setPlaylistName', 44 | payload: { 45 | playlistName: playlist.value === "全部歌曲" ? undefined : playlist.value 46 | } 47 | }) 48 | } 49 | }> 50 | 51 | 52 | ); 53 | } 54 | 55 | Row.propTypes = { 56 | index: PropTypes.number.isRequired, 57 | style: PropTypes.object.isRequired, 58 | }; 59 | 60 | export default function VirtualizedList() { 61 | 62 | // > sm menu 63 | const { state, dispatch } = React.useContext(PhosPlayerContext) 64 | const { data, playlistName, searchType, searchWord, showNowPlaylist } = state 65 | const classes = useStyles(); 66 | let playlists = data.songs ? data.songs.schema.playlist.options : [] 67 | const height = window.innerHeight - 191 // 68 | 69 | if (searchType === 'pl') { 70 | playlists = playlists.filter(s => s && s.value.includes(searchWord)) 71 | } 72 | 73 | // < sm menu 74 | const [anchorEl, setAnchorEl] = React.useState(null); 75 | 76 | function handleClick(event) { 77 | setAnchorEl(event.currentTarget); 78 | } 79 | 80 | function handleClose(playlistName) { 81 | setAnchorEl(null); 82 | dispatch({ 83 | type: 'setPlaylistName', 84 | payload: { 85 | playlistName 86 | } 87 | }) 88 | } 89 | 90 | return ( 91 |
92 | 93 |
94 | {/* 95 | 96 | */} 97 | { 101 | dispatch({ 102 | type: 'setPlaylistName', 103 | payload: { 104 | playlistName: undefined 105 | } 106 | }) 107 | } 108 | }> 109 | 110 | 111 | 118 | {Row} 119 | 120 |
121 |
122 | {/* 123 |
124 | 127 | handleClose()} 133 | > 134 | handleClose(undefined)}>全部歌曲 135 | { 136 | playlists.map(playlist => handleClose(playlist.value)}>{playlist.value}) 137 | } 138 | 139 |
140 | 141 |
*/} 142 |
143 | ) 144 | } -------------------------------------------------------------------------------- /src/components/SiderList/SiderList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import Box from '@material-ui/core/Box'; 6 | import SearchIcon from '@material-ui/icons/Search'; 7 | import InputBase from '@material-ui/core/InputBase'; 8 | import InputAdornment from '@material-ui/core/InputAdornment'; 9 | import Slide from '@material-ui/core/Slide'; 10 | import Tooltip from '@material-ui/core/Tooltip'; 11 | import InfoIcon from '@material-ui/icons/Info'; 12 | import Hidden from '@material-ui/core/Hidden'; 13 | 14 | 15 | // 下面三个列表代码高度相似,后续抽象成一个通用组件。 16 | import Playlist from './Playlist' 17 | import ArtistList from './ArtistList' 18 | import AlbumList from './AlbumList' 19 | 20 | import { PhosPlayerContext } from '../PhosPlayerContext' 21 | 22 | function TabPanel(props) { 23 | const { children, value, index, ...other } = props; 24 | 25 | return ( 26 | 36 | ); 37 | } 38 | 39 | TabPanel.propTypes = { 40 | children: PropTypes.node, 41 | index: PropTypes.any.isRequired, 42 | value: PropTypes.any.isRequired, 43 | }; 44 | 45 | 46 | const useStyles = makeStyles(theme => ({ 47 | root: { 48 | flexGrow: 1, 49 | // backgroundColor: theme.palette.background.paper, 50 | }, 51 | nav: { 52 | display: 'flex', 53 | justifyContent: 'space-between', 54 | [theme.breakpoints.down('sm')]: { 55 | justifyContent: 'right', 56 | }, 57 | }, 58 | navItem: { 59 | padding: 10, 60 | color: '#999', 61 | '&:hover': { 62 | cursor: 'pointer' 63 | } 64 | }, 65 | searchIcon: { 66 | marginRight: '-20px', 67 | [theme.breakpoints.down('sm')]: { 68 | marginRight: 0, 69 | }, 70 | }, 71 | selectNavItem: { 72 | color: theme.palette.primary.main 73 | }, 74 | infoIcon: { 75 | color: '#eee', 76 | '$:hover': { 77 | cursor: 'pointer' 78 | } 79 | } 80 | })); 81 | 82 | export default function SimpleTabs() { 83 | const classes = useStyles(); 84 | const [value, setValue] = React.useState(0); 85 | const [q, setQ] = React.useState(); 86 | const [showInput, setShowInput] = React.useState(false); 87 | const { state, dispatch } = React.useContext(PhosPlayerContext) 88 | 89 | 90 | function handleSearchChange(e) { 91 | setQ(e.target.value) 92 | } 93 | 94 | function submitSearch() { 95 | if (/^[ap][rl][::]/.test(q) && !q.startsWith('pr')) { 96 | // 高级搜索 97 | let sType = q.slice(0, 2) 98 | let sWord = q.slice(3) 99 | const typeMap = { 100 | 'pl': 'playlistName', 101 | 'ar': 'artistName', 102 | 'al': 'albumName', 103 | } 104 | switch (sType) { 105 | case 'pl': 106 | setValue(0) 107 | break 108 | case 'ar': 109 | setValue(1) 110 | break 111 | case 'al': 112 | setValue(2) 113 | break 114 | } 115 | 116 | 117 | dispatch({ 118 | type: 'set', 119 | payload: { 120 | searchWord: sWord, 121 | searchType: sType, 122 | // filterBy: typeMap[sType], 123 | // [typeMap[sType]]: sWord 124 | } 125 | }) 126 | } else { 127 | dispatch({ 128 | type: 'set', 129 | payload: { 130 | searchWord: q, 131 | searchType: 'so' 132 | } 133 | }) 134 | } 135 | } 136 | function showSeachInput() { 137 | if (showInput) { 138 | dispatch({ 139 | type: 'set', 140 | payload: { 141 | searchWord: undefined, 142 | searchType: 'so' 143 | } 144 | }) 145 | } 146 | setShowInput(!showInput) 147 | } 148 | function handleChange(newValue) { 149 | setValue(newValue); 150 | 151 | // 切换 filter 是否需要重置列表 152 | // const indexClearNameMap = { 153 | // 0: ['setArtistName', 'setAlbumName'], 154 | // 1: ['setPlaylistName', 'setAlbumName'], 155 | // 2: ['setPlaylistName', 'setArtistName'], 156 | // } 157 | 158 | // const actionPayloadNameMap = { 159 | // setPlaylistName: 'playlistName', 160 | // setArtistName: 'artistName', 161 | // setAlbumName: 'albumName' 162 | // } 163 | 164 | // indexClearNameMap[newValue].map(action => { 165 | // dispatch({ 166 | // type: action, 167 | // payload: { 168 | // [actionPayloadNameMap[action]]: undefined 169 | // } 170 | // }) 171 | // }) 172 | } 173 | 174 | return ( 175 |
176 | 177 |
178 | 179 | handleChange(0)}> 歌单 180 | 181 | 182 | handleChange(1)}> 艺人 183 | 184 | 185 | handleChange(2)}> 专辑 186 | 187 | 188 | 194 | { 198 | if (ev.key === 'Enter') { 199 | ev.preventDefault() 200 | submitSearch() 201 | } 202 | }} 203 | endAdornment={ 204 | 205 | 206 | 207 |
默认搜索歌曲名称,添加前缀可以使用高级搜索
208 |
pl:歌单名
209 |
ar:艺人名
210 |
al:专辑名
211 |
}> 212 | 213 | 214 | 215 | 216 | } 217 | /> 218 | 219 | 220 |
221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | ); 233 | } 234 | -------------------------------------------------------------------------------- /src/components/SongList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemText from '@material-ui/core/ListItemText'; 6 | import { FixedSizeList } from 'react-window'; 7 | import { PhosPlayerContext } from './PhosPlayerContext' 8 | import Hidden from '@material-ui/core/Hidden'; 9 | import useMediaQuery from '@material-ui/core/useMediaQuery'; 10 | import { useTheme } from '@material-ui/core/styles'; 11 | import logo_163 from '../static/logo_163.jpg' 12 | import logo_notion from '../static/logo_notion.png' 13 | import logo_ytb from '../static/logo_ytb.png' 14 | import Menu from '@material-ui/core/Menu'; 15 | import MenuItem from '@material-ui/core/MenuItem'; 16 | import ClickAwayListener from '@material-ui/core/ClickAwayListener'; 17 | import MenuList from '@material-ui/core/MenuList'; 18 | import Paper from '@material-ui/core/Paper'; 19 | import Popover from '@material-ui/core/Popover'; 20 | import Button from '@material-ui/core/Button'; 21 | 22 | const useStyles = makeStyles(theme => ({ 23 | root: { 24 | width: '100%', 25 | height: '100%', 26 | maxWidth: 1200, 27 | // backgroundColor: '#eee', 28 | margin: '0 auto' 29 | }, 30 | title: { 31 | height: 42 32 | }, 33 | active: { 34 | color: theme.palette.primary.main 35 | }, 36 | nav: { 37 | paddingLeft: 16 38 | }, 39 | col: { 40 | width: '30%' 41 | }, 42 | smCol: { 43 | [theme.breakpoints.down('sm')]: { 44 | marginBottom: 10 45 | }, 46 | }, 47 | noSourceSong: { 48 | color: '#888' 49 | }, 50 | 51 | nowPlayingSong: { 52 | color: theme.palette.primary.main 53 | }, 54 | logo: { 55 | marginBottom: -2, 56 | marginRight: 5, 57 | width: 16, 58 | height: 16 59 | }, 60 | addPlaylist: { 61 | width: 100 62 | } 63 | })); 64 | 65 | 66 | 67 | function Row(props) { 68 | const { state, dispatch } = React.useContext(PhosPlayerContext) 69 | const { data, index, style } = props; 70 | let song = data[index] 71 | let artists = song.artist ? song.artist.filter(i => !!i).map(a => a.name).join(",") : '未知' 72 | 73 | let album 74 | try { 75 | album = song.album ? song.album[0] ? song.album[0].name : '未知' : '未知' 76 | } catch (error) { 77 | console.log(song.album) 78 | } 79 | 80 | const playlists = state.allPlaylist 81 | 82 | 83 | const classes = useStyles(); 84 | const [anchorEl, setAnchorEl] = React.useState(null); 85 | 86 | const [showPlaylist, setShowPlaylist] = React.useState(null); 87 | 88 | const open = Boolean(anchorEl) && Boolean(showPlaylist); 89 | const id = open ? 'simple-popover' : undefined; 90 | 91 | 92 | function handlePlaySong(e) { 93 | if (e.type === 'click' && !Boolean(anchorEl)) { 94 | dispatch({ 95 | type: 'playOneSong', 96 | payload: { song } 97 | }) 98 | } else if (e.type === 'contextmenu') { 99 | console.log('Right click'); 100 | } 101 | } 102 | function handleEnter(event) { 103 | event.preventDefault() 104 | setShowPlaylist(event.currentTarget); 105 | } 106 | 107 | function handleLeave(event) { 108 | event.preventDefault() 109 | setShowPlaylist(null); 110 | } 111 | 112 | function handleRightClick(event) { 113 | console.log(song) 114 | event.preventDefault() 115 | setAnchorEl(event.currentTarget); 116 | } 117 | 118 | function handleClose(event) { 119 | setAnchorEl(null); 120 | setShowPlaylist(null); 121 | } 122 | 123 | function handleAddOneSongToCurrentPlaylist() { 124 | dispatch({ 125 | type: 'addOneSongToCurrentPlaylist', 126 | payload: { 127 | song 128 | } 129 | }) 130 | handleClose() 131 | } 132 | function handleAddSongToPlaylist(p) { 133 | console.log(song.playlist) 134 | song.playlist = song.playlist ? [...song.playlist, p] : [p] 135 | handleClose() 136 | } 137 | 138 | function handleRemoveSongFromPlaylist() { 139 | song.playlist = song.playlist.filter(i => !(i == state.playlistName)) 140 | handleClose() 141 | } 142 | 143 | function handleRemoveSongFromCurrentPlaylist() { 144 | dispatch({ 145 | type: 'set', 146 | payload: { 147 | currentPlaylist: state.currentPlaylist.filter(i => !(i.title === song.title)) 148 | } 149 | 150 | }) 151 | handleClose() 152 | } 153 | 154 | const { currentPlaySong } = state 155 | const getRowClass = () => { 156 | if (song.title === currentPlaySong.title) { 157 | return classes.nowPlayingSong 158 | } else if (!song.file && !song.id_163) { 159 | return classes.noSourceSong 160 | } 161 | } 162 | 163 | const getSourceLogo = () => { 164 | let logo 165 | switch (song.source) { 166 | case "file": 167 | logo = logo_notion 168 | break 169 | case "163": 170 | logo = logo_163 171 | break 172 | case "ytb": 173 | logo = logo_ytb 174 | break 175 | default: 176 | logo = logo_notion 177 | } 178 | return logo 179 | } 180 | let logo = getSourceLogo() 181 | return ( 182 | 187 | 188 |
189 | 196 | {!state.showNowPlaylist && 加入当前播放列表} 197 | { 198 | !state.showNowPlaylist && state.playlistName && {state.playlistName}中移除此歌曲 199 | } 200 | { 201 | state.showNowPlaylist && 从当前播放队列中移除此歌曲 202 | } 203 | {/* 210 | 添加到歌单 211 | 224 | 225 | 226 | { 227 | song.playlist 228 | ? playlists.filter(p => !song.playlist.includes(p)).map(p => handleAddSongToPlaylist(p)}>{p}) 229 | : playlists.map(p => handleAddSongToPlaylist(p)}>{p}) 230 | } 231 | 232 | 233 | 234 | 235 | */} 236 | 237 |
238 |
239 | 240 | {song.title}} className={classes.col} /> 241 | 242 | 243 | 244 | 245 | 246 | {song.title}} 248 | secondary={`${artists} - ${album}`} 249 | /> 250 | 251 | 252 |
253 | ); 254 | } 255 | 256 | Row.propTypes = { 257 | index: PropTypes.number.isRequired, 258 | style: PropTypes.object.isRequired, 259 | }; 260 | 261 | export default function VirtualizedList() { 262 | const { state, dispatch } = React.useContext(PhosPlayerContext) 263 | const { data, playlistName, artistName, albumName, filterBy, searchWord, searchType, showNowPlaylist, currentPlaylist } = state 264 | const classes = useStyles(); 265 | let songlist = data.songs ? data.songs.rows : [] 266 | 267 | const theme = useTheme(); 268 | const matches = useMediaQuery(theme.breakpoints.down('sm')); 269 | 270 | const height = window.innerHeight - 185 271 | 272 | function playSongs() { 273 | // 播放第一首 274 | dispatch({ 275 | type: 'playOneSong', 276 | payload: { 277 | song: songlist[0] 278 | } 279 | }) 280 | dispatch({ 281 | type: 'set', 282 | payload: { 283 | currentPlaylist: songlist 284 | } 285 | }) 286 | } 287 | 288 | if (songlist) { 289 | if (showNowPlaylist) { 290 | // showNowPlaylist 显示当前播放列表 291 | songlist = currentPlaylist 292 | } else { 293 | // 首先基于歌单艺人和专辑过滤歌曲 294 | // 非移动端点击歌单艺人和专辑时,名称精确匹配 295 | switch (filterBy) { 296 | case 'playlistName': 297 | if (playlistName) songlist = songlist.filter(item => item.playlist && item.playlist.includes(playlistName)) 298 | break 299 | case 'artistName': 300 | if (artistName) songlist = songlist.filter(item => item.artist && item.artist.filter(i => i).map(a => a.name).includes(artistName)) 301 | break 302 | case 'albumName': 303 | if (albumName) songlist = songlist.filter(item => item.album && item.album.filter(i => i).map(a => a.name).includes(albumName)) 304 | break 305 | } 306 | 307 | // 如果存在搜索词,则再次过滤 308 | // 模糊名称匹配 309 | if (searchWord) { 310 | switch (searchType) { 311 | case 'so': 312 | let reg = new RegExp(searchWord, 'i') 313 | songlist = songlist.filter(item => reg.test(item.title)) 314 | break 315 | case 'pl': 316 | songlist = songlist.filter(item => item.playlist && item.playlist.join("").includes(searchWord)) 317 | break 318 | case 'ar': 319 | songlist = songlist.filter(item => item.artist && item.artist.filter(i => i).map(a => a.name).join("").includes(searchWord)) 320 | break 321 | case 'al': 322 | songlist = songlist.filter(item => item.album && item.album.filter(i => i).map(a => a.name).join("").includes(searchWord)) 323 | break 324 | } 325 | } 326 | } 327 | } 328 | 329 | // 按创建时间排序 330 | songlist = songlist.sort((a, b) => { 331 | return a.created_time > b.created_time ? -1 : 0 332 | }) 333 | 334 | return ( 335 | <> 336 |
337 | {showNowPlaylist ? 播放队列 : } 341 | {!showNowPlaylist && filterBy === 'playlistName' ? playlistName && `歌单:${playlistName} 中的歌曲` : ''} 342 | {!showNowPlaylist && filterBy === 'artistName' ? artistName && `艺人:${artistName} 的歌曲` : ''} 343 | {!showNowPlaylist && filterBy === 'albumName' ? albumName && `专辑:${albumName} 中的歌曲` : ''} 344 | {!showNowPlaylist && !playlistName && !artistName && !albumName && "全部歌曲"} 345 |
346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 362 | 363 | {Row} 364 | 365 | 366 | ); 367 | } 368 | -------------------------------------------------------------------------------- /src/components/utils.js: -------------------------------------------------------------------------------- 1 | export function getSongArtists(song) { 2 | let artists 3 | if (song) { 4 | artists = `${song.artist && song.artist.length ? song.artist.filter(i => !!i).map(a => a.name).join(",") : '未知'}` 5 | } else { 6 | artists = '' 7 | } 8 | 9 | return artists 10 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | import { PhosPlayerProvider } from "./components/PhosPlayerContext"; 7 | 8 | ReactDOM.render(, document.getElementById('root')); 9 | 10 | // If you want your app to work offline and load faster, you can change 11 | // unregister() to register() below. Note this comes with some pitfalls. 12 | // Learn more about service workers: https://bit.ly/CRA-PWA 13 | serviceWorker.register(); 14 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } -------------------------------------------------------------------------------- /src/static/logo_163.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayneyao/phos/ebc20a7fbf2dd96bb8d4b1c516382d9d7ada8e2b/src/static/logo_163.jpg -------------------------------------------------------------------------------- /src/static/logo_notion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayneyao/phos/ebc20a7fbf2dd96bb8d4b1c516382d9d7ada8e2b/src/static/logo_notion.png -------------------------------------------------------------------------------- /src/static/logo_ytb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayneyao/phos/ebc20a7fbf2dd96bb8d4b1c516382d9d7ada8e2b/src/static/logo_ytb.png -------------------------------------------------------------------------------- /src/sw.js: -------------------------------------------------------------------------------- 1 | 2 | if (workbox) { 3 | console.log(`Yay! Workbox is loaded 🎉`); 4 | workbox.core.setCacheNameDetails({ 5 | prefix: 'phos', 6 | suffix: 'v2' 7 | }); 8 | 9 | // 优先从缓存中获取 API 基础数据,然后获取新的数据更新缓存 10 | workbox.routing.registerRoute( 11 | ({ url, event }) => { 12 | if (url.pathname.startsWith("/api/v3")) { 13 | return true 14 | } 15 | }, 16 | new workbox.strategies.StaleWhileRevalidate(), 17 | ); 18 | 19 | // 优先从缓存中获取 20 | workbox.routing.registerRoute( 21 | ({ url, event }) => { 22 | if (url.href.startsWith("https://s3.us-west-2.amazonaws.com/") || 23 | url.href.startsWith("https://www.notion.so/signed") || 24 | url.href.startsWith("https://notion.so/signed/") 25 | ) { 26 | return true 27 | } 28 | }, 29 | new workbox.strategies.CacheFirst({ 30 | plugins: [ 31 | // 成功响应的文件可以被缓存 有时候会返回 206 部分内容也缓存 32 | new workbox.cacheableResponse.Plugin({ 33 | statuses: [0, 210] 34 | }) 35 | ] 36 | }), 37 | ); 38 | } 39 | --------------------------------------------------------------------------------