├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── .vscode
└── settings.json
├── README.md
├── __tests__
├── chipList.test.tsx
└── toast.test.tsx
├── index.html
├── jest-setup.ts
├── jest.config.js
├── package.json
├── pnpm-lock.yaml
├── public
├── favicon.ico
├── logo192.png
├── logo512.png
├── manifest.json
├── robots.txt
└── workbox-service-worker.js
├── src
├── App.tsx
├── assets
│ ├── css
│ │ ├── iconfont.css
│ │ └── reset.css
│ ├── default.png
│ ├── logo192.png
│ ├── placeholder.png
│ └── sun_main.png
├── components
│ ├── ChipList
│ │ └── index.tsx
│ ├── HideOnScroll
│ │ └── index.tsx
│ ├── LoadingPage
│ │ └── index.tsx
│ ├── MovieItem
│ │ └── index.tsx
│ ├── MovieList
│ │ └── index.tsx
│ ├── MyAppBar
│ │ └── index.tsx
│ ├── MyDrawer
│ │ └── index.tsx
│ ├── SearchBar
│ │ └── index.tsx
│ ├── Toast
│ │ ├── index.tsx
│ │ └── toast.tsx
│ ├── ratioImage
│ │ └── index.tsx
│ └── swiper
│ │ ├── index.tsx
│ │ └── style.css
├── http
│ └── index.ts
├── main.tsx
├── pages
│ ├── Detail
│ │ └── index.tsx
│ ├── Home
│ │ ├── homeCategory.tsx
│ │ ├── homeMain.tsx
│ │ └── index.tsx
│ ├── Play
│ │ └── index.tsx
│ ├── Search
│ │ └── index.tsx
│ └── WatchHistory
│ │ └── index.tsx
├── store
│ ├── index.tsx
│ └── unstate-next.tsx
├── types
│ ├── constant.ts
│ ├── index.ts
│ └── swiper.ts
├── utils
│ ├── index.ts
│ ├── storeUtils.ts
│ └── theme.ts
└── vite-env.d.ts
├── tsconfig.json
└── vite.config.ts
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: ['eslint:recommended', 'plugin:react/recommended', 'plugin:react/jsx-runtime', "plugin:@typescript-eslint/recommended" ],
7 | parser: '@typescript-eslint/parser',
8 | parserOptions: {
9 | ecmaFeatures: {
10 | jsx: true,
11 | },
12 | ecmaVersion: 13,
13 | sourceType: 'module',
14 | },
15 | plugins: ['react', '@typescript-eslint'],
16 | rules: {
17 | 'react/prop-types': 0,
18 | '@typescript-eslint/no-explicit-any': 'off',
19 | '@typescript-eslint/no-empty-function': 'off',
20 | },
21 | settings: {
22 | react: {
23 | version: 'detect',
24 | },
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | build
5 | dist-ssr
6 | *.local
7 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | trailingComma: "es5",
3 | tabWidth: 2,
4 | printWidth: 100,
5 | semi: true,
6 | singleQuote: true
7 | };
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "swipeable",
4 | "vite",
5 | "vitejs"
6 | ]
7 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 风影院 H5版本
2 |
3 | 
4 |
5 |
6 | 换了ios手机,之前看做的看视频的app只有安卓版本的。由于iosApp开发的条件限制,暂时先做一个H5的用用。
7 |
8 | 如果喜欢APP,可以试试[安卓版本](https://github.com/CodeByZack/kongtv-android),暂时没有IOS。
9 |
10 | ## 使用
11 |
12 | [线上地址](https://movie.zackdk.com/)
13 |
14 | IOS手机,请添加到主屏幕使用,体验更好!!!
15 |
16 | 基本功能已开发完。
17 |
18 | 支持电视剧、电影、动漫、综艺四个大类节目。
19 |
20 | 可根据年份、地区进行详细筛选。
21 |
22 | 支持关键字模糊搜索,以及搜索历史。
23 |
24 | 首页推荐每天自动爬取爱奇艺电视剧、电影、动漫、综艺排行前六。
25 |
26 | 支持日间/夜间模式切换。
27 |
28 | 支持观看历史记录。
29 |
30 | 支持PWA,可添加到桌面使用。
31 |
32 | 技术栈: react、react-hook、 unstate-next、 react-router、 material-ui
33 |
34 |
35 | ## 运行项目
36 |
37 | ### 本地
38 |
39 | ```
40 | git clone https://github.com/CodeByZack/kongtv-react
41 |
42 | npm i
43 |
44 | npm run start
45 | ```
46 |
47 | ### 打包
48 |
49 | `npm run build`用于github action自动构建,请勿使用。
50 |
51 | 请使用`npm run build-dev`。
52 |
53 |
54 | ## 问题记录
55 | 开发过程中,一些配置问题的[记录(待整理)](https://www.yuque.com/zackdk/web/an8i5p)。
56 |
57 | ## ~~todo~~
58 |
59 | ~~删除redux相关,使用unstated-next~~
60 |
61 | ~~删除antd-mobild~~
62 |
63 | ~~使用material ui~~
64 |
65 | ~~添加PWA支持(IOS需要手动添加到主屏幕)~~
66 |
67 | ~~增加本地观看记录~~
68 |
69 | ~~增加分类~~
70 |
71 | ~~优化体验,增加夜间模式~~
72 |
73 | ## gif预览
74 |
75 | 
76 |
77 |
78 | ## 截图预览(old)
79 |
80 | 
81 |
82 | 
83 |
84 | 
--------------------------------------------------------------------------------
/__tests__/chipList.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, fireEvent } from '@testing-library/react';
2 | import ChipList, { YearChipList, AreaChipList } from '../src/components/ChipList';
3 |
4 | describe('ChipList', () => {
5 | test('默认ChipList组件', () => {
6 | const mockClick = jest.fn();
7 |
8 | const renderResult = render();
9 |
10 | expect(renderResult.getByText('年代:')).toBeInTheDocument();
11 |
12 | expect(renderResult.getAllByRole('button').length).toEqual(10);
13 |
14 | expect(renderResult.getByText('2020').parentElement).toHaveClass('MuiChip-outlinedDefault');
15 |
16 | expect(renderResult.getByText('2021').parentElement).not.toHaveClass('MuiChip-outlinedDefault');
17 |
18 | fireEvent.click(renderResult.getByText('2021'));
19 |
20 | expect(mockClick.mock.calls.length).toEqual(1);
21 |
22 | expect(mockClick.mock.calls[0][0]).toEqual('2021');
23 | });
24 |
25 | test('年份ChipList组件', () => {
26 | const mockClick = jest.fn();
27 |
28 | const renderResult = render();
29 |
30 | expect(renderResult.getByText('年代:')).toBeInTheDocument();
31 |
32 | expect(renderResult.getAllByRole('button').length).toEqual(10);
33 |
34 | expect(renderResult.getByText('2020').parentElement).toHaveClass('MuiChip-outlinedDefault');
35 |
36 | expect(renderResult.getByText('2021').parentElement).not.toHaveClass('MuiChip-outlinedDefault');
37 |
38 | fireEvent.click(renderResult.getByText('2014'));
39 |
40 | expect(mockClick.mock.calls.length).toEqual(1);
41 |
42 | expect(mockClick.mock.calls[0][0]).toEqual('2014');
43 | });
44 |
45 | test('地区ChipList组件', () => {
46 | const mockClick = jest.fn();
47 |
48 | const renderResult = render();
49 |
50 | expect(renderResult.getByText('地区:')).toBeInTheDocument();
51 |
52 | expect(renderResult.getAllByRole('button').length).toEqual(16);
53 |
54 | expect(renderResult.getByText('大陆').parentElement).toHaveClass('MuiChip-outlinedDefault');
55 |
56 | expect(renderResult.getByText('法国').parentElement).not.toHaveClass('MuiChip-outlinedDefault');
57 |
58 | fireEvent.click(renderResult.getByText('意大利'));
59 |
60 | expect(mockClick.mock.calls.length).toEqual(1);
61 |
62 | expect(mockClick.mock.calls[0][0]).toEqual('意大利');
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/__tests__/toast.test.tsx:
--------------------------------------------------------------------------------
1 | import Toast from '../src/components/Toast';
2 | import { screen } from '@testing-library/react';
3 | import { act } from 'react-dom/test-utils';
4 |
5 | describe('Toast',()=>{
6 |
7 | test('测试loading', ()=>{
8 | act(()=>{
9 | Toast.loading("测试loading",0);
10 | });
11 |
12 | console.log(document.body);
13 |
14 | expect(screen.findByText('测试loading')).toBeInTheDocument();
15 | });
16 |
17 | });
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
28 |
29 | 风影院,像风一样自由~
30 |
31 |
32 |
33 |
44 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/jest-setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'jsdom',
5 | setupFilesAfterEnv: ['./jest-setup.ts']
6 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-ts",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "dev": "vite",
6 | "build": "tsc && vite build",
7 | "build-dev": "tsc && vite build",
8 | "serve": "vite preview",
9 | "pr": "prettier --write \"src/**/*{.tsx,.ts}\"",
10 | "lint": "eslint --ext .ts,.tsx src/",
11 | "analyze": "source-map-explorer 'dist/assets/*.js' --no-border-checks",
12 | "test": "jest"
13 | },
14 | "dependencies": {
15 | "@emotion/react": "^11.5.0",
16 | "@emotion/styled": "^11.3.0",
17 | "@mui/icons-material": "^5.0.5",
18 | "@mui/material": "^5.0.6",
19 | "@mui/styles": "^5.0.2",
20 | "axios": "^0.23.0",
21 | "react": "^18.0.0",
22 | "react-dom": "^18.0.0",
23 | "react-router-dom": "^5.3.0",
24 | "react-swipeable-views": "^0.14.0",
25 | "source-map-explorer": "^2.5.2"
26 | },
27 | "devDependencies": {
28 | "@testing-library/jest-dom": "^5.15.0",
29 | "@testing-library/react": "^12.1.2",
30 | "@types/jest": "^27.0.2",
31 | "@types/react": "^17.0.43",
32 | "@types/react-dom": "^17.0.14",
33 | "@types/react-router-dom": "^5.3.2",
34 | "@types/react-swipeable-views": "^0.13.1",
35 | "@typescript-eslint/eslint-plugin": "^5.2.0",
36 | "@typescript-eslint/parser": "^5.2.0",
37 | "@vitejs/plugin-react": "^1.0.0",
38 | "eslint": "^8.1.0",
39 | "eslint-plugin-react": "^7.26.1",
40 | "jest": "^27.3.1",
41 | "less": "^4.1.2",
42 | "prettier": "^2.4.1",
43 | "ts-jest": "^27.0.7",
44 | "typescript": "^4.4.4",
45 | "vite": "^2.6.4"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "风影院",
3 | "name": "风影院",
4 | "description": "风影院,像风一样自由...",
5 | "icons": [
6 | {
7 | "src": "favicon.ico",
8 | "sizes": "64x64 32x32 24x24 16x16",
9 | "type": "image/x-icon"
10 | },
11 | {
12 | "src": "logo192.png",
13 | "type": "image/png",
14 | "sizes": "192x192"
15 | },
16 | {
17 | "src": "logo512.png",
18 | "type": "image/png",
19 | "sizes": "512x512"
20 | }
21 | ],
22 | "start_url": ".",
23 | "display": "standalone",
24 | "theme_color": "#000000",
25 | "background_color": "#ffffff"
26 | }
27 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/public/workbox-service-worker.js:
--------------------------------------------------------------------------------
1 | importScripts('https://storage.googleapis.com/workbox-cdn/releases/5.1.2/workbox-sw.js');
2 |
3 | const { routing, strategies, expiration } = workbox;
4 | const { registerRoute } = routing;
5 | const { NetworkFirst, CacheFirst, StaleWhileRevalidate } = strategies;
6 | const { ExpirationPlugin } = expiration;
7 |
8 | if (workbox) {
9 | console.log(`Yay! Workbox is loaded 🎉`);
10 | } else {
11 | console.log(`Boo! Workbox didn't load 😬`);
12 | }
13 |
14 |
15 |
16 | // 处理html
17 | registerRoute(({request})=>request.destination === 'document',new StaleWhileRevalidate());
18 |
19 | // 处理js
20 | registerRoute(({request})=>request.destination === 'script',new StaleWhileRevalidate());
21 |
22 |
23 |
24 | // 处理css
25 | const styleCapture = ({request})=>request.destination === 'style';
26 | const styleCache = new StaleWhileRevalidate({ cacheName : 'css-cache' });
27 | registerRoute(styleCapture,styleCache);
28 |
29 |
30 | // 处理图片
31 | const imageCapture = ({request})=>request.destination === 'image';
32 | const imageCache = new CacheFirst({
33 | cacheName : 'image-cache',
34 | plugins : [
35 | new ExpirationPlugin({
36 | // Cache only 100 images.
37 | maxEntries: 100,
38 | // Cache for a maximum of a week.
39 | maxAgeSeconds: 7 * 24 * 60 * 60,
40 | })
41 | ]
42 | });
43 | registerRoute(imageCapture,imageCache);
44 |
45 |
46 | // 处理m3u8 和 ts 文件
47 | const matchCb = ({url, request, event}) => {
48 | return /(\.m3u8|\.ts)/.test(url.pathname);
49 | };
50 |
51 | registerRoute( matchCb,new CacheFirst({
52 | cacheName : 'video-cache',
53 | plugins : [
54 | new ExpirationPlugin({
55 | // Cache only 100 images.
56 | maxEntries: 10000,
57 | // Cache for a maximum of a week.
58 | maxAgeSeconds: 24 * 60 * 60,
59 | })
60 | ]
61 | }))
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Slide } from '@mui/material';
2 | import { BrowserRouter, Route, Switch } from 'react-router-dom';
3 | import store from './store';
4 | import { checkBrowser } from './utils';
5 | import { ThemeProvider } from '@mui/material/styles';
6 | import { lazy, Suspense, useState } from 'react';
7 | import themeObj from './utils/theme';
8 | import LoadingPage from './components/LoadingPage';
9 |
10 | const Home = lazy(() => import('./pages/Home'));
11 | const Play = lazy(() => import('./pages/Play'));
12 | const Detail = lazy(() => import('./pages/Detail'));
13 | const Search = lazy(() => import('./pages/Search'));
14 | const WatchHistory = lazy(() => import('./pages/WatchHistory'));
15 |
16 | const routes = [
17 | { path: '/play', Component: Play },
18 | { path: '/detail', Component: Detail },
19 | { path: '/search/:query', Component: Search },
20 | { path: '/search', Component: Search },
21 | { path: '/watchhistory', Component: WatchHistory },
22 | { path: '/', Component: Home },
23 | ];
24 |
25 | const isSafari = checkBrowser() === 'Safari';
26 | // const isMobile = checkIsMobile();
27 |
28 | const renderWithTransition = (path: string, Component: React.FC) => {
29 | const render = (props: any) => {
30 | const { match, history } = props;
31 |
32 | if (!isSafari) {
33 | const direction = history.action === 'POP' ? 'right' : 'left';
34 | return (
35 |
36 |
37 |
38 |
39 |
40 | );
41 | } else {
42 | if (history.action === 'POP') {
43 | return ;
44 | } else {
45 | return (
46 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 | }
54 | };
55 | return (
56 |
57 | {render}
58 |
59 | );
60 | };
61 |
62 | const App = () => {
63 | const [theme, setTheme] = useState(themeObj.defaultTheme);
64 | const toggoleTheme = (type: any): void => {
65 | setTheme(themeObj.ThemeArr[type]);
66 | themeObj.setThemeLocal(type);
67 | };
68 | return (
69 |
70 |
71 |
72 | }>
73 |
74 | {routes.map(({ path, Component }) => renderWithTransition(path, Component))}
75 |
76 |
77 |
78 |
79 |
80 | );
81 | };
82 |
83 | export default App;
84 |
--------------------------------------------------------------------------------
/src/assets/css/iconfont.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'iconfont'; /* project id 1724904 */
3 | src: url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.eot');
4 | src: url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.eot?#iefix') format('embedded-opentype'),
5 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.woff2') format('woff2'),
6 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.woff') format('woff'),
7 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.ttf') format('truetype'),
8 | url('//at.alicdn.com/t/font_1724904_8ryev8rq29r.svg#iconfont') format('svg');
9 | }
10 |
--------------------------------------------------------------------------------
/src/assets/css/reset.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | }
4 | a[title] {
5 | display: none;
6 | }
7 |
--------------------------------------------------------------------------------
/src/assets/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/default.png
--------------------------------------------------------------------------------
/src/assets/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/logo192.png
--------------------------------------------------------------------------------
/src/assets/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/placeholder.png
--------------------------------------------------------------------------------
/src/assets/sun_main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeByZack/kongtv-react/daca97cfc7cb3fcf11933e08a3bc53e6a33537a5/src/assets/sun_main.png
--------------------------------------------------------------------------------
/src/components/ChipList/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Chip, Typography } from '@mui/material';
2 | import { noop } from '../../types/constant';
3 |
4 | const year = ['全部', '2023', '2022', '2021', '2020', '2019', '2018', '2017', '2016', '2015', '2014', '2013'];
5 | const area = [
6 | '全部',
7 | '大陆',
8 | '香港',
9 | '台湾',
10 | '美国',
11 | '法国',
12 | '英国',
13 | '日本',
14 | '韩国',
15 | '德国',
16 | '泰国',
17 | '印度',
18 | '意大利',
19 | '西班牙',
20 | '加拿大',
21 | '其他',
22 | ];
23 | interface IChipListProp {
24 | data?: string[];
25 | title?: string;
26 | onChange?: (txt: string) => void;
27 | now?: string;
28 | }
29 |
30 | const ChipList = (props: IChipListProp) => {
31 | const { data = year, title = '年代', onChange = noop, now = '' } = props;
32 | const handleClick = (item: string) => {
33 | if (item === '全部') {
34 | onChange('');
35 | } else {
36 | onChange(item);
37 | }
38 | };
39 |
40 | return (
41 |
49 |
50 | {title}:
51 |
52 | {data.map((i) => (
53 | handleClick(i)}
57 | size="small"
58 | label={i}
59 | variant={now === i ? 'outlined' : undefined}
60 | />
61 | ))}
62 |
63 | );
64 | };
65 |
66 | export interface IChipGroupProps {
67 | onChange: (txt: string) => void;
68 | nowShow: string;
69 | }
70 |
71 | export const YearChipList: React.FC = ({ onChange, nowShow }) => {
72 | return ;
73 | };
74 |
75 | export const AreaChipList: React.FC = ({ onChange, nowShow }) => {
76 | return ;
77 | };
78 |
79 | export default ChipList;
80 |
--------------------------------------------------------------------------------
/src/components/HideOnScroll/index.tsx:
--------------------------------------------------------------------------------
1 | import { Fade, useScrollTrigger } from '@mui/material';
2 | import { PropsWithChildren } from 'react';
3 |
4 | const HideOnScroll = (props: PropsWithChildren) => {
5 | const { children } = props;
6 | const trigger = useScrollTrigger();
7 | return (
8 |
9 | {children}
10 |
11 | );
12 | };
13 | export default HideOnScroll;
14 |
--------------------------------------------------------------------------------
/src/components/LoadingPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress, Stack } from '@mui/material';
2 |
3 | const LoadingPage = () => {
4 | return (
5 |
11 |
12 |
13 | );
14 | };
15 |
16 | export default LoadingPage;
17 |
--------------------------------------------------------------------------------
/src/components/MovieItem/index.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardMedia, CardContent, Typography, Skeleton } from '@mui/material';
2 | import defaultImg from '../../assets/placeholder.png';
3 | import store from '../../store';
4 | import { IMovieItem } from '../../types';
5 | interface IProps {
6 | data: IMovieItem;
7 | }
8 |
9 | const MovieItem = (props: IProps) => {
10 | const { data } = props;
11 | // const styles = useStyles();
12 |
13 | const { detail, jumpUtil } = store.useContainer();
14 | const { jumpToDetail } = jumpUtil;
15 | const onMovieClick = () => {
16 | detail.setNowMovie(data);
17 | jumpToDetail(data);
18 | };
19 |
20 | const bkUrl = `url(${data.vod_pic}),url(${defaultImg})`;
21 |
22 | return (
23 |
24 |
33 |
38 |
39 | {data.vod_name}
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export const MovieItemSkeleton = () => {
47 | return (
48 |
49 |
50 |
56 |
57 |
58 |
63 |
64 | .
65 |
66 |
67 |
68 |
69 | );
70 | };
71 |
72 | export default MovieItem;
73 |
--------------------------------------------------------------------------------
/src/components/MovieList/index.tsx:
--------------------------------------------------------------------------------
1 | import { Grid } from '@mui/material';
2 | import { IMovieItem } from '../../types';
3 | import MovieItem, { MovieItemSkeleton } from '../MovieItem';
4 |
5 | interface IProps {
6 | movies: IMovieItem[];
7 | }
8 |
9 | const MovieList = (props: IProps) => {
10 | const { movies } = props;
11 |
12 | if (!movies) return null;
13 |
14 | return (
15 |
16 |
17 | {movies.map((tile, index) => (
18 |
19 |
20 |
21 | ))}
22 |
23 |
24 | );
25 | };
26 |
27 | export const MovieListSkeleton = () => {
28 | return (
29 |
30 | {[1, 2, 3, 4, 5, 6].map((tile) => (
31 |
32 |
33 |
34 | ))}
35 |
36 | );
37 | };
38 |
39 | export default MovieList;
40 |
--------------------------------------------------------------------------------
/src/components/MyAppBar/index.tsx:
--------------------------------------------------------------------------------
1 | import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
2 | import HideOnScroll from '../HideOnScroll';
3 | import MenuIcon from '@mui/icons-material/Menu';
4 | import ArrowBackIcon from '@mui/icons-material/ArrowBack';
5 | import SearchIcon from '@mui/icons-material/Search';
6 | import { PropsWithChildren } from 'react';
7 | import { noop } from '../../types/constant';
8 |
9 | interface IProps {
10 | title: string;
11 | toggoleDrawer?: () => void;
12 | onSearch?: () => void;
13 | }
14 |
15 | interface INavBarProps {
16 | title: string;
17 | onBack?: () => void;
18 | }
19 |
20 | const MyAppBar = (props: PropsWithChildren) => {
21 | const { title, toggoleDrawer, onSearch = noop, ...restProps } = props;
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {title}
32 |
33 |
34 |
35 |
36 |
37 | {props.children}
38 |
39 |
40 | );
41 | };
42 |
43 | export const NavBar = (props: INavBarProps) => {
44 | const { title = '风影院', onBack = noop } = props;
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 | {title}
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | export default MyAppBar;
61 |
--------------------------------------------------------------------------------
/src/components/MyDrawer/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Drawer,
3 | IconButton,
4 | ImageListItem,
5 | ImageListItemBar,
6 | List,
7 | ListItem,
8 | ListItemIcon,
9 | ListItemText,
10 | } from '@mui/material';
11 | import { noop } from '../../types/constant';
12 | import drawerImg from '../../assets/sun_main.png';
13 | import logoImg from '../../assets/logo192.png';
14 |
15 | interface IProps {
16 | open: boolean;
17 | onClose?: () => void;
18 | menus?: { txt: string; icon: React.ReactNode; onClick?: (txt: string) => void }[];
19 | }
20 |
21 | const MyDrawer = (props: IProps) => {
22 | const { open, onClose = noop, menus = [] } = props;
23 | return (
24 |
25 |
26 |
27 |
33 |
34 |
35 | }
36 | />
37 |
38 |
39 | {menus.map((menu) => {
40 | const { txt, icon, onClick = noop } = menu;
41 | return (
42 | onClick('watchhistory')}>
43 | {icon}
44 |
45 |
46 | );
47 | })}
48 |
49 |
50 | );
51 | };
52 | export default MyDrawer;
53 |
--------------------------------------------------------------------------------
/src/components/SearchBar/index.tsx:
--------------------------------------------------------------------------------
1 | import ArrowBackIcon from '@mui/icons-material/ArrowBack';
2 | import SearchIcon from '@mui/icons-material/Search';
3 | import { AppBar, Box, Chip, ClickAwayListener, IconButton, InputBase } from '@mui/material';
4 | import { useState } from 'react';
5 | import storeUtils from '../../utils/storeUtils';
6 |
7 | interface IProps {
8 | onBack: () => void;
9 | onSearch: (str: string) => void;
10 | placeholder?: string;
11 | }
12 |
13 | const SearchBar = (props: IProps) => {
14 | const { onBack, onSearch, placeholder } = props;
15 | const [showHistory, setShowHistory] = useState(false);
16 | const [searchText, setSearchText] = useState('');
17 |
18 | const handleKeyUp: React.KeyboardEventHandler = (e) => {
19 | if (e.keyCode === 13 && onSearch) {
20 | onSearch(searchText);
21 | }
22 | };
23 |
24 | const handleHistoryClick = (e: string) => () => {
25 | setSearchText(e);
26 | onSearch(e);
27 | setShowHistory(false);
28 | };
29 |
30 | return (
31 | setShowHistory(false)}>
32 |
33 |
40 |
41 |
42 |
43 | setShowHistory(true)}
45 | value={searchText}
46 | onKeyUp={handleKeyUp}
47 | onChange={(e) => setSearchText(e.target.value)}
48 | sx={{ ml: 1, flex: 1, color: 'common.white' }}
49 | placeholder={placeholder || ''}
50 | />
51 | onSearch(searchText)}
54 | size="large"
55 | >
56 |
57 |
58 |
59 | {showHistory && (
60 |
61 | {storeUtils.getSearchHistory()?.map((t) => (
62 |
71 | ))}
72 |
73 | )}
74 |
75 |
76 | );
77 | };
78 |
79 | export default SearchBar;
80 |
--------------------------------------------------------------------------------
/src/components/Toast/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRef } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { ControlToast, IControlRef, ToastType } from './toast';
4 |
5 | const createNotice = () => {
6 | const div = document.createElement('div');
7 | document.body.appendChild(div);
8 | let timerId: number;
9 |
10 | const destroy = () => {
11 | ReactDOM.unmountComponentAtNode(div);
12 | };
13 |
14 | const loading = (content: string, duration: number = 1000) => {
15 | const ref = createRef();
16 | ReactDOM.render(, div);
17 | ref.current?.show({
18 | type: ToastType.Loading,
19 | content,
20 | });
21 |
22 | if (timerId) {
23 | clearTimeout(timerId);
24 | }
25 |
26 | if (duration > 0) {
27 | setTimeout(destroy, duration);
28 | }
29 | };
30 |
31 | const info = (content: string, duration: number = 1000) => {
32 | const ref = createRef();
33 | ReactDOM.render(, div);
34 | ref.current?.show({
35 | type: ToastType.Info,
36 | content,
37 | });
38 | if (timerId) {
39 | clearTimeout(timerId);
40 | }
41 |
42 | if (duration > 0) {
43 | setTimeout(destroy, duration);
44 | }
45 | };
46 |
47 | return {
48 | loading,
49 | info,
50 | destroy,
51 | };
52 | };
53 |
54 | let notice: ReturnType;
55 |
56 | const getInstance = () => {
57 | if (!notice) {
58 | notice = createNotice();
59 | }
60 | return notice;
61 | };
62 |
63 | export default {
64 | info: (content: string, duration?: number) => getInstance().info(content, duration),
65 | loading: (content: string, duration?: number) => getInstance().loading(content, duration),
66 | destroy: () => getInstance().destroy(),
67 | };
68 |
--------------------------------------------------------------------------------
/src/components/Toast/toast.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress, Backdrop, Stack, Typography, Box } from '@mui/material';
2 | import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
3 |
4 | export enum ToastType {
5 | Loading,
6 | Info,
7 | }
8 |
9 | interface IProps {
10 | type: ToastType;
11 | content: string;
12 | }
13 |
14 | interface IControlProps {}
15 |
16 | export interface IControlRef {
17 | show: (toastProps: IProps, duration?: number) => void;
18 | hide: () => void;
19 | }
20 |
21 | const Toast = (props: IProps) => {
22 | const { type = 'info', content } = props;
23 | return (
24 | theme.zIndex.appBar + 1,
35 | }}
36 | >
37 |
48 | {type === ToastType.Loading && }
49 | {content}
50 |
51 |
52 | );
53 | };
54 |
55 | export const ControlToast = forwardRef((props, ref) => {
56 | const [toastProps, setToastProps] = useState();
57 | const [visible, setVisible] = useState(false);
58 |
59 | const hide = () => {
60 | setToastProps(undefined);
61 | setVisible(false);
62 | };
63 |
64 | useImperativeHandle(
65 | ref,
66 | () => {
67 | return {
68 | show: (toastProps: IProps) => {
69 | setToastProps(toastProps);
70 | setVisible(true);
71 | },
72 | hide,
73 | };
74 | },
75 | []
76 | );
77 | if (!toastProps || !visible) return null;
78 | return ;
79 | });
80 |
81 | export default Toast;
82 |
--------------------------------------------------------------------------------
/src/components/ratioImage/index.tsx:
--------------------------------------------------------------------------------
1 | import DefaultImg from '../../assets/placeholder.png';
2 | const style = {
3 | position: 'relative',
4 | paddingBottom: '140%',
5 | };
6 |
7 | const imgStyle: any = {
8 | position: 'absolute',
9 | left: 0,
10 | right: 0,
11 | width: '100%',
12 | height: '100%',
13 | objectFit: 'cover',
14 | };
15 | const errImg = (e: any, DefaultImg: string) => {
16 | e.target.src = DefaultImg;
17 | };
18 | const RatioImage = (props: any) => {
19 | const { imgUrl, imgAlt, ratio = 1 } = props;
20 |
21 | const height = `${ratio * 100}%`;
22 |
23 | const wrapperStyle: any = {
24 | ...style,
25 | paddingBottom: height,
26 | };
27 |
28 | return (
29 |
30 | {/*

(e.target.src = DefaultImg)} /> */}
31 |

errImg(e, DefaultImg)} />
32 |
33 | );
34 | };
35 | export default RatioImage;
36 |
--------------------------------------------------------------------------------
/src/components/swiper/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import RatioImage from '../ratioImage';
3 | import './style.css';
4 | import { swiperStyle, setSwipeType } from '../../types/swiper';
5 | import { noop } from '../../types/constant';
6 | const defaultStyle = { visibility: 'hidden', width: 0, height: 0 };
7 | const styles = [
8 | {
9 | transform: 'translate(-150%, -50%) scale3d(0.8, 0.8, 0.8)',
10 | zIndex: 1,
11 | },
12 | {
13 | transform: 'translate(-100%, -50%) scale3d(0.9, 0.9, 0.9)',
14 | zIndex: 2,
15 | },
16 | {
17 | transform: 'translate(-50%, -50%) scale3d(1, 1, 1)',
18 | zIndex: 3,
19 | },
20 | {
21 | transform: 'translate(0%, -50%) scale3d(0.9, 0.9, 0.9)',
22 | zIndex: 2,
23 | },
24 | {
25 | transform: 'translate(50%, -50%) scale3d(0.8, 0.8, 0.8)',
26 | zIndex: 1,
27 | },
28 | ];
29 |
30 | const calculatingStyle = (index: number, length: number): swiperStyle[] => {
31 | const resStyle: swiperStyle[] = [];
32 | for (let i = 0; i < length; i++) {
33 | const _index = !index
34 | ? i - index
35 | : index > 0
36 | ? i - index >= 0
37 | ? i - index
38 | : i - index + length
39 | : i - index > 4
40 | ? i - index - length
41 | : i - index;
42 | const style = styles[_index] ?? defaultStyle;
43 | resStyle.push(style);
44 | }
45 | // console.log( index,'index' );
46 | // console.log( resStyle,'resStyle' );
47 | return resStyle;
48 | };
49 |
50 | const Swiper = (props: any): any => {
51 | const { imgArr = [], autoPlay = true, onSwiperItemClick = noop } = props;
52 | const [index, setIndex] = useState(0);
53 | const [_styles, setStyle]: [any, any] = useState([]);
54 | let xStart: number;
55 | const startHandle = (e: any): void => {
56 | e.stopPropagation();
57 | xStart = e.touches[0].pageX;
58 | };
59 | const moveHandle = (e: any): void => {
60 | e.stopPropagation();
61 | const touch = e.touches[0];
62 | xStart - touch.pageX <= -50 && swiperFn(index, 'right');
63 | xStart - touch.pageX >= 50 && swiperFn(index, 'left');
64 | };
65 |
66 | const setIndexFn: setSwipeType = (index: number, direction: string): number => {
67 | const _index =
68 | direction === 'left'
69 | ? index - 1 === -imgArr.length
70 | ? 0
71 | : index - 1
72 | : index + 1 === imgArr.length
73 | ? 0
74 | : index + 1;
75 | return _index;
76 | };
77 | const swiperFn: setSwipeType = (index: number, direction: string): void => {
78 | const _index = setIndexFn(index, direction);
79 | setIndex(_index);
80 | setStyle(calculatingStyle(_index, imgArr.length));
81 | };
82 | useEffect(() => {
83 | if (!imgArr.length) return;
84 | const timer = setTimeout(() => swiperFn(index, 'right'), 2000);
85 | return () => clearTimeout(timer);
86 | //eslint-disable-next-line
87 | }, [imgArr, autoPlay, index]);
88 | useEffect(() => {
89 | imgArr.length && setStyle(calculatingStyle(0, imgArr.length));
90 | }, [imgArr]);
91 |
92 | return (
93 |
94 | {imgArr.map((d: any, i: any) => {
95 | return (
96 |
onSwiperItemClick(d, i)}
101 | >
102 | {/*

*/}
103 |
104 |
105 | );
106 | })}
107 |
108 | );
109 | };
110 |
111 | export default Swiper;
112 |
--------------------------------------------------------------------------------
/src/components/swiper/style.css:
--------------------------------------------------------------------------------
1 | .ks-swiper {
2 | position: relative;
3 | width: 100%;
4 | padding-bottom: 55%;
5 | }
6 | .ks-swiper-item {
7 | text-align: center;
8 | position: absolute;
9 | width: 35%;
10 | color: #fff;
11 | top: 50%;
12 | left: 50%;
13 | transform: translate(-50%, -50%);
14 | transition: all 0.5s ease;
15 | }
16 |
--------------------------------------------------------------------------------
/src/http/index.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { ICommonRequestParams, IMovieItem, MovieType } from '../types';
3 |
4 | // axios.defaults.baseURL = 'http://47.94.254.236:55';
5 | // axios.defaults.baseURL = 'https://fengxiaoci.cn';
6 | axios.defaults.baseURL = 'https://api.zackdk.com/movie';
7 | // axios.defaults.baseURL = 'http://127.0.0.1:8360/movie';
8 | axios.defaults.timeout = 20000;
9 |
10 | axios.interceptors.request.use(
11 | function (config) {
12 | return config;
13 | },
14 | function (error) {
15 | return Promise.reject(error);
16 | }
17 | );
18 |
19 | axios.interceptors.response.use(
20 | function (response) {
21 | // console.log(response);
22 | return response.data;
23 | },
24 | function (error) {
25 | // Toast.error(error.message);
26 | return Promise.reject(error);
27 | }
28 | );
29 |
30 | const getIndex = () => axios.get('/index');
31 |
32 | const getCategory = (type: MovieType, params: ICommonRequestParams) =>
33 | axios.get(`/${type}`, { params });
34 |
35 | const searchMovie = (searchText: string) => {
36 | const params = {
37 | router: 'search',
38 | word: searchText,
39 | pagesize: 100,
40 | };
41 | return axios.get('/search', { params });
42 | };
43 |
44 | const clearCacheAndRefresh = async () => {
45 | const res = await axios.get('http://fengxiaoci.cn/api.php/timming/index.html?enforce=1&name=bdzy');
46 | const res2 = await axios.get('https://api.fengxiaoci.cn/movie/updateindex');
47 | };
48 |
49 | export { getIndex, getCategory, searchMovie, clearCacheAndRefresh };
50 | export default axios;
51 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import App from './App';
3 | const container = document.getElementById('root');
4 | const root = createRoot(container!);
5 | root.render();
6 |
--------------------------------------------------------------------------------
/src/pages/Detail/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import {
3 | Paper,
4 | Card,
5 | CardMedia,
6 | Typography,
7 | Tabs,
8 | Tab,
9 | Grid,
10 | Button,
11 | Fade,
12 | Box,
13 | CssBaseline,
14 | } from '@mui/material';
15 | import store from '../../store';
16 | import { NavBar } from '../../components/MyAppBar';
17 | import { IJuJi, IPlayInfo } from '../../types';
18 | import storeUtils from '../../utils/storeUtils';
19 |
20 | interface IDescLine {
21 | title: string;
22 | desc: string;
23 | lines?: number;
24 | }
25 |
26 | const decodeJuJi = (playUrls: string) => {
27 | return playUrls
28 | .split('$$$')
29 | .map((diffSource) => {
30 | const jujiArr: IJuJi[] = diffSource.split('#').map((juji) => {
31 | const [text, link] = juji.split('$');
32 | return { text, link };
33 | });
34 | return jujiArr;
35 | })
36 | .filter((arr) => arr[0].link.endsWith('.m3u8'));
37 | };
38 |
39 | const DescLine = (props: IDescLine) => {
40 | const { title, desc, lines = 1 } = props;
41 |
42 | return (
43 |
56 | {title}
57 |
58 | {desc}
59 |
60 |
61 | );
62 | };
63 |
64 | const MovieDetail = () => {
65 | const { detail, jumpUtil } = store.useContainer();
66 | const { jumpToPlay, jumpBack } = jumpUtil;
67 | const { nowMovie, clear } = detail;
68 | const [tabValue, setTabValue] = useState(0);
69 |
70 | if (!nowMovie) return null;
71 |
72 | const onPlayClick = (item: IJuJi) => () => {
73 | storeUtils.addWatchHistory(nowMovie, item);
74 | const palyObj: IPlayInfo = {
75 | title: nowMovie.vod_name,
76 | ...item,
77 | };
78 | jumpToPlay(palyObj);
79 | };
80 |
81 | const onBackClick = (): void => {
82 | clear();
83 | jumpBack();
84 | };
85 |
86 | const playSources = decodeJuJi(nowMovie.vod_play_url);
87 |
88 | return (
89 |
90 |
91 | onBackClick()} />
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | 简介:
108 |
109 |
110 | {nowMovie.vod_blurb}
111 |
112 |
113 |
114 |
115 |
116 | 剧集列表:
117 |
118 | setTabValue(v)}>
119 | {playSources.map((t, i) => (
120 |
121 | ))}
122 |
123 | {playSources.map((playSource, index) => {
124 | return (
125 |
126 |
127 | {playSource.map((juji, i) => {
128 | return (
129 |
130 |
139 |
140 | );
141 | })}
142 |
143 |
144 | );
145 | })}
146 |
147 |
148 | );
149 | };
150 |
151 | export default MovieDetail;
152 |
--------------------------------------------------------------------------------
/src/pages/Home/homeCategory.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress, Box } from '@mui/material';
2 | import { useEffect, useRef } from 'react';
3 | import { YearChipList, AreaChipList } from '../../components/ChipList';
4 | import MovieList from '../../components/MovieList';
5 | import store from '../../store';
6 | import { MovieType } from '../../types';
7 |
8 | interface IProps {
9 | type: MovieType;
10 | }
11 |
12 | const HomeCategory = (props: IProps) => {
13 | const { type } = props;
14 | const categoryObj = store.useContainer()[type];
15 | const { filterOption, setFilterOption, getData, isFetching, list } = categoryObj;
16 | const boxRef = useRef();
17 |
18 | const handleFilterChange = (key: string) => (item: string) => {
19 | const option = { ...filterOption, [key]: item };
20 | setFilterOption(option);
21 | };
22 |
23 | useEffect(() => {
24 | if (!boxRef.current) return;
25 | let ticking = false;
26 | const doSomething = () => {
27 | if (isFetching) return;
28 | const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
29 | if (scrollHeight - scrollTop - clientHeight < 20) {
30 | getData();
31 | }
32 | };
33 | const handleScroll = () => {
34 | if (!ticking) {
35 | window.requestAnimationFrame(function () {
36 | doSomething();
37 | ticking = false;
38 | });
39 | ticking = true;
40 | }
41 | };
42 | boxRef.current?.addEventListener('scroll', handleScroll);
43 | return () => {
44 | boxRef.current?.removeEventListener('scroll', handleScroll);
45 | };
46 | // eslint-disable-next-line
47 | }, [isFetching]);
48 |
49 | return (
50 |
58 |
59 |
60 |
61 | {isFetching && (
62 |
63 |
64 |
65 | )}
66 |
67 | );
68 | };
69 | export default HomeCategory;
70 |
--------------------------------------------------------------------------------
/src/pages/Home/homeMain.tsx:
--------------------------------------------------------------------------------
1 | import Swiper from '../../components/swiper';
2 | import store from '../../store';
3 | import { IMovieItem } from '../../types';
4 | import LocalMoviesIcon from '@mui/icons-material/LocalMovies';
5 | import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
6 | import { Box, Skeleton, Typography } from '@mui/material';
7 | import MovieList, { MovieListSkeleton } from '../../components/MovieList';
8 | import { useMemo } from 'react';
9 |
10 | interface IHomeItemProps {
11 | title: string;
12 | movies: IMovieItem[];
13 | }
14 |
15 | const HomeItem = (props: IHomeItemProps) => {
16 | const { title, movies } = props;
17 | return (
18 |
23 |
30 |
31 | {title}
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | const HomeItemSkeleton = (props: Pick) => {
41 | return (
42 |
47 |
54 |
55 | {props.title}
56 |
57 |
58 |
59 |
60 |
61 | );
62 | };
63 | const HomeMainSkeleton = () => {
64 | return (
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | const splitAdviceMovie = (data: IMovieItem[]) => {
76 | const dy = data.filter((movie) => movie.type_id_1 === 1);
77 | const dsj = data.filter((movie) => movie.type_id_1 === 2);
78 | const zy = data.filter((movie) => movie.type_id === 4);
79 | const dm = data.filter((movie) => movie.type_id === 3);
80 |
81 | const swipers = [dy[0], dsj[0], zy[0], dm[0], dy[1]].filter((i) => i);
82 |
83 | return {
84 | homeItems: [
85 | { title: '热播电影', data: dy },
86 | { title: '电视剧', data: dsj },
87 | { title: '热播综艺', data: zy },
88 | { title: '热播动漫', data: dm },
89 | ],
90 | swipers,
91 | };
92 | };
93 |
94 | const HomeMain = () => {
95 | const { home } = store.useContainer();
96 | const { adviceMovieList: data, isFetching } = home;
97 | const { detail, jumpUtil } = store.useContainer();
98 | const { jumpToDetail } = jumpUtil;
99 | const { homeItems, swipers } = useMemo(() => splitAdviceMovie(data), [data]);
100 |
101 | const onSwiperItemClick = (movie: IMovieItem) => {
102 | detail.setNowMovie(movie);
103 | jumpToDetail(movie);
104 | };
105 |
106 | if (isFetching) {
107 | return ;
108 | }
109 |
110 | return (
111 |
112 |
113 | {homeItems.map((item) => (
114 |
115 | ))}
116 |
117 | );
118 | };
119 |
120 | export default HomeMain;
121 |
--------------------------------------------------------------------------------
/src/pages/Home/index.tsx:
--------------------------------------------------------------------------------
1 | import { CssBaseline, Tab, Tabs, Toolbar, Box } from '@mui/material';
2 | import SwipeableViews from 'react-swipeable-views';
3 | import MyDrawer from '../../components/MyDrawer';
4 | import MyAppBar from '../../components/MyAppBar';
5 | import store from '../../store';
6 | import HomeCategory from './homeCategory';
7 | import HomeMain from './homeMain';
8 | import HistoryIcon from '@mui/icons-material/History';
9 | import InfoIcon from '@mui/icons-material/Info';
10 | import LightIcon from '@mui/icons-material/Brightness7';
11 | import RefreshIcon from '@mui/icons-material/Refresh';
12 | import DarkIcon from '@mui/icons-material/Brightness4';
13 | import themeObj from '../../utils/theme';
14 | import { TABS, TABS_NAME } from '../../types/constant';
15 | import Toast from '../../components/Toast';
16 | import { clearCacheAndRefresh } from '../../http';
17 | const swStyle = { height: '100%' };
18 |
19 | const useInitMenus = (
20 | jumpToWatchHistory: () => void,
21 | toggoleTheme: () => void,
22 | themeHelper: any
23 | ) => {
24 | const menus = [
25 | { txt: '观看记录', icon: , onClick: jumpToWatchHistory },
26 | {
27 | txt: themeHelper.theme.palette.mode === 'dark' ? '夜间模式' : '白天模式',
28 | icon: themeHelper.theme.palette.mode === 'dark' ? : ,
29 | onClick: toggoleTheme,
30 | },
31 | {
32 | txt: '更新资源',
33 | onClick: async () => {
34 | },
35 | icon: ,
36 | },
37 | { txt: '关于', icon: },
38 | ];
39 | return menus;
40 | };
41 |
42 | const Home = () => {
43 | const { home, jumpUtil, themeHelper } = store.useContainer();
44 | const { drawerStatus, setDrawerStatus, tabIndex, setTabIndex } = home;
45 | const toggoleTheme = () => {
46 | themeHelper.theme === themeObj.ThemeArr.dark
47 | ? themeHelper.toggoleTheme('light')
48 | : themeHelper.toggoleTheme('dark');
49 | setDrawerStatus(false);
50 | };
51 | const menus = useInitMenus(jumpUtil.jumpToWatchHistory, toggoleTheme, themeHelper);
52 |
53 | const handleChange = (event: React.SyntheticEvent, newValue: number) => {
54 | setTabIndex(newValue);
55 | };
56 |
57 | const handleChangeIndex = (index: number) => {
58 | setTabIndex(index);
59 | };
60 |
61 | return (
62 |
71 |
72 | jumpUtil.jumpToSearch()}
75 | toggoleDrawer={() => setDrawerStatus(true)}
76 | >
77 |
84 |
85 | {TABS.map((item) => {
86 | return ;
87 | })}
88 |
89 |
90 | setDrawerStatus(false)} />
91 |
92 |
93 |
94 |
100 |
107 |
108 | {TABS.map((item) => (
109 |
110 | ))}
111 |
112 |
113 |
114 | );
115 | };
116 | export default Home;
117 |
--------------------------------------------------------------------------------
/src/pages/Play/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { Box, CssBaseline } from '@mui/material';
3 | import { NavBar } from '../../components/MyAppBar';
4 | import store from '../../store';
5 | import { getQuery } from '../../utils';
6 | import { useLocation } from 'react-router-dom';
7 |
8 | const VIDEO_ID = 'VIDEO_ID';
9 |
10 | const PLAYER_CONFIG = (url: string) => ({
11 | id: VIDEO_ID,
12 | url,
13 | playsinline: true,
14 | whitelist: [''],
15 | playbackRate: [0.5, 0.75, 1, 1.5, 2],
16 | defaultPlaybackRate: 1,
17 | download: true,
18 | closeVideoTouch: true,
19 | airplay: true,
20 | fluid: true,
21 | });
22 |
23 | const PlayMovie = () => {
24 | const { jumpUtil } = store.useContainer();
25 | const { jumpBack } = jumpUtil;
26 | const location = useLocation();
27 | const nowPlay = getQuery(location.search);
28 |
29 | useEffect(() => {
30 | if (!nowPlay.url) return;
31 | const playerXG = new window.HlsJsPlayer(PLAYER_CONFIG(nowPlay.url));
32 | return () => playerXG.destroy();
33 | }, [nowPlay.url]);
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | );
42 | };
43 | export default PlayMovie;
44 |
--------------------------------------------------------------------------------
/src/pages/Search/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box, CssBaseline, Toolbar } from '@mui/material';
2 | import MovieList from '../../components/MovieList';
3 | import SearchBar from '../../components/SearchBar';
4 | import store from '../../store';
5 |
6 | const MovieSearch = () => {
7 | const { searchState, jumpUtil } = store.useContainer();
8 | const { jumpBack } = jumpUtil;
9 | const { searchRes, search } = searchState;
10 |
11 | const onSearch = (e: string) => {
12 | if (!e) {
13 | console.info('输入搜索关键字!');
14 | return;
15 | }
16 | search(e);
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 | export default MovieSearch;
30 |
--------------------------------------------------------------------------------
/src/pages/WatchHistory/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import {
3 | List,
4 | ListItem,
5 | ListItemAvatar,
6 | ListItemText,
7 | Avatar,
8 | Box,
9 | CssBaseline,
10 | } from '@mui/material';
11 | import store from '../../store';
12 | import storeUtils from '../../utils/storeUtils';
13 | import { IMovieItem } from '../../types';
14 | import { NavBar } from '../../components/MyAppBar';
15 |
16 | const WatchHistory = () => {
17 | const [history, setHistory] = useState<
18 | (IMovieItem & {
19 | watch_history?: string | undefined;
20 | })[]
21 | >([]);
22 | const { jumpUtil, detail } = store.useContainer();
23 | const { jumpBack, jumpToDetail } = jumpUtil;
24 | useEffect(() => {
25 | const watchHistory = storeUtils.getWatchHistory();
26 | if (watchHistory?.length) {
27 | setHistory(watchHistory);
28 | }
29 | }, []);
30 |
31 | const jump = (movie: IMovieItem) => {
32 | detail.setNowMovie(movie);
33 | jumpToDetail(movie);
34 | };
35 |
36 | return (
37 | <>
38 |
39 |
40 | {history.length > 0 ? (
41 |
42 | {history.map((item) => {
43 | const textInfo = (
44 | <>
45 | {`${item.vod_area}-${item.vod_class}-${item.vod_director}`}
49 | {`观看至${item.watch_history}`}
50 | >
51 | );
52 |
53 | return (
54 | {
58 | jump(item);
59 | }}
60 | >
61 |
62 |
63 |
64 |
69 |
70 | );
71 | })}
72 |
73 | ) : (
74 | ''
75 | )}
76 | >
77 | );
78 | };
79 | export default WatchHistory;
80 |
--------------------------------------------------------------------------------
/src/store/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/display-name */
2 | import { createContainer } from './unstate-next';
3 | import { useState, useEffect } from 'react';
4 | import { getCategory, getIndex, searchMovie } from '../http';
5 | import React from 'react';
6 | import { IMovieItem, IPlayInfo, MovieType } from '../types';
7 | import storeUtils from '../utils/storeUtils';
8 | import { useHistory } from 'react-router-dom';
9 | import Toast from '../components/Toast';
10 |
11 | const useStore = (themeHelper: any) => {
12 | const home = useHome();
13 | const dy = useCategory(MovieType.dy);
14 | const dsj = useCategory(MovieType.dsj);
15 | const dm = useCategory(MovieType.dm);
16 | const zy = useCategory(MovieType.zy);
17 | const detail = useDetail();
18 | const searchState = useSearch();
19 | const jumpUtil = useJumpUtil();
20 |
21 | return {
22 | home,
23 | dy,
24 | dsj,
25 | dm,
26 | zy,
27 | detail,
28 | searchState,
29 | jumpUtil,
30 | themeHelper,
31 | };
32 | };
33 |
34 | const useJumpUtil = () => {
35 | const history = useHistory();
36 |
37 | const jumpToDetail = (data: IMovieItem) => {
38 | history.push({ pathname: '/detail', state: data });
39 | };
40 |
41 | const jumpToSearch = () => {
42 | history.push({ pathname: '/search' });
43 | };
44 |
45 | const jumpToWatchHistory = () => {
46 | history.push({ pathname: '/watchhistory' });
47 | };
48 |
49 | const jumpToPlay = (playInfo: IPlayInfo) => {
50 | const url = `/play?name=${playInfo.title}-${playInfo.text}&url=${playInfo.link}`;
51 | history.push(url);
52 | };
53 |
54 | const jumpToHome = (msg?: string) => {
55 | // eslint-disable-next-line no-console
56 | console.log(msg);
57 | history.push({ pathname: '/' });
58 | };
59 |
60 | const jumpBack = () => history.goBack();
61 |
62 | return { jumpToDetail, jumpToWatchHistory, jumpToSearch, jumpToPlay, jumpBack, jumpToHome };
63 | };
64 |
65 | const useHome = () => {
66 | const [tabIndex, setTabIndex] = useState(0);
67 | const [isFetching, setIsFetching] = useState(false);
68 | const [drawerStatus, setDrawerStatus] = useState(false);
69 | const [adviceMovieList, setAdviceMovieList] = useState([]);
70 |
71 | const getAdviceData = async () => {
72 | setIsFetching(true);
73 | const data = await getIndex();
74 | setAdviceMovieList(data);
75 | setIsFetching(false);
76 | };
77 |
78 | useEffect(() => {
79 | getAdviceData();
80 | }, []);
81 |
82 | return {
83 | isFetching,
84 | tabIndex,
85 | setTabIndex,
86 | adviceMovieList,
87 | drawerStatus,
88 | setDrawerStatus,
89 | };
90 | };
91 | const useCategory = (type: MovieType) => {
92 | const [page, setPage] = useState(0);
93 | const [list, setList] = useState([]);
94 | const [isFetching, setIsFetching] = useState(false);
95 | const [filterOption, setFilterOption] = useState<{ [x: string]: any }>({});
96 |
97 | const getData = async (resetPage?: boolean) => {
98 | if (resetPage) {
99 | setIsFetching(true);
100 | const data = await getCategory(type, { page: 1, pagesize: 9, ...filterOption });
101 | const newList = [...data];
102 | setList(newList);
103 | setPage(1);
104 | setIsFetching(false);
105 | return;
106 | }
107 |
108 | if (isFetching) return;
109 | setIsFetching(true);
110 | const data = await getCategory(type, { page: page + 1, pagesize: 9, ...filterOption });
111 | const newList = [...list, ...data];
112 | setList(newList);
113 | setPage(page + 1);
114 | setIsFetching(false);
115 | };
116 |
117 | useEffect(() => {
118 | getData(true);
119 | }, [filterOption]);
120 |
121 | return {
122 | isFetching,
123 | page,
124 | list,
125 | getData,
126 | filterOption,
127 | setFilterOption,
128 | };
129 | };
130 | const useDetail = () => {
131 | const [nowMovie, setNowMovie] = useState();
132 | const clear = () => setNowMovie(undefined);
133 | return {
134 | nowMovie,
135 | setNowMovie,
136 | clear,
137 | };
138 | };
139 |
140 | const useSearch = () => {
141 | const [searchRes, setSearchRes] = useState([]);
142 |
143 | const search = async (text: string) => {
144 | Toast.loading('正在加载数据', 0);
145 | // setSearchText(searchText);
146 | storeUtils.addSearchHistory(text);
147 | const data = await searchMovie(text);
148 | if (data.length === 0) {
149 | Toast.destroy();
150 | Toast.info('没有搜索到相关影片');
151 | } else {
152 | Toast.destroy();
153 | }
154 | setSearchRes(data);
155 | };
156 | return {
157 | searchRes,
158 | search,
159 | };
160 | };
161 |
162 | const store = createContainer(useStore);
163 |
164 | export const injectStore =
165 | >(Comp: T) =>
166 | (props: React.ComponentProps) =>
167 | (
168 |
169 |
170 |
171 | );
172 |
173 | export default store;
174 |
--------------------------------------------------------------------------------
/src/store/unstate-next.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const EMPTY: unique symbol = Symbol();
4 |
5 | export interface ContainerProviderProps {
6 | initialState?: State;
7 | children: React.ReactNode;
8 | }
9 |
10 | export interface Container {
11 | Provider: React.ComponentType>;
12 | useContainer: () => Value;
13 | }
14 |
15 | export function createContainer(
16 | useHook: (initialState?: State) => Value
17 | ): Container {
18 | const Context = React.createContext(EMPTY);
19 |
20 | function Provider(props: ContainerProviderProps) {
21 | const value = useHook(props.initialState);
22 | return {props.children};
23 | }
24 |
25 | function useContainer(): Value {
26 | const value = React.useContext(Context);
27 | if (value === EMPTY) {
28 | throw new Error('Component must be wrapped with ');
29 | }
30 | return value;
31 | }
32 |
33 | return { Provider, useContainer };
34 | }
35 |
36 | export function useContainer(container: Container): Value {
37 | return container.useContainer();
38 | }
39 |
--------------------------------------------------------------------------------
/src/types/constant.ts:
--------------------------------------------------------------------------------
1 | import { MovieType } from '.';
2 |
3 | export const noop = () => {};
4 |
5 | export const TABS = [MovieType.dy, MovieType.dsj, MovieType.dm, MovieType.zy];
6 |
7 | export const TABS_NAME = {
8 | [MovieType.dy]: '电影',
9 | [MovieType.dsj]: '电视剧',
10 | [MovieType.dm]: '动漫',
11 | [MovieType.zy]: '综艺',
12 | };
13 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface IMovieItem {
2 | vod_id: number;
3 | type_id: number;
4 | vod_time: number;
5 | vod_level: number;
6 | type_id_1: number;
7 | vod_actor: string;
8 | vod_area: string;
9 | vod_blurb: string;
10 | vod_class: string;
11 | vod_content: string;
12 | vod_director: string;
13 | vod_lang: string;
14 | vod_name: string;
15 | vod_pic: string;
16 | vod_play_url: string;
17 | vod_remarks: string;
18 | vod_year: string;
19 | }
20 |
21 | export interface ICommonRequestParams {
22 | page?: number;
23 | pagesize?: number;
24 | year?: string;
25 | area?: string;
26 | }
27 |
28 | export enum MovieType {
29 | dy = 'dy',
30 | dsj = 'dsj',
31 | zy = 'zy',
32 | dm = 'dm',
33 | }
34 |
35 | export interface IJuJi {
36 | text: string;
37 | link: string;
38 | }
39 | export interface IPlayInfo extends IJuJi {
40 | title: string;
41 | }
42 |
--------------------------------------------------------------------------------
/src/types/swiper.ts:
--------------------------------------------------------------------------------
1 | export interface swiperStyle {
2 | transform?: string;
3 | zIndex?: number;
4 | }
5 | export type setSwipeType = (index: number, direction: string) => any;
6 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export const getQuery = (str: string) => {
2 | const queryStr = str.split('?')[1];
3 | const arr = queryStr.split('&');
4 | const obj = arr.reduce<{ [x: string]: any }>((acc, now) => {
5 | const [k, v] = now.split('=');
6 | acc[k] = decodeURIComponent(v);
7 | return acc;
8 | }, {});
9 | return obj;
10 | };
11 |
12 | export const checkBrowser = () => {
13 | const userAgent = navigator.userAgent; //取得浏览器的userAgent字符串
14 | if (userAgent.indexOf('Opera') > -1) {
15 | return 'Opera';
16 | } //判断是否Opera浏览器
17 | if (userAgent.indexOf('Firefox') > -1) {
18 | return 'FF';
19 | } //判断是否Firefox浏览器
20 | if (userAgent.indexOf('Chrome') > -1) {
21 | return 'Chrome';
22 | }
23 | if (userAgent.indexOf('Safari') > -1) {
24 | return 'Safari';
25 | } //判断是否Safari浏览器
26 | // if (userAgent.indexOf("compatible") > -1 && userAgent.indexOf("MSIE") > -1 && !isOpera) {
27 | // return "IE";
28 | // }; //判断是否IE浏览器
29 | };
30 |
31 | export const checkIsMobile = () => {
32 | if (
33 | navigator.userAgent.match(
34 | /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
35 | )
36 | ) {
37 | // alert('手机端')
38 | return true;
39 | } else {
40 | // alert('PC端')
41 | return false;
42 | }
43 | };
44 |
--------------------------------------------------------------------------------
/src/utils/storeUtils.ts:
--------------------------------------------------------------------------------
1 | import { IJuJi, IMovieItem } from '../types';
2 |
3 | let WATCH_HISTORY: Array | null = null;
4 | let SEARCH_HISTORY: Array | null = null;
5 |
6 | const WATCH_HISTORY_KEY = 'WATCH_HISTORY_KEY';
7 | const SEARCH_HISTORY_KEY = 'SEARCH_HISTORY_KEY';
8 |
9 | const initLocalStorage = () => {
10 | const wHistory = localStorage.getItem(WATCH_HISTORY_KEY) || '[]';
11 | const sHistory = localStorage.getItem(SEARCH_HISTORY_KEY) || '[]';
12 | WATCH_HISTORY = JSON.parse(wHistory);
13 | SEARCH_HISTORY = JSON.parse(sHistory);
14 | };
15 |
16 | const getWatchHistory = () => {
17 | return WATCH_HISTORY;
18 | };
19 |
20 | const getSearchHistory = () => {
21 | return SEARCH_HISTORY;
22 | };
23 |
24 | const addWatchHistory = (obj: IMovieItem & { watch_history?: string }, item: IJuJi) => {
25 | if (!WATCH_HISTORY) return;
26 |
27 | const oldRecord = WATCH_HISTORY.find((o) => obj.vod_id === o.vod_id);
28 | if (oldRecord && oldRecord.watch_history === item.text) return;
29 |
30 | if (!oldRecord) {
31 | obj.watch_history = item.text;
32 | WATCH_HISTORY.push(obj);
33 | if (WATCH_HISTORY.length > 30) {
34 | WATCH_HISTORY.shift();
35 | }
36 | localStorage.setItem(WATCH_HISTORY_KEY, JSON.stringify(WATCH_HISTORY));
37 | } else {
38 | oldRecord.watch_history = item.text;
39 | localStorage.setItem(WATCH_HISTORY_KEY, JSON.stringify(WATCH_HISTORY));
40 | }
41 | };
42 |
43 | const addSearchHistory = (obj: string) => {
44 | if (!SEARCH_HISTORY) return;
45 | if (SEARCH_HISTORY.includes(obj)) return;
46 | SEARCH_HISTORY.push(obj);
47 | if (SEARCH_HISTORY.length > 10) {
48 | SEARCH_HISTORY.shift();
49 | }
50 | localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(SEARCH_HISTORY));
51 | };
52 |
53 | initLocalStorage();
54 |
55 | export default {
56 | getWatchHistory,
57 | getSearchHistory,
58 | addSearchHistory,
59 | addWatchHistory,
60 | };
61 |
--------------------------------------------------------------------------------
/src/utils/theme.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mui/material/styles';
2 |
3 | const themeDark = createTheme({
4 | palette: {
5 | mode: 'dark',
6 | primary: {
7 | main: '#757575',
8 | },
9 | secondary: {
10 | main: '#757575',
11 | },
12 | },
13 | });
14 |
15 | const themeNormal = createTheme({
16 | palette: {
17 | primary: {
18 | main: '#009688',
19 | },
20 | secondary: {
21 | main: '#009688',
22 | },
23 | },
24 | });
25 |
26 | const ThemeArr: any = {
27 | dark: themeDark,
28 | light: themeNormal,
29 | };
30 |
31 | const getThemeLocal = (): string => {
32 | const themeLocal = localStorage.getItem('themelocal') || 'light';
33 | return ThemeArr[themeLocal];
34 | };
35 | const setThemeLocal = (type: string) => {
36 | localStorage.setItem('themelocal', type);
37 | };
38 |
39 | const defaultTheme = getThemeLocal();
40 |
41 | export default {
42 | defaultTheme,
43 | ThemeArr,
44 | setThemeLocal,
45 | };
46 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | /*
3 | * ts图片声明导入文件
4 | */
5 | // declare module '*.scss' {
6 | // const css: { [key: string]: string };
7 | // export default css;
8 | // }
9 | declare module '*.svg';
10 | declare module '*.png';
11 | declare module '*.jpg';
12 | declare module '*.jpeg';
13 | declare module '*.gif';
14 | declare module '*.bmp';
15 | declare module '*.tiff';
16 | declare interface Window {
17 | HlsJsPlayer: any;
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": false,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "suppressImplicitAnyIndexErrors":true
19 | },
20 | "include": ["./src","./src/vite-env.d.ts","./jest-setup.ts"]
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | build: {
8 | sourcemap: true,
9 | outDir: 'build',
10 | rollupOptions: {
11 | // external: ['react', 'react-dom'],
12 | // output: {
13 | // globals: {
14 | // react: 'React',
15 | // 'react-dom': 'ReactDOM',
16 | // },
17 | // },
18 | },
19 | },
20 | });
21 |
--------------------------------------------------------------------------------