Repository: fi3ework/react-cloud-music Branch: master Commit: baf392c6e5d4 Files: 136 Total size: 138.5 KB Directory structure: gitextract_hgejng1n/ ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── .prettierrc.js ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── config/ │ ├── env.js │ ├── jest/ │ │ ├── cssTransform.js │ │ ├── fileTransform.js │ │ └── typescriptTransform.js │ ├── paths.js │ ├── polyfills.js │ ├── webpack.config.dev.js │ ├── webpack.config.prod.js │ └── webpackDevServer.config.js ├── images.d.ts ├── package.json ├── public/ │ ├── index.html │ └── manifest.json ├── scripts/ │ ├── build.js │ ├── start.js │ └── test.js ├── src/ │ ├── App.css.d.ts │ ├── App.tsx │ ├── components/ │ │ ├── Carousel/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ ├── Cover/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ ├── Matrix/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ ├── SectionTitle/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ ├── Track/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ └── TrackList/ │ │ ├── index.tsx │ │ ├── style.scss │ │ ├── style.scss.d.ts │ │ └── view.tsx │ ├── constant/ │ │ ├── api.tsx │ │ └── style.scss │ ├── index.css │ ├── index.css.d.ts │ ├── index.tsx │ ├── layouts/ │ │ ├── BottomBar/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ ├── ExploreHeaderBar/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ ├── GithubFork/ │ │ │ └── index.tsx │ │ └── HeaderBar/ │ │ ├── index.tsx │ │ ├── style.scss │ │ ├── style.scss.d.ts │ │ └── view.tsx │ ├── pages/ │ │ ├── Account/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ └── style.scss.d.ts │ │ ├── Explore/ │ │ │ ├── Banner/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── style.scss.d.ts │ │ │ │ └── view.tsx │ │ │ ├── Custom.tsx │ │ │ ├── List.tsx │ │ │ ├── ListCover/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── style.scss.d.ts │ │ │ │ └── view.tsx │ │ │ ├── RecommendList/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── style.scss.d.ts │ │ │ │ └── view.tsx │ │ │ ├── Slider.tsx │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ └── style.scss.d.ts │ │ ├── Friends/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ └── style.scss.d.ts │ │ ├── Mine/ │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ └── style.scss.d.ts │ │ ├── Playing/ │ │ │ ├── ControlBar/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── style.scss.d.ts │ │ │ │ └── view.tsx │ │ │ ├── HeaderBar/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── style.scss.d.ts │ │ │ │ └── view.tsx │ │ │ ├── RotatingCover/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── style.scss.d.ts │ │ │ │ └── view.tsx │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ ├── Playlist/ │ │ │ ├── Header/ │ │ │ │ ├── index.tsx │ │ │ │ ├── style.scss │ │ │ │ ├── style.scss.d.ts │ │ │ │ └── view.tsx │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ ├── style.scss.d.ts │ │ │ └── view.tsx │ │ └── Video/ │ │ ├── index.tsx │ │ ├── style.scss │ │ └── style.scss.d.ts │ ├── registerServiceWorker.ts │ ├── router/ │ │ ├── index.tsx │ │ ├── routerTrans.scss │ │ ├── routerTrans.scss.d.ts │ │ └── slideContext.tsx │ ├── store.tsx │ └── utils/ │ ├── calcFunctions.tsx │ ├── ee.tsx │ └── models/ │ ├── componentFetchModel.tsx │ └── index.tsx ├── tsconfig.json ├── tsconfig.prod.json ├── tsconfig.test.json └── tslint.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ /config/* /src/registerServiceWorker.js /node_modules/* ================================================ FILE: .eslintrc.js ================================================ module.exports = { extends: ['eslint-config-alloy/typescript-react', 'prettier', 'prettier/react'], plugins: ['prettier', 'typescript'], globals: { // 这里填入你的项目需要的全局变量 // 这里值为 false 表示这个全局变量不允许被重新赋值,比如: // // React: false, // ReactDOM: false }, parser: 'typescript-eslint-parser', rules: { // 这里填入你的项目需要的个性化配置,比如: // // // @fixable 一个缩进必须用两个空格替代 semi: ['error', 'never'], // 'no-console': 'off', 'no-unused-vars': [ 'warn', { vars: 'all', args: 'none', caughtErrors: 'none' } ], 'max-nested-callbacks': 'off', 'react/no-children-prop': 'off', 'typescript/member-ordering': 'off', 'typescript/member-delimiter-style': 'off', 'react/jsx-indent-props': 'off', 'react/no-did-update-set-state': 'off', indent: [ 'off', 2, { SwitchCase: 1, flatTernaryExpressions: true } ] } } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/ignore-files/ for more about ignoring files. # dependencies /node_modules # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: .postcssrc.js ================================================ module.exports = { "plugins": { "postcss-import": {}, "postcss-url": {}, "postcss-aspect-ratio-mini": {}, "postcss-write-svg": { utf8: false }, "postcss-cssnext": {}, "postcss-px-to-viewport": { viewportWidth: 750, viewportHeight: 1334, // (Number) The height of the viewport. unitPrecision: 3, // (Number) The decimal numbers to allow the REM units to grow to. viewportUnit: 'vw', // (String) Expected units. selectorBlackList: ['.ignore', '.hairlines'], // (Array) The selectors to ignore and leave as px. minPixelValue: 1, // (Number) Set the minimum pixel value to replace. mediaQuery: false // (Boolean) Allow px to be converted in media queries. }, "postcss-viewport-units": {}, "cssnano": { preset: "advanced", autoprefixer: false, "postcss-zindex": false } } } ================================================ FILE: .prettierrc.js ================================================ module.exports = { printWidth: 120, tabWidth: 2, // useTabs: false, semi: false, singleQuote: true // trailingComma: 'none' // bracketSpacing: true, // jsxBracketSameLine: false, // arrowParens: 'avoid', // rangeStart: 0, // rangeEnd: Infinity, // proseWrap: "preserve" } ================================================ FILE: .vscode/settings.json ================================================ { "javascript.implicitProjectConfig.experimentalDecorators": true, "files.exclude": { "**/.git": true }, "cSpell.words": ["NETEASE"] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Wee Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ 🎶 基于 React 实现的仿 iOS 客户端网易云音乐。 在线地址:**[戳我](http://118.24.21.99:5001/)**(PC 浏览器需切换到移动端模式) 移动端体验: ![qr](./docs/qr.png) ## 预览 ![preview](./docs/preview.png) ## 技术栈 - React 16.3 - TypeScript - Mobx + Redux - react-redux - react-router-v4 - Scss ## 实现细节 目前只实现了上面四个页面,但是总体的结构已经形成了,其他页面的添加只是时间上的问题 ~~(其实是懒)~~,暂时没有实现,下面是目前已实现的功能的细节: ### 局部状态管理 像首页的 banner 或者推荐歌单等,都是不会被共享的局部状态,使用 Mobx 来进行请求的发起和状态的管理。 ### 全局状态 播放器的状态是一个全局状态,包括当前的播放列表,切歌,播放 / 暂停等,所以很自然的使用 redux 来进行管理,可以清楚的掌握所有改变全局状态的行为。 ### TypeScript 尽管上手需要掌握一些语法,但是静态类型与自动提示都能提供很大的帮助,在这个并不大的项目中我也体验到了很大的帮助。但是要注意的是 TS 其实并不严格限制对象的类型,只要够懒,遍地 any,就会把 TS 写成 JS,所以为了充分发挥 TS 的威力,一定要有良好的 TS 代码风格。 ### 手势滑动 为了模仿 iOS 端可以通过滑屏切换页面的功能,通过监听 `touchStart`,`touchMove`,`touchEnd` 来进行手势的判断并通过 `transform` 触发模拟滚动实现,在 `touchMove` 中检测监听滑动的方向及距离,在 `touchEnd` 中触发路由的切换及页面吸附到整屏的位置。 ### 歌单的状态保留 有这么一个操作需要注意:用户在某歌单往下滑了几下,然后点了某歌播放然后进入了播放器,会发生路由的改变,如果此时从播放器返回,会丢包包括滚动位置在内的歌单页的所有状态丢失(因为 re-mount 了)。 我造了一个轮子来解决这个问题:[react-live-route](https://github.com/fi3ework/react-live-route),是对 react-router-v4 中 Route 组件的增强,简单的说就是将歌单页隐藏掉而不是 unmount 掉,具体的解决思路可以参考轮子里的文档。 ### 跨组件传递状态 在 iOS 版的网易云中,可以滑动来切换页面,同时会触发顶部 tab 下的滑块移动。在项目中,滑动页面与滑块分属于两个兄弟组件的子组件且嵌套层次较深,如果直接通过 prop 来传递略显丑陋,有如下解决方案: 1. 通过 redux,但是 redux 最好只负责领域数据,这种 UI 的状态就不要往 store 中放了。 2. 通过 event-emitter,其实和 redux 差不多,因为 redux 也是基于 event-emitter 实现的, 但是不经过 react-redux 虽然可以实现,但是破坏了 react 整个自顶向下界面更新的原则。 3. 通过新的 context API 实现,如下图: ![context](./docs/context.png) ## API 项目中用到的网易云音乐的 API 来自 [NeteaseCloudMusicApi](https://binaryify.github.io/NeteaseCloudMusicApi/#/?id=neteasecloudmusicapi)。 ## TODO 目前还有一些部分没完成,包括但不限于: - [ ] code splitting - [ ] 组件中有些功能还是有耦合,需要再抽象 - [ ] SSR ## 开发 克隆代码到本地之后,需要在 4000 端口运行 [NeteaseCloudMusicApi](https://binaryify.github.io/NeteaseCloudMusicApi/#/?id=neteasecloudmusicapi)。 ================================================ FILE: config/env.js ================================================ 'use strict'; const fs = require('fs'); const path = require('path'); const paths = require('./paths'); // Make sure that including paths.js after env.js will read .env variables. delete require.cache[require.resolve('./paths')]; const NODE_ENV = process.env.NODE_ENV; if (!NODE_ENV) { throw new Error( 'The NODE_ENV environment variable is required but was not specified.' ); } // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use var dotenvFiles = [ `${paths.dotenv}.${NODE_ENV}.local`, `${paths.dotenv}.${NODE_ENV}`, // Don't include `.env.local` for `test` environment // since normally you expect tests to produce the same // results for everyone NODE_ENV !== 'test' && `${paths.dotenv}.local`, paths.dotenv, ].filter(Boolean); // Load environment variables from .env* files. Suppress warnings using silent // if this file is missing. dotenv will never modify any environment variables // that have already been set. Variable expansion is supported in .env files. // https://github.com/motdotla/dotenv // https://github.com/motdotla/dotenv-expand dotenvFiles.forEach(dotenvFile => { if (fs.existsSync(dotenvFile)) { require('dotenv-expand')( require('dotenv').config({ path: dotenvFile, }) ); } }); // We support resolving modules according to `NODE_PATH`. // This lets you use absolute paths in imports inside large monorepos: // https://github.com/facebookincubator/create-react-app/issues/253. // It works similar to `NODE_PATH` in Node itself: // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. // Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. // https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 // We also resolve them to make sure all tools using them work consistently. const appDirectory = fs.realpathSync(process.cwd()); process.env.NODE_PATH = (process.env.NODE_PATH || '') .split(path.delimiter) .filter(folder => folder && !path.isAbsolute(folder)) .map(folder => path.resolve(appDirectory, folder)) .join(path.delimiter); // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be // injected into the application via DefinePlugin in Webpack configuration. const REACT_APP = /^REACT_APP_/i; function getClientEnvironment(publicUrl) { const raw = Object.keys(process.env) .filter(key => REACT_APP.test(key)) .reduce( (env, key) => { env[key] = process.env[key]; return env; }, { // Useful for determining whether we’re running in production mode. // Most importantly, it switches React into the correct mode. NODE_ENV: process.env.NODE_ENV || 'development', // Useful for resolving the correct path to static assets in `public`. // For example, . // This should only be used as an escape hatch. Normally you would put // images into the `src` and `import` them in code to get their paths. PUBLIC_URL: publicUrl, } ); // Stringify all values so we can feed into Webpack DefinePlugin const stringified = { 'process.env': Object.keys(raw).reduce( (env, key) => { env[key] = JSON.stringify(raw[key]); return env; }, {} ), }; return { raw, stringified }; } module.exports = getClientEnvironment; ================================================ FILE: config/jest/cssTransform.js ================================================ 'use strict'; // This is a custom Jest transformer turning style imports into empty objects. // http://facebook.github.io/jest/docs/en/webpack.html module.exports = { process() { return 'module.exports = {};'; }, getCacheKey() { // The output is always the same. return 'cssTransform'; }, }; ================================================ FILE: config/jest/fileTransform.js ================================================ 'use strict'; const path = require('path'); // This is a custom Jest transformer turning file imports into filenames. // http://facebook.github.io/jest/docs/en/webpack.html module.exports = { process(src, filename) { return `module.exports = ${JSON.stringify(path.basename(filename))};`; }, }; ================================================ FILE: config/jest/typescriptTransform.js ================================================ // Copyright 2004-present Facebook. All Rights Reserved. 'use strict'; const tsJestPreprocessor = require('ts-jest/preprocessor'); module.exports = tsJestPreprocessor; ================================================ FILE: config/paths.js ================================================ 'use strict'; const path = require('path'); const fs = require('fs'); const url = require('url'); // Make sure any symlinks in the project folder are resolved: // https://github.com/facebookincubator/create-react-app/issues/637 const appDirectory = fs.realpathSync(process.cwd()); const resolveApp = relativePath => path.resolve(appDirectory, relativePath); const envPublicUrl = process.env.PUBLIC_URL; function ensureSlash(path, needsSlash) { const hasSlash = path.endsWith('/'); if (hasSlash && !needsSlash) { return path.substr(path, path.length - 1); } else if (!hasSlash && needsSlash) { return `${path}/`; } else { return path; } } const getPublicUrl = appPackageJson => envPublicUrl || require(appPackageJson).homepage; // We use `PUBLIC_URL` environment variable or "homepage" field to infer // "public path" at which the app is served. // Webpack needs to know it to put the right React Cloud Music
================================================ FILE: public/manifest.json ================================================ { "short_name": "React App", "name": "Create React App Sample", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } ], "start_url": "./index.html", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: scripts/build.js ================================================ 'use strict'; // Do this as the first thing so that any code reading it knows the right env. process.env.BABEL_ENV = 'production'; process.env.NODE_ENV = 'production'; // Makes the script crash on unhandled rejections instead of silently // ignoring them. In the future, promise rejections that are not handled will // terminate the Node.js process with a non-zero exit code. process.on('unhandledRejection', err => { throw err; }); // Ensure environment variables are read. require('../config/env'); const path = require('path'); const chalk = require('chalk'); const fs = require('fs-extra'); const webpack = require('webpack'); const config = require('../config/webpack.config.prod'); const paths = require('../config/paths'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); const printBuildError = require('react-dev-utils/printBuildError'); const measureFileSizesBeforeBuild = FileSizeReporter.measureFileSizesBeforeBuild; const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; const useYarn = fs.existsSync(paths.yarnLockFile); // These sizes are pretty large. We'll warn for bundles exceeding them. const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // Warn and crash if required files are missing if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { process.exit(1); } // First, read the current file sizes in build directory. // This lets us display how much they changed later. measureFileSizesBeforeBuild(paths.appBuild) .then(previousFileSizes => { // Remove all content but keep the directory so that // if you're in it, you don't end up in Trash fs.emptyDirSync(paths.appBuild); // Merge with the public folder copyPublicFolder(); // Start the webpack build return build(previousFileSizes); }) .then( ({ stats, previousFileSizes, warnings }) => { if (warnings.length) { console.log(chalk.yellow('Compiled with warnings.\n')); console.log(warnings.join('\n\n')); console.log( '\nSearch for the ' + chalk.underline(chalk.yellow('keywords')) + ' to learn more about each warning.' ); console.log( 'To ignore, add ' + chalk.cyan('// eslint-disable-next-line') + ' to the line before.\n' ); } else { console.log(chalk.green('Compiled successfully.\n')); } console.log('File sizes after gzip:\n'); printFileSizesAfterBuild( stats, previousFileSizes, paths.appBuild, WARN_AFTER_BUNDLE_GZIP_SIZE, WARN_AFTER_CHUNK_GZIP_SIZE ); console.log(); const appPackage = require(paths.appPackageJson); const publicUrl = paths.publicUrl; const publicPath = config.output.publicPath; const buildFolder = path.relative(process.cwd(), paths.appBuild); printHostingInstructions( appPackage, publicUrl, publicPath, buildFolder, useYarn ); }, err => { console.log(chalk.red('Failed to compile.\n')); printBuildError(err); process.exit(1); } ); // Create the production build and print the deployment instructions. function build(previousFileSizes) { console.log('Creating an optimized production build...'); let compiler = webpack(config); return new Promise((resolve, reject) => { compiler.run((err, stats) => { if (err) { return reject(err); } const messages = formatWebpackMessages(stats.toJson({}, true)); if (messages.errors.length) { // Only keep the first error. Others are often indicative // of the same problem, but confuse the reader with noise. if (messages.errors.length > 1) { messages.errors.length = 1; } return reject(new Error(messages.errors.join('\n\n'))); } if ( process.env.CI && (typeof process.env.CI !== 'string' || process.env.CI.toLowerCase() !== 'false') && messages.warnings.length ) { console.log( chalk.yellow( '\nTreating warnings as errors because process.env.CI = true.\n' + 'Most CI servers set it automatically.\n' ) ); return reject(new Error(messages.warnings.join('\n\n'))); } return resolve({ stats, previousFileSizes, warnings: messages.warnings, }); }); }); } function copyPublicFolder() { fs.copySync(paths.appPublic, paths.appBuild, { dereference: true, filter: file => file !== paths.appHtml, }); } ================================================ FILE: scripts/start.js ================================================ 'use strict'; // Do this as the first thing so that any code reading it knows the right env. process.env.BABEL_ENV = 'development'; process.env.NODE_ENV = 'development'; // Makes the script crash on unhandled rejections instead of silently // ignoring them. In the future, promise rejections that are not handled will // terminate the Node.js process with a non-zero exit code. process.on('unhandledRejection', err => { throw err; }); // Ensure environment variables are read. require('../config/env'); const fs = require('fs'); const chalk = require('chalk'); const webpack = require('webpack'); const WebpackDevServer = require('webpack-dev-server'); const clearConsole = require('react-dev-utils/clearConsole'); const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); const { choosePort, createCompiler, prepareProxy, prepareUrls, } = require('react-dev-utils/WebpackDevServerUtils'); const openBrowser = require('react-dev-utils/openBrowser'); const paths = require('../config/paths'); const config = require('../config/webpack.config.dev'); const createDevServerConfig = require('../config/webpackDevServer.config'); const useYarn = fs.existsSync(paths.yarnLockFile); const isInteractive = process.stdout.isTTY; // Warn and crash if required files are missing if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { process.exit(1); } // Tools like Cloud9 rely on this. const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; const HOST = process.env.HOST || '0.0.0.0'; if (process.env.HOST) { console.log( chalk.cyan( `Attempting to bind to HOST environment variable: ${chalk.yellow( chalk.bold(process.env.HOST) )}` ) ); console.log( `If this was unintentional, check that you haven't mistakenly set it in your shell.` ); console.log(`Learn more here: ${chalk.yellow('http://bit.ly/2mwWSwH')}`); console.log(); } // We attempt to use the default port but if it is busy, we offer the user to // run on a different port. `choosePort()` Promise resolves to the next free port. choosePort(HOST, DEFAULT_PORT) .then(port => { if (port == null) { // We have not found a port. return; } const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; const appName = require(paths.appPackageJson).name; const urls = prepareUrls(protocol, HOST, port); // Create a webpack compiler that is configured with custom messages. const compiler = createCompiler(webpack, config, appName, urls, useYarn); // Load proxy config const proxySetting = require(paths.appPackageJson).proxy; const proxyConfig = prepareProxy(proxySetting, paths.appPublic); // Serve webpack assets generated by the compiler over a web sever. const serverConfig = createDevServerConfig( proxyConfig, urls.lanUrlForConfig ); const devServer = new WebpackDevServer(compiler, serverConfig); // Launch WebpackDevServer. devServer.listen(port, HOST, err => { if (err) { return console.log(err); } if (isInteractive) { clearConsole(); } console.log(chalk.cyan('Starting the development server...\n')); openBrowser(urls.localUrlForBrowser); }); ['SIGINT', 'SIGTERM'].forEach(function(sig) { process.on(sig, function() { devServer.close(); process.exit(); }); }); }) .catch(err => { if (err && err.message) { console.log(err.message); } process.exit(1); }); ================================================ FILE: scripts/test.js ================================================ 'use strict'; // Do this as the first thing so that any code reading it knows the right env. process.env.BABEL_ENV = 'test'; process.env.NODE_ENV = 'test'; process.env.PUBLIC_URL = ''; // Makes the script crash on unhandled rejections instead of silently // ignoring them. In the future, promise rejections that are not handled will // terminate the Node.js process with a non-zero exit code. process.on('unhandledRejection', err => { throw err; }); // Ensure environment variables are read. require('../config/env'); const jest = require('jest'); let argv = process.argv.slice(2); // Watch unless on CI, in coverage mode, or explicitly running all tests if ( !process.env.CI && argv.indexOf('--coverage') === -1 && argv.indexOf('--watchAll') === -1 ) { argv.push('--watch'); } jest.run(argv); ================================================ FILE: src/App.css.d.ts ================================================ export const App: string; export const app: string; export const appLogo: string; export const appLogoSpin: string; export const appHeader: string; export const appTitle: string; export const appIntro: string; ================================================ FILE: src/App.tsx ================================================ import * as React from 'react' import Router from '@/router' class App extends React.Component { render() { return } } export default App ================================================ FILE: src/components/Carousel/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/components/Carousel/style.scss ================================================ :global { .swipe-wrapper { position: relative; } .swipe-item { text-align: center; width: 100%; .swipe-image { width: 100%; // height: 2.2rem; } } .swipe-dots { position: absolute; width: 100%; bottom: 0.07rem; text-align: center; .dot { display: inline-block; width: 15px; height: 3px; margin-right: 10px; background: #d6d7d8; &.active { background: #b4282d; } } } } .loading { background-color: #ccc; width: 100%; height: 300px; } ================================================ FILE: src/components/Carousel/style.scss.d.ts ================================================ export const loading: string; ================================================ FILE: src/components/Carousel/view.tsx ================================================ import React from 'react' import ReactSwipe from 'react-swipe' import style from './style.scss' type IProps = {} type IState = { index: number } export default class Carousel extends React.Component { swipe: ReactSwipe | null = null state = { index: 0 } swipeOpt: SwipeOptions = { auto: 5000, continuous: false, callback: index => { this.setState({ index }) } } dotClass = index => { return this.state.index === index ? 'dot active' : 'dot' } handleClickDot = index => { if (this.swipe !== null) { this.swipe.slide(index, 1000) } } render() { const children = this.props.children ? this.props.children :
return (
{ this.swipe = ref }} > {children}
{Array(React.Children.count(this.props.children)) .fill('ph') .map((val, index) => ( this.handleClickDot(index)} key={index} className={this.dotClass(index)} /> ))}
) } } ================================================ FILE: src/components/Cover/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/components/Cover/style.scss ================================================ .cover { width: 100%; height: 100%; position: relative; overflow: hidden; } .coverImg { display: block; width: 100%; border-radius: 5px; background-color: #ddd; } .playCountWrapper { position: absolute; font-size: 20px; color: #fff; text-shadow: 2px 1px 1px rgba(0, 0, 0, 0.2); margin: 0; top: 10px; right: 10px; } .playCountIcon { font-size: 18px !important; margin-right: 5px; } .listName { font-size: 24px; color: #333; margin: 10px auto; } .linkWrapper { display: block; width: 100%; } :global { .SVGInline { display: block; border-radius: 5px; background-color: #eee; } } ================================================ FILE: src/components/Cover/style.scss.d.ts ================================================ export const cover: string; export const coverImg: string; export const playCountWrapper: string; export const playCountIcon: string; export const listName: string; export const linkWrapper: string; ================================================ FILE: src/components/Cover/view.tsx ================================================ import React from 'react' import style from './style.scss' import { Link } from 'react-router-dom' import phSVG from './placeholder.svg' import SVGInline from 'react-svg-inline' import { calcPlayCount } from '@/utils/calcFunctions' import cs from 'classnames' type IProps = { coverImg: string listName: string path: string playCount?: number id?: string } type IState = { isLoading: boolean } class Cover extends React.Component { coverImg: HTMLImageElement | null state = { isLoading: true } componentDidUpdate() { if (this.state.isLoading === true) { const newImg = new Image() newImg.onload = () => { this.setState({ isLoading: false }) } if (this.props.coverImg) { newImg.src = this.props.coverImg } } } render() { const { coverImg, path, playCount, listName } = this.props const playCountShow = calcPlayCount(playCount) return (
{this.state.isLoading ? ( ) : ( { this.coverImg = ref }} /> )} {coverImg ? (

{listName}

{playCountShow}
) : null}
) } } export default Cover ================================================ FILE: src/components/Matrix/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/components/Matrix/style.scss ================================================ .row{ display: flex; justify-content: space-between; margin-bottom: 10px; } ================================================ FILE: src/components/Matrix/style.scss.d.ts ================================================ export const row: string; ================================================ FILE: src/components/Matrix/view.tsx ================================================ import React, { ReactNode, Children } from 'react' import style from './style.scss' type IProps = { cols?: number width: number children: ReactNode[] } const defaultProps: Partial = { cols: 3 } const Matrix: React.SFC = ({ children, cols, width }) => { if (!children) { return null } const layoutWidth = width ? `${width}%` : `${100 / (cols as number)}%` const rows: ReactNode[] = [] for (let i = 0; i < children.length; i += cols as number) { const currRow = children.slice(i, i + (cols as number)) rows.push( currRow.map((item, index) => { return (
{item}
) }) ) } // wrap a col const colElements = rows.map((row, index) => { return (
{row}
) }) return
{colElements}
} Matrix.defaultProps = defaultProps export default Matrix ================================================ FILE: src/components/SectionTitle/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/components/SectionTitle/style.scss ================================================ .title{ text-align: left; color: #333; margin: 20px 0 20px 10px; } ================================================ FILE: src/components/SectionTitle/style.scss.d.ts ================================================ export const title: string; ================================================ FILE: src/components/SectionTitle/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' const SectionTitle: React.SFC = ({ children }) => { return

{children}

} export default SectionTitle ================================================ FILE: src/components/Track/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/components/Track/style.scss ================================================ .trackWrapper { display: flex; background-color: #fff; color: #333; padding: 0 0 20px 0; } .index { display: flex; justify-content: center; flex-shrink: 0; align-items: center; width: 100px; text-align: center; color: #777; } .info { flex-grow: 1; color: #333; padding: 0 40px 10px 0; border-bottom: 1px solid #ddd; text-align: left; } .songName { margin-bottom: 10px; } .album { font-size: 20px; color: #888; } .bar { padding: 30px 100px; text-align: left; background-color: #fff; border-top-left-radius: 30px; border-top-right-radius: 30px; } .loading { border-top-left-radius: 30px; border-top-right-radius: 30px; height: 1000px; background-color: #fff; } ================================================ FILE: src/components/Track/style.scss.d.ts ================================================ export const trackWrapper: string; export const index: string; export const info: string; export const songName: string; export const album: string; export const bar: string; export const loading: string; ================================================ FILE: src/components/Track/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { Link } from 'react-router-dom' type IArtist = { name: string } type IAlbum = { name: string } type ITrackProps = { name: string artists: IArtist[] album: IAlbum index: number id: string play: any } export default class Track extends React.Component { handleClick: React.MouseEventHandler = e => { this.props.play() } render() { const { name, artists, album, index } = this.props return (
{index}
{name}
{artists[0].name} - {album.name}
) } } ================================================ FILE: src/components/TrackList/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/components/TrackList/style.scss ================================================ .trackWrapper { display: flex; background-color: #fff; color: #333; padding: 0 0 20px 0; } // bar .bar { display: flex; padding: 30px 0; text-align: left; background-color: #fff; border-top-left-radius: 30px; border-top-right-radius: 30px; color: #333; i { margin: 0; width: 100px; text-align: center; } } .playAllIcon { margin-right: 10px; } // list .index { display: flex; justify-content: center; align-items: center; width: 100px; text-align: center; color: #777; } .info { flex-grow: 1; color: #333; padding-bottom: 10px; border-bottom: 1px solid #ddd; text-align: left; } .album { font-size: 20px; color: #888; } .loading { border-top-left-radius: 30px; border-top-right-radius: 30px; height: 1000px; background-color: #fff; } ================================================ FILE: src/components/TrackList/style.scss.d.ts ================================================ export const trackWrapper: string; export const bar: string; export const playAllIcon: string; export const index: string; export const info: string; export const album: string; export const loading: string; ================================================ FILE: src/components/TrackList/view.tsx ================================================ import * as React from 'react' import { observer } from 'mobx-react' import get from 'lodash/get' import * as style from './style.scss' import Track from '@/components/Track' import cs from 'classnames' import PropTypes from 'prop-types' import { IPlayingSong, playCurrPlaylist } from '../../store' import { Link } from 'react-router-dom' type ITrackListProps = { payload: object | null } class Bar extends React.Component<{ tracksCount: number; tracks: any[] }> { static contextTypes = { store: PropTypes.object } generateSongs: () => IPlayingSong[] = () => { return this.props.tracks.map(track => { const song: IPlayingSong = { id: track.id, name: track.name, coverImg: track.album.picUrl, url: '', artists: track.artists.map(artist => artist.name), album: track.album.name } return song }) } playAllSongs: React.MouseEventHandler = () => { const songs = this.generateSongs() this.context.store.dispatch(playCurrPlaylist(songs)) } render() { return (
{`播放全部(共${this.props.tracksCount}首)`}
) } } @observer export default class TrackList extends React.Component { static contextTypes = { store: PropTypes.object } generateSongs: () => IPlayingSong[] = () => { console.log(this.props.payload) return get(this.props.payload, 'playlist.tracks').map(track => { const song: IPlayingSong = { id: track.id, name: track.name, coverImg: track.al.picUrl, url: '', artists: track.ar.map(artist => artist.name), album: track.al.name } return song }) } // tslint:disable-next-line:member-ordering getNormalizedSongs = (() => { let songs return () => { if (!songs) { songs = this.generateSongs() } return songs } })() playCertainSong = index => { return () => { this.context.store.dispatch(playCurrPlaylist(this.getNormalizedSongs(), index)) } } calcTracks = store => { const tracks = get(store, 'playlist.tracks') if (!tracks) { return null } else { return tracks.map((item, index) => { return ( ) }) } } render() { const tracks = this.calcTracks(this.props.payload) return (
{tracks ? (
{tracks}
) : (
)}
) } } ================================================ FILE: src/constant/api.tsx ================================================ import { compile } from 'path-to-regexp' type IPath = { path: string } type IApi = | string | { path: string } type Iapis = { banner: IApi recommendList: IApi recommendSong: IApi songDetail: IApi playlist: IApi songUrl: IApi songUrlBackUp: IApi list: IApi } const PROXY_HOST = process.env.NODE_ENV === 'production' ? 'http://118.24.21.99:4001' : '/api' const NETEASE_API: Iapis = { banner: '/banner', // 轮播图 recommendList: '/personalized', // 推荐歌单 recommendSong: '/personalized/newsong', // 推荐歌曲 // 歌单详情 playlist: { path: '/playlist/detail?id=:id' }, // 歌曲URL songUrl: { path: '/music/url?id=:ids' }, // 歌曲详情 songDetail: { path: '/song/detail?ids=:ids' }, // 歌曲 URL 备胎 songUrlBackUp: { path: 'http://music.163.com/song/media/outer/url?id=:id.mp3' }, // 排行榜 list: { path: '/top/list?idx=:idx' } } // 给 URL 添加 hostPath const addHost = (URL, hostPath) => { return hostPath + URL } export default NETEASE_API // 根据 API 和 params 来 compose URL export const getURL = (API: IApi, params?) => { // simple API if (!params) { return addHost(API, PROXY_HOST) } // complex API const toPath = compile(`${(API as IPath).path}`) const urlWithoutHost = toPath(params) return addHost(urlWithoutHost, PROXY_HOST) } ================================================ FILE: src/constant/style.scss ================================================ $ncmRed: #e24e48; // netease cloud music red $headerBarHeight: 100px; $bottomBarHeight: 100px; @font-face { font-family: 'iconfont-cloud-music'; /* project id 669459 */ src: url('//at.alicdn.com/t/font_669459_rskbi91kov.eot'); src: url('//at.alicdn.com/t/font_669459_rskbi91kov.eot?#iefix') format('embedded-opentype'), url('//at.alicdn.com/t/font_669459_rskbi91kov.woff') format('woff'), url('//at.alicdn.com/t/font_669459_rskbi91kov.ttf') format('truetype'), url('//at.alicdn.com/t/font_669459_rskbi91kov.svg#iconfont-cloud-music') format('svg'); } $base-font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', STHeiti, 'Microsoft YaHei', 'Microsoft JhengHei', 'Source Han Sans SC', 'Noto Sans CJK SC', 'Source Han Sans CN', 'Noto Sans SC', 'Source Han Sans TC', 'Noto Sans CJK TC', 'WenQuanYi Micro Hei', SimSun, sans-serif; :global { html, body, #root { -webkit-overflow-scrolling: touch; position: relative; height: 100%; overflow: hidden; font-family: $base-font-family; outline: 0; -webkit-text-size-adjust: none; -webkit-tap-highlight-color: transparent; } a { color: #fff; text-decoration: none; } img { content: normal !important; } .iconfont-ncm { font-family: 'iconfont-cloud-music' !important; font-size: 1rem; font-style: normal; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } } @function height-by-width($width) { @return (750 / 1334 * $width) * 1px; } ================================================ FILE: src/index.css ================================================ ================================================ FILE: src/index.css.d.ts ================================================ export {}; ================================================ FILE: src/index.tsx ================================================ import * as React from 'react' import * as ReactDOM from 'react-dom' import App from './App' import registerServiceWorker from './registerServiceWorker' import { Provider } from 'react-redux' import { createStore, applyMiddleware } from 'redux' import { reducers, defaultState } from './store' import promiseMiddleware from 'redux-promise' import thunkMiddleware from 'redux-thunk' const middlewares = [thunkMiddleware, promiseMiddleware] if (process.env.NODE_ENV === `development`) { const { logger } = require(`redux-logger`) middlewares.push(logger) } const store = createStore(reducers, defaultState as object, applyMiddleware(...middlewares)) ReactDOM.render( , document.getElementById('root') as HTMLElement ) registerServiceWorker() ================================================ FILE: src/layouts/BottomBar/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/layouts/BottomBar/style.scss ================================================ @import '@/constant/style.scss'; .bottomBar { position: absolute; left: 0; right: 0; height: 44px; bottom: 0; display: flex; bottom: 0; box-sizing: border-box; justify-content: space-between; align-items: center; width: 100%; height: $bottomBarHeight; margin: 0; padding: 10px; list-style-type: none; background-color: #ececec; border-top: 1px solid #eee; bottom: 0; li { width: 20%; text-align: center; } a { color: #555; display: flex; flex-direction: column; } .icon { font-size: 35px; font-weight: bold; margin: 0 0 10px 0; } .netease { font-weight: lighter; } } .title { font-size: 13px; } .activeLink { color: $ncmRed; } ================================================ FILE: src/layouts/BottomBar/style.scss.d.ts ================================================ export const bottomBar: string; export const icon: string; export const netease: string; export const title: string; export const activeLink: string; ================================================ FILE: src/layouts/BottomBar/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { Link, withRouter } from 'react-router-dom' import cs from 'classnames' class BottomBar extends React.Component { render() { const linkData = [ { router: 'explore', name: '发现', icon: '\ue67c', // TODO: 不能直接写  styleName: 'netease' }, { router: 'video', name: '视频', icon: '\ue61c' }, { router: 'mine', name: '我的', icon: '\ue680' }, { router: 'friends', name: '朋友', icon: '\ue60b' }, { router: 'account', name: '账号', icon: '\ue63b' } ] return (
    {linkData.map((item, index) => { let computedClassName = { 'iconfont-ncm': true, [style.icon]: true } if (typeof item.styleName === 'string') { computedClassName = { ...computedClassName, [style[item.styleName]]: true } } return (
  • {item.icon}
    {item.name}
  • ) })}
) } } export default withRouter(BottomBar) ================================================ FILE: src/layouts/ExploreHeaderBar/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/layouts/ExploreHeaderBar/style.scss ================================================ @import '@/constant/style.scss'; .headerBar { height: 170px; } .wrapper { position: relative; background-color: $ncmRed; margin: 10px 0 0 0; } .slideNav { display: flex; position: relative; padding: 0 0 10px 0; list-style-type: none; justify-content: center; a { width: 250px; padding: 10px 0; text-align: center; } } .slider { transition: all ease 0.4s; position: absolute; bottom: 0px; width: 100px; left: 0; height: 6px; background-color: #fff; border-radius: 3px; } .index0 { transform: translate(190px, 0); } .index1 { transform: translate(480px, 0); } .back { display: block; height: $headerBarHeight; color: rgba(255, 255, 255, 0.7); line-height: $headerBarHeight; font-weight: bolder; padding-left: 30px; i { font-size: 40px; } } ================================================ FILE: src/layouts/ExploreHeaderBar/style.scss.d.ts ================================================ export const headerBar: string; export const wrapper: string; export const slideNav: string; export const slider: string; export const index0: string; export const index1: string; export const back: string; ================================================ FILE: src/layouts/ExploreHeaderBar/view.tsx ================================================ import * as React from 'react' import BaseHeaderBar from '@/layouts/HeaderBar' import * as style from '@/layouts/ExploreHeaderBar/style.scss' import { Link } from 'react-router-dom' import cs from 'classnames' import { SlideContext } from '@/router/slideContext' type IState = { isGoingToStick: boolean prevPos: number isMounting: boolean pageIndex: number } type IProps = { component: any style: any pos: number pageIndex: number setPageIndex: any } class SlideNav extends React.Component { static defaultProps = { pageIndex: 0 } INDEX0_POS_X = window.screen.width * (200 / 750) INDEX1_POS_X = window.screen.width * (450 / 750) state = { prevPos: 0, isGoingToStick: true, isMounting: true, pageIndex: this.props.pageIndex } static getDerivedStateFromProps(nextProps, prevState) { return { isGoingToStick: nextProps.pos === prevState.prevPos, pageIndex: nextProps.pageIndex, prevPos: nextProps.pos } } componentDidMount() { this.setState({ isMounting: false }) } calcPosByPercent: (disXPercent: number) => number = disXPercent => { const disX = disXPercent * (this.INDEX1_POS_X - this.INDEX0_POS_X) + this[`INDEX${this.state.pageIndex}_POS_X`] return disX } changePage = clickedPageIndex => { this.props.setPageIndex(clickedPageIndex) } render() { let disX = this.calcPosByPercent(this.props.pos) if (this.state.isGoingToStick || this.state.isMounting) { disX = this[`INDEX${this.state.pageIndex}_POS_X`] } return (
this.changePage(0)}> 个性推荐 this.changePage(1)}> 排行榜
) } } export default () => { return ( {({ pos, pageIndex, setPageIndex }) => ( )} ) } ================================================ FILE: src/layouts/GithubFork/index.tsx ================================================ import React from 'react' export default () => { return ( Fork me on GitHub ) } ================================================ FILE: src/layouts/HeaderBar/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/layouts/HeaderBar/style.scss ================================================ @import '@/constant/style.scss'; .headerBar { position: absolute; display: flex; flex-direction: column; justify-content: center; top: 0; left: 0; right: 0; z-index: 99; width: 100%; height: $headerBarHeight - 5px; background-color: $ncmRed; overflow: hidden; } .playingLink { position: absolute; right: 20px; i { font-size: 50px; } } .search { &::placeholder { color: rgba($color: #fff, $alpha: 0.5); } display: block; font-size: 30px; line-height: 50px; height: 45px; width: 70%; margin: 0 auto; padding-left: 30px; color: #eee; background-color: rgba($color: #eee, $alpha: 0.3); border: 1px solid $ncmRed; border-radius: 100px; outline: none; } ================================================ FILE: src/layouts/HeaderBar/style.scss.d.ts ================================================ export const headerBar: string; export const playingLink: string; export const search: string; ================================================ FILE: src/layouts/HeaderBar/view.tsx ================================================ import React, { ReactNode } from 'react' import style from '@/layouts/HeaderBar/style.scss' import { Link } from 'react-router-dom' import cs from 'classnames' type IProps = { style?: any render?: (props) => ReactNode component?: React.ComponentClass pos?: number pageIndex?: number setPageIndex?: any } const HeaderBar: React.SFC = props => { let children const { component, render } = props if (component) { children = React.createElement(component, props) } else if (render) { children = render(props) } return ( ) } export default HeaderBar ================================================ FILE: src/pages/Account/index.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import Fork from '@/layouts/GithubFork' interface IProps { style: React.CSSProperties } class App extends React.Component { render() { return (

👤 account page

) } } export default App ================================================ FILE: src/pages/Account/style.scss ================================================ @import '@/constant/style.scss'; .wrapper { text-align: center; height: 100vh; display: flex; justify-content: center; align-items: center; background-color: mintcream; overflow: hidden; color: #333; } ================================================ FILE: src/pages/Account/style.scss.d.ts ================================================ export const wrapper: string; ================================================ FILE: src/pages/Explore/Banner/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Explore/Banner/style.scss ================================================ .slideItem{ background-color: #ccc; } .slideImg{ width: 100%; display: block; } ================================================ FILE: src/pages/Explore/Banner/style.scss.d.ts ================================================ export const slideItem: string; export const slideImg: string; ================================================ FILE: src/pages/Explore/Banner/view.tsx ================================================ import Carousel from '@/components/Carousel' import * as React from 'react' import * as style from './style.scss' import { observer } from 'mobx-react' import Store from '@/utils/models/componentFetchModel' import get from 'lodash/get' type IBannerItem = { url: string picUrl: string } export type IBannerPayload = { code: number banners: IBannerItem[] } type IProps = { store: Store } type IState = { isImgsLoaded: boolean } @observer class Banner extends React.Component { state = { isInited: false, isImgsLoaded: false } componentDidMount() { this.isImgsLoadComplete(get(this.props, 'store.payload.banners')) } componentDidUpdate(prevProps, prevState) { if (!this.state.isInited) { this.isImgsLoadComplete(get(this.props, 'store.payload.banners')) } } isImgsLoadComplete = urls => { const length = get(urls, 'length') if (!length) { return } const totalImgCount = length let loadedImgCount = 0 urls.forEach(img => { const testImg = new Image() testImg.src = img.picUrl testImg.onload = () => { loadedImgCount++ if (loadedImgCount === totalImgCount) { this.setState({ isImgsLoaded: true, isInited: true }) } } }) } render() { const payload = this.props.store.payload as IBannerPayload return ( {payload && this.state.isImgsLoaded ? payload.banners.map(banner => { return (
) }) : null}
) } } export default Banner ================================================ FILE: src/pages/Explore/Custom.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { ComponentFetchModel } from '@/utils/models' import RecommendList from './RecommendList' import Banner from './Banner' import NETEASE_API, { getURL } from '../../constant/api' const bannerStore: ComponentFetchModel = new ComponentFetchModel({ URL: getURL(NETEASE_API.banner) }) const listStore: ComponentFetchModel = new ComponentFetchModel({ URL: getURL(NETEASE_API.recommendList) }) const songStore: ComponentFetchModel = new ComponentFetchModel({ URL: getURL(NETEASE_API.recommendSong) }) const listNormalizer = result => result.map(item => ({ id: item.id, picUrl: item.picUrl, playCount: item.playCount, name: item.name, path: `/playlist/${item.id}` })) const songNormalizer = result => result.map(item => ({ id: item.song.id, picUrl: item.song.album.picUrl, playCount: null, name: item.name, path: `/playlist/${item.id}` })) export default class Custom extends React.Component { banner: HTMLDivElement | null componentDidMount() { if (this.banner) { this.banner.addEventListener('touchmove', e => { e.stopPropagation() }) } bannerStore.fetchData() listStore.fetchData() songStore.fetchData() } render() { return (
{ this.banner = ref }} >
) } } ================================================ FILE: src/pages/Explore/List.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import ListCover from './ListCover' const listIndexes: number[] = Array(23) .fill(0) .map((item, index) => index) export default class List extends React.Component { render() { return (
{listIndexes.map(itemIndex => { return })}
) } } ================================================ FILE: src/pages/Explore/ListCover/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Explore/ListCover/style.scss ================================================ .coverImg { width: 200px; height: 200px; border-radius: 10px; margin-right: 30px; } .wrapper { display: flex; padding: 10px 30px; border-bottom: 1px solid #eee; box-sizing: border-box; } .previews { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0; font-size: 30px; line-height: 40px; color: #555; } ================================================ FILE: src/pages/Explore/ListCover/style.scss.d.ts ================================================ export const coverImg: string; export const wrapper: string; export const previews: string; ================================================ FILE: src/pages/Explore/ListCover/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { observer } from 'mobx-react' import { ComponentFetchModel } from '@/utils/models' import NETEASE_API, { getURL } from '@/constant/api' import get from 'lodash/get' import { Link } from 'react-router-dom' type IProps = { store?: any listIndex: number } type IState = { store: any } @observer export default class Custom extends React.Component { store: any = new ComponentFetchModel({ URL: getURL(NETEASE_API.list, { idx: this.props.listIndex }) }) componentDidMount() { this.store.fetchData() } render() { const coverImgUrl = get(this.store, 'payload.playlist.coverImgUrl') const previewItems = get(this.store, 'payload.playlist.tracks') const name = get(this.store, 'payload.playlist.name') const playCount = get(this.store, 'payload.playlist.playCount') const path = `/playlist/${get(this.store, 'payload.playlist.id')}` return (
{previewItems && previewItems.splice(0, 3).map((track, index) => (
{index + 1}. {track.name}
))}
) } } ================================================ FILE: src/pages/Explore/RecommendList/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Explore/RecommendList/style.scss ================================================ .recommendList{ margin: 10px 10px 50px 10px; } ================================================ FILE: src/pages/Explore/RecommendList/style.scss.d.ts ================================================ export const recommendList: string; ================================================ FILE: src/pages/Explore/RecommendList/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { observer } from 'mobx-react' import Matrix from '@/components/Matrix' import Cover from '@/components/Cover' import SectionTitle from '@/components/SectionTitle' import Store from '@/utils/models/componentFetchModel' type IProps = { store: Store normalizer: (result: object) => any[] title: string } export type IRecommendListPayload = { code: number result: object } const RecommendList: React.SFC = ({ store, normalizer, title }) => { const payload = store.payload as IRecommendListPayload const lists = payload ? normalizer(payload.result) : Array(6) .fill({ key: '', coverImg: null, link: '' }) .map((item, index) => ({ ...item, key: index })) return (
{title} {lists.slice(0, 6).map((list, index) => { return ( {list.id} ) })}
) } export default observer(RecommendList) ================================================ FILE: src/pages/Explore/Slider.tsx ================================================ import * as React from 'react' import * as cs from 'classnames' import * as style from '@/pages/Explore/style.scss' interface IProps { location: { pathname: string } history: any style?: React.CSSProperties setRankLoaded: (isLoaded: boolean) => void changePos: (pos: number) => void setPageIndex: (index: number) => void pageIndex: number } class Slider extends React.Component { pageWrapper: HTMLElement | null state = { touchStartPos: { x: 0, y: 0 }, prevOffsetX: 0, swipedDisX: 0, isVerticalScrolling: null, isTransitioning: false } SWIPE_DIS_THRESH = 80 // 触发翻页生效的最小滑动距离 PAGE_WIDTH = window.screen.width // 一页的宽度(屏幕宽度) PAGE_NUMBER = 2 // 页数 pageState = { DO_NOT_CHANGE: -1 // flag } componentDidMount() { if (this.pageWrapper) { this.pageWrapper.addEventListener('touchstart', this.handleTouchStart) this.pageWrapper.addEventListener('touchmove', this.handleTouchMove) this.pageWrapper.addEventListener('touchend', this.handleTouchEnd) } } componentDidUpdate(prevProps, prevState) { if (this.props.pageIndex === this.pageState.DO_NOT_CHANGE) { return } if (this.props.pageIndex !== prevProps.pageIndex) { this.forceStickScroll(this.props.pageIndex) } } changeRouter = pathName => { this.props.history.push(`/explore/${pathName}`) if (pathName.includes(`rank`)) { this.setState({ hasRankLoaded: true }) this.props.setRankLoaded(true) } } forceStickScroll = pageIndex => { const offset = pageIndex === 1 ? -this.PAGE_WIDTH : 0 this.setState({ prevOffsetX: offset, swipedDisX: 0, isTransitioning: true }) } stickScroll = () => { if (this.pageWrapper) { this.pageWrapper.addEventListener('transitionend', () => { this.setState({ isTransitioning: false }) }) } const swipedDisX = this.state.swipedDisX // 左滑切换到右侧页 if (swipedDisX < -this.SWIPE_DIS_THRESH) { const nextPage = this.props.pageIndex + 1 this.setState({ prevOffsetX: -nextPage * this.PAGE_WIDTH, swipedDisX: 0, isTransitioning: true }) this.changeRouter('rank') return nextPage } // 右滑切换到左侧页 if (swipedDisX > this.SWIPE_DIS_THRESH) { const nextPage = this.props.pageIndex - 1 this.setState({ prevOffsetX: -nextPage * this.PAGE_WIDTH, swipedDisX: 0, isTransitioning: true }) this.changeRouter('custom') return nextPage } // 不足以切换页回到原页 this.setState({ prevOffsetX: -this.props.pageIndex * this.PAGE_WIDTH, swipedDisX: 0, isTransitioning: true }) return this.props.pageIndex } handleTouchStart = e => { console.log('===== start moving =====') if (e.touches.length === 1) { this.setState({ prevOffsetX: this.state.prevOffsetX + this.state.swipedDisX, touchStartPos: { x: e.touches[0].screenX, y: e.touches[0].screenY }, swipedDisX: 0 }) } } handleTouchMove = e => { if (e.touches.length === 1) { const touch = e.touches[0] // 计算 x,y 方向的移动距离 const delta = { x: touch.screenX - this.state.touchStartPos.x, y: touch.screenY - this.state.touchStartPos.y } // 判断是否是垂直滚动 if (this.state.isVerticalScrolling === null) { console.log('===== reset =====') this.setState({ isVerticalScrolling: !!(Math.abs(delta.x) < Math.abs(delta.y)) }) } if (!this.state.isVerticalScrolling) { // 模拟滚动 e.preventDefault() const currSwipedXDis = e.touches[0].screenX - this.state.touchStartPos.x // 阻止滑动到边缘时继续滑动 if ( this.state.prevOffsetX + currSwipedXDis > 0 || // 右滑超过第 0 屏 this.state.prevOffsetX + currSwipedXDis < -(this.PAGE_WIDTH * (this.PAGE_NUMBER - 1)) // 左滑超过最后一屏 ) { return } // update this.setState({ swipedDisX: currSwipedXDis }) this.props.changePos(-currSwipedXDis / this.PAGE_WIDTH) } } } handleTouchEnd = e => { let endIndex if (this.state.isVerticalScrolling === false) { endIndex = this.stickScroll() this.props.setPageIndex(endIndex) } this.setState({ isVerticalScrolling: null }) } render() { const wrapperClass = cs({ [style.exploreWrapper]: true, [style.isTransitioning]: this.state.isTransitioning === true }) return (
{ this.pageWrapper = node }} style={{ transform: `translate3d(${this.state.prevOffsetX + this.state.swipedDisX}px, 0, 0)`, ...this.props.style }} > {this.props.children}
) } } export default Slider ================================================ FILE: src/pages/Explore/index.tsx ================================================ import * as React from 'react' import * as style from '@/pages/Explore/style.scss' import Custom from '@/pages/Explore/Custom' import List from '@/pages/Explore/List' import Slider from './Slider' import { SlideContext } from '@/router/slideContext' import { withRouter } from 'react-router' interface IProps { location: { pathname: string } history: any style: React.CSSProperties } class Explore extends React.Component { state = { hasRankLoaded: false } static getDerivedStateFromProps(nextProps, prevState) { if (nextProps.location.pathname.split('/').indexOf('rank') >= 0 && !prevState.hasRankLoaded) { return { hasRankLoaded: true } } return null } setRankLoaded = isLoaded => { this.setState({ hasRankLoaded: isLoaded }) } render() { return ( {({ changePos, setPageIndex, pageIndex }) => (
{this.state.hasRankLoaded ? : null}
)}
) } } export default Explore ================================================ FILE: src/pages/Explore/style.scss ================================================ @import '@/constant/style.scss'; .exploreWrapper { margin: 0; width: 200%; text-align: center; background-color: $ncmRed; height: 100%; overflow-x: scroll; overflow-y: hidden; display: flex; } .isTransitioning { transition: transform 0.2s ease-out; } .custom { position: relative; background-color: #fff; padding: 0 0 1px 0; } .lists { padding-top: 30px; background-color: #fff; } .innerWrapper { flex-shrink: 0; box-sizing: border-box; border: 1px solid transparent; border-width: 170px 0 $bottomBarHeight 0; width: 50%; height: 100%; overflow-x: auto; overflow-y: auto; } .banners { border-radius: 10px; margin: 10px auto 10px auto; -webkit-mask-image: -webkit-radial-gradient(white, black); // TODO width: 98%; overflow: hidden; } .redBg { position: absolute; background-color: $ncmRed; width: 100%; height: 120px; } ================================================ FILE: src/pages/Explore/style.scss.d.ts ================================================ export const exploreWrapper: string; export const isTransitioning: string; export const custom: string; export const lists: string; export const innerWrapper: string; export const banners: string; export const redBg: string; ================================================ FILE: src/pages/Friends/index.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import Fork from '@/layouts/GithubFork' interface IProps { style: React.CSSProperties } class App extends React.Component { // count: number // state = { // count: 0 // } // componentWillUnmount() { // console.log('=== Friends will unmount =====') // } // componentDidMount() { // setInterval(() => { // this.setState({ // count: this.state.count + 1 // }) // }, 200) // } render() { return (

🍻 friends page

) } } export default App ================================================ FILE: src/pages/Friends/style.scss ================================================ @import '@/constant/style.scss'; .wrapper { text-align: center; height: 100vh; display: flex; justify-content: center; align-items: center; background-color: lightgoldenrodyellow; overflow: hidden; color: #333; } ================================================ FILE: src/pages/Friends/style.scss.d.ts ================================================ export const wrapper: string; ================================================ FILE: src/pages/Mine/index.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import Fork from '@/layouts/GithubFork' interface IProps { style: React.CSSProperties } class App extends React.Component { render() { return (

👶🏻 mine page

) } } export default App ================================================ FILE: src/pages/Mine/style.scss ================================================ @import '@/constant/style.scss'; .wrapper { text-align: center; height: 100vh; display: flex; justify-content: center; align-items: center; background-color: salmon; overflow: hidden; color: #333; } ================================================ FILE: src/pages/Mine/style.scss.d.ts ================================================ export const wrapper: string; ================================================ FILE: src/pages/Playing/ControlBar/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Playing/ControlBar/style.scss ================================================ .controlButtonsWrapper { margin: 0 auto 90px auto; i { font-weight: lighter; color: rgba($color: #fff, $alpha: 0.5); margin: 0 60px; font-size: 100px; } } ================================================ FILE: src/pages/Playing/ControlBar/style.scss.d.ts ================================================ export const controlButtonsWrapper: string; ================================================ FILE: src/pages/Playing/ControlBar/view.tsx ================================================ import React from 'react' import * as style from './style.scss' type IProps = { isPlaying: boolean switchPrevSong: React.MouseEventHandler switchNextSong: React.MouseEventHandler switchPlayState: React.MouseEventHandler } const RotatingCover: React.SFC = props => { return (
{props.isPlaying ? ( ) : ( )}
) } export default RotatingCover ================================================ FILE: src/pages/Playing/HeaderBar/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Playing/HeaderBar/style.scss ================================================ .headerWrapper { position: relative; width: 100%; color: #fff; padding: 20px 0 10px 0; border-bottom: 1px solid rgba($color: #fff, $alpha: 0.5); } .songName { margin: 0 auto 5px auto; text-align: center; } .artists { font-size: 20px; text-align: center; } .back { position: absolute; top: 50%; transform: translateY(-50%); left: 10px; i { font-size: 50px; font-weight: bold; } } ================================================ FILE: src/pages/Playing/HeaderBar/style.scss.d.ts ================================================ export const headerWrapper: string; export const songName: string; export const artists: string; export const back: string; ================================================ FILE: src/pages/Playing/HeaderBar/view.tsx ================================================ import React, { MouseEventHandler } from 'react' import style from './style.scss' import { withRouter } from 'react-router-dom' type IProps = { artists: string name: string history: any } class RotatingCover extends React.Component { goBack: MouseEventHandler = e => { this.props.history.goBack() } render() { return (
{this.props.name}
{this.props.artists}
) } } export default withRouter(RotatingCover) ================================================ FILE: src/pages/Playing/RotatingCover/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Playing/RotatingCover/style.scss ================================================ .coverImg { width: 500px; border-radius: 50%; border: 20px solid rgba($color: #fff, $alpha: 0.5); } ================================================ FILE: src/pages/Playing/RotatingCover/style.scss.d.ts ================================================ export const coverImg: string; ================================================ FILE: src/pages/Playing/RotatingCover/view.tsx ================================================ import React from 'react' import * as style from './style.scss' import { IPlayingSong, IPlayState } from '../../../store' type IProps = { playingSong: IPlayingSong } export default class RotatingCover extends React.Component { render() { return (
) } } ================================================ FILE: src/pages/Playing/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Playing/style.scss ================================================ @import '@/constant/style.scss'; .wrapper { position: relative; height: 750px; height: 1334px; } .content { height: 2222px; } .foreground { display: flex; flex-direction: column; justify-content: space-between; background-color: rgba($color: #000, $alpha: 0.2); align-items: center; position: absolute; z-index: 2; top: 0; left: 0; width: 100%; height: 100%; } .playingBg { position: absolute; left: 0; top: 0; background-size: cover; background-position: center; filter: blur(30px); width: 100%; height: 100%; } ================================================ FILE: src/pages/Playing/style.scss.d.ts ================================================ export const wrapper: string; export const content: string; export const foreground: string; export const playingBg: string; ================================================ FILE: src/pages/Playing/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { connect } from 'react-redux' import { IStoreState, IPlayingSong, IPlayState, SwitchSongByPace, switchPlayState } from '../../store' import RotatingCover from './RotatingCover' import Header from './HeaderBar' import ControlBar from './ControlBar' import PropTypes from 'prop-types' interface IProps { style: React.CSSProperties playState: IPlayState playingSong: IPlayingSong } type IState = { isPlaying: boolean } class PlayingPage extends React.Component { static contextTypes = { store: PropTypes.object } audio: HTMLAudioElement | null getIsPlaying = () => { return this.context.store.getState().playState.isPlaying } handleSwitchPrevSong = () => { this.context.store.dispatch(SwitchSongByPace(-1)) } handleSwitchNextSong = () => { this.context.store.dispatch(SwitchSongByPace(1)) } handleSwitchPlayState = () => { this.context.store.dispatch(switchPlayState) if (this.audio) { const isPlaying = this.getIsPlaying() if (isPlaying) { this.audio.play() } else { this.audio.pause() } } } render() { return (
) } } const mapStateToProps = (state: IStoreState, ownProps) => { return { playState: state.playState, playingSong: state.playingSong } } export default connect(mapStateToProps)(PlayingPage) ================================================ FILE: src/pages/Playlist/Header/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Playlist/Header/style.scss ================================================ @import '@/constant/style.scss'; .headerWrapper { position: fixed; top: 0; left: 0; width: 100%; z-index: 2; height: $headerBarHeight; background-color: #666; color: rgba(255, 255, 255, 0.9); line-height: $headerBarHeight; background-size: cover; overflow: hidden; } .foreground { position: absolute; display: flex; top: 0; left: 0; width: 100%; height: 100%; z-index: 2; justify-content: space-between; } .back { font-weight: bolder; font-size: 40px; padding-left: 20px; } .playingLink { position: absolute; right: 20px; i { font-size: 50px; color: rgba(255, 255, 255, 0.9); } } .bgImg { position: absolute; top: 0; left: 0; width: 100%; height: calc(100% + 50px); z-index: 1; background-size: cover; filter: blur(15px); } ================================================ FILE: src/pages/Playlist/Header/style.scss.d.ts ================================================ export const headerWrapper: string; export const foreground: string; export const back: string; export const playingLink: string; export const bgImg: string; ================================================ FILE: src/pages/Playlist/Header/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { withRouter } from 'react-router' import { Link } from 'react-router-dom' import cs from 'classnames' type IProps = { history: any bgImgUrl: string } class Header extends React.Component { goBack: React.MouseEventHandler = e => { this.props.history.goBack() } render() { return (
) } } export default withRouter(Header) ================================================ FILE: src/pages/Playlist/index.tsx ================================================ export { default } from './view' ================================================ FILE: src/pages/Playlist/style.scss ================================================ @import '@/constant/style.scss'; .wrapper { position: relative; border: 1px solid transparent; border-width: 0 0 $bottomBarHeight 0; height: 100%; box-sizing: border-box; overflow-y: auto; background-color: #666; clear: both; } .foreground { position: absolute; margin-top: $headerBarHeight; width: 100%; left: 0; z-index: 2; } .back { display: block; color: rgba(255, 255, 255, 0.7); height: $headerBarHeight; line-height: $headerBarHeight; font-weight: bolder; padding-left: 30px; } .content { height: 2222px; } .infoWrapper { display: flex; align-items: center; padding: 0 30px; margin-bottom: 40px; background: transparent; } .bgImg { position: absolute; top: 0; left: 0; width: 100%; height: 500px; z-index: 1; background-size: cover; filter: blur(15px); } .coverWrapper { flex-shrink: 0; position: relative; width: 300px; height: 300px; border-radius: 5px; overflow: hidden; img { height: 100%; width: 100%; } } .playCount { position: absolute; top: 10px; right: 10px; color: #fff; margin: 0; } .listName { margin-left: 20px; color: #fff; font-size: 30px; } ================================================ FILE: src/pages/Playlist/style.scss.d.ts ================================================ export const wrapper: string; export const foreground: string; export const back: string; export const content: string; export const infoWrapper: string; export const bgImg: string; export const coverWrapper: string; export const playCount: string; export const listName: string; ================================================ FILE: src/pages/Playlist/view.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import { calcPlayCount } from '@/utils/calcFunctions' import { ComponentFetchModel } from '@/utils/models' import { observer } from 'mobx-react' import get from 'lodash/get' import TrackList from '@/components/TrackList' import NETEASE_API, { getURL } from '@/constant/api' import Header from './Header' type IProps = { style?: React.CSSProperties location?: any match?: any } @observer class Playlist extends React.Component { listStore = new ComponentFetchModel({ URL: getURL(NETEASE_API.playlist, { id: this.props.match.params.id }) }) componentDidMount() { this.listStore.fetchData() } render() { const locationState = this.props.location.state || {} const { playCount, picUrl, name } = locationState const coverImg = get(this.listStore, 'payload.playlist.coverImgUrl') return (

{calcPlayCount(playCount)}

{name}

) } } export default Playlist ================================================ FILE: src/pages/Video/index.tsx ================================================ import * as React from 'react' import * as style from './style.scss' import Fork from '@/layouts/GithubFork' interface IProps { style: React.CSSProperties } class App extends React.Component { render() { return (

🎞 video page

) } } export default App ================================================ FILE: src/pages/Video/style.scss ================================================ @import '@/constant/style.scss'; .wrapper { text-align: center; height: 100vh; display: flex; justify-content: center; align-items: center; background-color: paleturquoise; overflow: hidden; color: #333; } ================================================ FILE: src/pages/Video/style.scss.d.ts ================================================ export const wrapper: string; ================================================ FILE: src/registerServiceWorker.ts ================================================ // tslint:disable:no-console // In production, we register a service worker to serve assets from local cache. // This lets the app load faster on subsequent visits in production, and gives // it offline capabilities. However, it also means that developers (and users) // will only see deployed updates on the 'N+1' visit to a page, since previously // cached resources are updated in the background. // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. // This link also includes instructions on opting out of this behavior. const isLocalhost = Boolean( window.location.hostname === 'localhost' || // [::1] is the IPv6 localhost address. window.location.hostname === '[::1]' || // 127.0.0.1/8 is considered localhost for IPv4. window.location.hostname.match( /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ ) ); export default function register() { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { // The URL constructor is available in all browsers that support SW. const publicUrl = new URL( process.env.PUBLIC_URL!, window.location.toString() ); if (publicUrl.origin !== window.location.origin) { // Our service worker won't work if PUBLIC_URL is on a different origin // from what our page is served on. This might happen if a CDN is used to // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 return; } window.addEventListener('load', () => { const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; if (isLocalhost) { // This is running on localhost. Lets check if a service worker still exists or not. checkValidServiceWorker(swUrl); // Add some additional logging to localhost, pointing developers to the // service worker/PWA documentation. navigator.serviceWorker.ready.then(() => { console.log( 'This web app is being served cache-first by a service ' + 'worker. To learn more, visit https://goo.gl/SC7cgQ' ); }); } else { // Is not local host. Just register service worker registerValidSW(swUrl); } }); } } function registerValidSW(swUrl: string) { navigator.serviceWorker .register(swUrl) .then(registration => { registration.onupdatefound = () => { const installingWorker = registration.installing; if (installingWorker) { installingWorker.onstatechange = () => { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { // At this point, the old content will have been purged and // the fresh content will have been added to the cache. // It's the perfect time to display a 'New content is // available; please refresh.' message in your web app. console.log('New content is available; please refresh.'); } else { // At this point, everything has been precached. // It's the perfect time to display a // 'Content is cached for offline use.' message. console.log('Content is cached for offline use.'); } } }; } }; }) .catch(error => { console.error('Error during service worker registration:', error); }); } function checkValidServiceWorker(swUrl: string) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl) .then(response => { // Ensure service worker exists, and that we really are getting a JS file. if ( response.status === 404 || response.headers.get('content-type')!.indexOf('javascript') === -1 ) { // No service worker found. Probably a different app. Reload the page. navigator.serviceWorker.ready.then(registration => { registration.unregister().then(() => { window.location.reload(); }); }); } else { // Service worker found. Proceed as normal. registerValidSW(swUrl); } }) .catch(() => { console.log( 'No internet connection found. App is running in offline mode.' ); }); } export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then(registration => { registration.unregister(); }); } } ================================================ FILE: src/router/index.tsx ================================================ import * as React from 'react' import { BrowserRouter, Route, Switch } from 'react-router-dom' import LiveRoute from 'react-live-route' import ExplorePage from '@/pages/Explore' import VideoPage from '@/pages/Video' import MinePage from '@/pages/Mine' import FriendsPage from '@/pages/Friends' import AccountPage from '@/pages/Account' import Playing from '@/pages/Playing' import Playlist from '@/pages/Playlist' import BottomBar from '@/layouts/BottomBar' import BaseHeaderBar from '@/layouts/HeaderBar' import ExploreHeaderBar from '@/layouts/ExploreHeaderBar' import * as style from '@/router/routerTrans.scss' import { SlideContext } from '@/router/slideContext' const AppRoutes = () => (
) class Slider extends React.Component { state = { pos: 0, pageIndex: 0 } changePos = newPos => { this.setState({ pos: newPos }) } setPageIndex = index => { this.setState({ pageIndex: index }) } render() { return ( ) } } export default Slider ================================================ FILE: src/router/routerTrans.scss ================================================ :global { .switch-wrapper { position: relative; height: 100%; width: 100%; } .switch-wrapper>div { position: absolute; height: 100%; width: 100%; } } .routeWrapper { height: 100%; width: 100%; overflow-y: scroll; overflow-x: hidden; } .rootWrapper { background-color: #aaa; width: 100%; height: 100%; overflow: hidden; } .transitionGroup { position: relative; } ================================================ FILE: src/router/routerTrans.scss.d.ts ================================================ export const routeWrapper: string; export const rootWrapper: string; export const transitionGroup: string; ================================================ FILE: src/router/slideContext.tsx ================================================ import React from 'react' export const slidePos = { pageIndex: 0, pos: 0, changePos(newPos) { slidePos.pos = newPos }, setPageIndex(index) { slidePos.pageIndex = index } } export const SlideContext = React.createContext( slidePos // default value ) ================================================ FILE: src/store.tsx ================================================ import NETEASE_API, { getURL } from '@/constant/api' import axios from 'axios' import defaultCover from './assets/cover-default.jpg' import get from 'lodash/get' // ===== constant ===== // export const PLAY_SONG = 'PLAY_SONG' export const FETCH_URL = 'FETCH_URL' export const FETCH_SONG_DETAIL_SUCCESS = 'FETCH_SONG_DETAIL_SUCCESS' export const FETCH_SONG_URL_SUCCESS = 'FETCH_SONG_URL_SUCCESS' export const SWITCH_PREV_SONG = 'SWITCH_PREV_SONG' export const SWITCH_NEXT_SONG = 'SWITCH_NEXT_SONG' export const SWITCH_PLAY_STATE = 'SWITCH_PLAY_STATE' export const PUSH_TO_PLAYLIST = 'PUSH_TO_PLAYLIST' export const EMPTY_PLAYLIST = 'EMPTY_PLAYLIST' export const PLAY_PLAYLIST = 'PLAY_PLAYLIST' export const CHANGE_PLAYLIST_INDEX = 'CHANGE_PLAYLIST_INDEX' // ===== type ===== // export type IAction = { readonly type: string [propName: string]: any } export type IActionCreator = (param: any) => IAction enum CycleMode { single = 0, all, random } // 同步 action 修改 export type IPlayState = { isPlaying: boolean cycleMode: CycleMode playingTime: number } // 异步 action 修改 export type IPlayingSong = { id: string name: string coverImg: string url: string artists: string album: string } export type IPlaylist = { currIndex: number list: IPlayingSong[] } export type IStoreState = { playingSong: IPlayingSong playState: IPlayState playlist: IPlaylist } export const defaultState: IStoreState = { playingSong: { id: '', name: '💿', coverImg: defaultCover, url: '', artists: '', album: '' }, playState: { isPlaying: false, cycleMode: CycleMode.all, playingTime: 0 }, playlist: { currIndex: 0, list: [] } } type IReducer = (state: IStoreState, action: IAction) => IStoreState // ===== action creator ===== // // 更改播放列表中的 index export const changePlaylistIndexActionCreator: any = ({ pace, nextIndex }: { pace?: number; nextIndex?: number }) => { return dispatch => { if (typeof pace === 'number') { dispatch({ type: CHANGE_PLAYLIST_INDEX, pace }) } else if (typeof nextIndex === 'number') { dispatch({ type: CHANGE_PLAYLIST_INDEX, nextIndex }) } else { dispatch({ type: CHANGE_PLAYLIST_INDEX }) } } } // compose:切换 index + 播放当前 index export const SwitchSongByPace: (pace: number) => void = pace => { return (dispatch, getState) => { // 1. 先在调整当前播放歌曲的 index dispatch(changePlaylistIndexActionCreator({ pace })) // 2. 开始播放当前列表 dispatch({ type: PLAY_PLAYLIST }) // 3. 异步获取对应的歌曲 url const currState: IStoreState = getState() const id = get(currState, 'playingSong.id') if (typeof id === 'string') { dispatch(fetchSongUrl(currState.playingSong.id)) } else { console.error('bad song id requested!') } } } // 切换播放/暂停 export const switchPlayState: IAction = { type: SWITCH_PLAY_STATE } // 将歌曲添加到播放列表中 export const addToPlaylist: IActionCreator = songs => { return { type: PUSH_TO_PLAYLIST, songsToPush: Array.isArray(songs.songsToPush) ? songs.songsToPush : [songs.songsToPush] } } // 开始播放当前列表 export const playCurrPlaylist = (songs, index = 0) => { return (dispatch, getState) => { // 1. 将歌添加到播放列表中 dispatch( addToPlaylist({ type: PUSH_TO_PLAYLIST, songsToPush: Array.isArray(songs) ? songs : [songs] }) ) // 2. 将 currIndex 置为 0 dispatch(changePlaylistIndexActionCreator({ nextIndex: index })) // 3. 开始播放 currIndex 对应的歌曲 dispatch({ type: PLAY_PLAYLIST }) // 4. 异步获取对应的歌曲 url const currState: IStoreState = getState() dispatch(fetchSongUrl(currState.playingSong.id)) } } // 将播放列表清空 export const emptyPlaylist: IActionCreator = () => { return { type: EMPTY_PLAYLIST } } // 点击一首歌 export const playSongActionCreator: IActionCreator = id => ({ type: PLAY_SONG, nextPlayingSongId: id }) // 已成功到获取一首歌的详情 export const syncReplacePlayingSong: IActionCreator = payload => ({ type: FETCH_SONG_DETAIL_SUCCESS, payload }) // 异步获取一首歌的 URL 成功 export const fetchSongUrlSuccessActionCreator: IActionCreator = payload => ({ type: FETCH_SONG_URL_SUCCESS, payload }) // 异步获取一首歌的详情(export 给组件) export const fetchSongDetail = id => generateFetchActionCreator(getURL(NETEASE_API.songDetail, { ids: id }), syncReplacePlayingSong) // 异步获取一首歌的 URL(export 给组件) export const fetchSongUrl = id => generateFetchActionCreator(getURL(NETEASE_API.songUrl, { ids: id }), fetchSongUrlSuccessActionCreator) // 通用异步获取 export const generateFetchActionCreator = (URL, actionCreator) => { // TODO: 加入取消之前的请求 return axios .get(URL) .then(response => { console.log(response.data) return actionCreator(response.data) }) .catch(error => { console.log(error) }) } // ===== action reducers ===== // // 歌曲详情 reducer const fetchSongDetailSuccessReducer: IReducer = (state, action) => { const firstSong = action.payload.songs[0] const prevPlayingSong = state.playingSong const nextPlayingSong = { ...prevPlayingSong, coverImg: firstSong.al.picUrl } return { ...state, playingSong: nextPlayingSong } } // 歌曲 URL reducer const fetchSongUrlSuccessReducer: IReducer = (state, action) => { const firstSong = action.payload.data[0] const prevPlayingSong = state.playingSong const nextPlayingSong = { ...prevPlayingSong, url: firstSong.url } return { ...state, playingSong: nextPlayingSong } } // 开始播放当前列表中对应 index 的歌曲 const playCurrSongReducer: IReducer = (state, action) => { const nextPlayingSong: IPlayingSong = state.playlist.list[state.playlist.currIndex] if (!nextPlayingSong) { return state } const nextPlayingState: IPlayState = { isPlaying: true, cycleMode: state.playState.cycleMode, playingTime: 0 } return { ...state, playingSong: nextPlayingSong, playState: nextPlayingState } } // 更改列表中的 index const changePlaylistIndexReducer: IReducer = (state, action) => { console.log('切歌') const prevPlaylist = state.playlist let nextSongIndex if (typeof action.pace === 'number') { nextSongIndex = prevPlaylist.currIndex + action.pace } else if (typeof action.nextIndex === 'number') { nextSongIndex = action.nextIndex } else { nextSongIndex = prevPlaylist.currIndex } // 如果超出播放列表的边界则什么都不做 // TODO: 全部循环时列表会首尾相接 if (nextSongIndex < 0 || nextSongIndex >= prevPlaylist.list.length || nextSongIndex === prevPlaylist.currIndex) { return state } const nextIndex = nextSongIndex const nextPlaylist = { ...prevPlaylist, currIndex: nextIndex } return { ...state, playlist: nextPlaylist } } // 切换播放/暂停状态 const switchPlayingStateReducer: IReducer = (state, action) => { const prevPlayState = state.playState const nextPlayState = { ...prevPlayState, isPlaying: !prevPlayState.isPlaying } return { ...state, playState: nextPlayState } } // 将新的歌推入歌单中 const pushSongsToPlaylistReducer: IReducer = (state, action) => { const prevPlaylist = state.playlist const nextPlaylist = { ...prevPlaylist, list: action.songsToPush } return { ...state, playlist: nextPlaylist } } // 总 reducer export const reducers: IReducer = (state, action) => { // console.log(action) switch (action.type) { case PLAY_PLAYLIST: return playCurrSongReducer(state, action) case FETCH_SONG_DETAIL_SUCCESS: return fetchSongDetailSuccessReducer(state, action) case FETCH_SONG_URL_SUCCESS: return fetchSongUrlSuccessReducer(state, action) case CHANGE_PLAYLIST_INDEX: return changePlaylistIndexReducer(state, action) case SWITCH_PLAY_STATE: return switchPlayingStateReducer(state, action) case PUSH_TO_PLAYLIST: return pushSongsToPlaylistReducer(state, action) default: return state } } ================================================ FILE: src/utils/calcFunctions.tsx ================================================ const calcPlayCount = playCount => { if (playCount > 100000000) { return `${(playCount / 100000000).toFixed(1)}亿` } if (playCount > 10000) { return `${Math.floor(playCount / 10000)}万` } return String(playCount) } export { calcPlayCount } ================================================ FILE: src/utils/ee.tsx ================================================ import EE from 'event-emitter' const ee = new EE() // ee.on('onTouchMove', disPercentX => {}) export default ee ================================================ FILE: src/utils/models/componentFetchModel.tsx ================================================ import { configure, observable, action, runInAction, computed, autorun } from 'mobx' import { IRecommendListPayload } from '../../pages/Explore/RecommendList/view' import { IBannerPayload } from '../../pages/Explore/Banner/view' configure({ enforceActions: true }) type IOption = { URL: string } class Store { @observable URL: string = '' @observable payload: IRecommendListPayload | IBannerPayload | null = null @observable state: string = 'pending' // "pending" / "done" / "error" constructor(option: IOption) { this.URL = option.URL } fetchURL = () => { return fetch(this.URL, {}) } @action fetchData() { this.state = 'pending' this.fetchURL().then( response => { if (!(response.status === 200 || response.status === 304)) { throw new Error('Fail to get response with status:' + response.status) } response .json() .then(payload => { runInAction(() => { this.state = 'done' this.payload = payload }) }) .catch(error => { throw new Error('Invalid json response: ' + error) }) }, error => { runInAction(() => { this.state = 'error' }) } ) } } export default Store ================================================ FILE: src/utils/models/index.tsx ================================================ import ComponentFetchModel from './componentFetchModel' export { ComponentFetchModel } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "baseUrl": ".", "paths": { "@/*": [ "src/*" ] }, "outDir": "build/dist", "module": "esnext", "target": "es5", "lib": [ "es6", "dom" ], "sourceMap": true, "allowJs": true, "jsx": "react", "moduleResolution": "node", "rootDir": "src", "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noImplicitThis": true, "noImplicitAny": false, "strictNullChecks": true, "suppressImplicitAnyIndexErrors": true, "noUnusedLocals": false }, "exclude": [ "node_modules", "build", "scripts", "acceptance-tests", "webpack", "jest", "src/setupTests.ts" ] } ================================================ FILE: tsconfig.prod.json ================================================ { "extends": "./tsconfig.json" } ================================================ FILE: tsconfig.test.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "module": "commonjs" } } ================================================ FILE: tslint.json ================================================ { "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], "compilerOptions": { "allowSyntheticDefaultImports": true, "esModuleInterop": true }, "linterOptions": { "exclude": ["config/**/*.js", "node_modules/**/*.ts", "src/react-live-router/**/*.js"] }, "rules": { "no-var-requires": false, "max-classes-per-file": false, // "prettier": true, "arrow-parens": false, "arrow-return-shorthand": [false], "comment-format": [true, "check-space"], "import-blacklist": [true, "rxjs"], "interface-over-type-literal": false, "interface-name": false, "member-access": false, "member-ordering": [true, { "order": "fields-first" }], // "newline-before-return": false,] "no-any": false, "no-empty-interface": false, "no-import-side-effect": [true], "no-inferrable-types": [true, "ignore-params", "ignore-properties"], "no-invalid-this": [true, "check-function-in-method"], "no-null-keyword": false, "no-require-imports": false, "no-this-assignment": [true, { "allow-destructuring": true }], "no-trailing-whitespace": true, "no-unused-variable": [false, "react"], "object-literal-sort-keys": false, "object-literal-shorthand": false, "one-variable-per-declaration": [false], "only-arrow-functions": [true, "allow-declarations"], "ordered-imports": [false], "no-console": [false], "prefer-method-signature": false, "prefer-template": [true, "allow-single-concat"], "quotemark": [true, "single", "jsx-double"], "triple-equals": [true, "allow-null-check"], // "type-literal-delimiter": true, "typedef": { "severity": "off", "options": ["parameter", "property-declaration"] }, "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case", "allow-leading-underscore"], // tslint-react "jsx-no-lambda": false } }