├── .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 | [](https://app.netlify.com/sites/phos-music/deploys)
3 |
4 | PWA Music Player
5 | 
6 |
7 | Data in your Notion's database
8 | 
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 |
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 |
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 |
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 |
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 |
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 |
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 |
34 | {children}
35 |
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 |
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 |
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 |
--------------------------------------------------------------------------------