Browse Source

feat(分屏展示): 新增ScreenPlayer组件

xieyonghong 3 years ago
parent
commit
f2b1e7064b

+ 6 - 0
config/config.ts

@@ -78,6 +78,12 @@ export default defineConfig({
   nodeModulesTransform: { type: 'none' },
   nodeModulesTransform: { type: 'none' },
   // mfsu: {},
   // mfsu: {},
   webpack5: {},
   webpack5: {},
+  copy: [
+    { from: 'node_modules/@liveqing/liveplayer/dist/element/liveplayer.swf', to: '/' },
+    { from: 'node_modules/@liveqing/liveplayer/dist/element/crossdomain.xml', to: '/' },
+    { from: 'node_modules/@liveqing/liveplayer/dist/element/liveplayer-element.min.js', to: '/' },
+  ],
+  headScripts: [{ src: './liveplayer-element.min.js', defer: true }],
   // exportStatic: {},
   // exportStatic: {},
   chainWebpack(memo, { env, webpack, createCSSRule }) {
   chainWebpack(memo, { env, webpack, createCSSRule }) {
     memo.plugin('monaco-editor').use(
     memo.plugin('monaco-editor').use(

+ 1 - 0
package.json

@@ -71,6 +71,7 @@
     "@formily/shared": "2.0.0-rc.17",
     "@formily/shared": "2.0.0-rc.17",
     "@jetlinks/pro-list": "^1.10.8",
     "@jetlinks/pro-list": "^1.10.8",
     "@jetlinks/pro-table": "^2.63.11",
     "@jetlinks/pro-table": "^2.63.11",
+    "@liveqing/liveplayer": "^2.6.4",
     "@types/react-syntax-highlighter": "^13.5.2",
     "@types/react-syntax-highlighter": "^13.5.2",
     "@umijs/route-utils": "^1.0.36",
     "@umijs/route-utils": "^1.0.36",
     "ahooks": "^2.10.9",
     "ahooks": "^2.10.9",

+ 142 - 0
src/components/Player/ScreenPlayer.tsx

@@ -0,0 +1,142 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import classNames from 'classnames';
+import LivePlayer from './index';
+import { Radio } from 'antd';
+import { useFullscreen } from 'ahooks';
+import './index.less';
+
+type Player = {
+  id?: string;
+  url?: string;
+};
+
+interface ScreenProps {
+  url?: string;
+  id?: string;
+  /**
+   *
+   * @param id 当时播发视频ID
+   * @param type 鼠标按下状态,true 为按下, false 为松开
+   */
+  onLeft?: (id: string, type: boolean) => void;
+  /**
+   *
+   * @param id 当时播发视频ID
+   * @param type 鼠标按下状态,true 为按下, false 为松开
+   */
+  onUp?: (id: string, type: boolean) => void;
+  /**
+   *
+   * @param id 当时播发视频ID
+   * @param type 鼠标按下状态,true 为按下, false 为松开
+   */
+  onRight?: (id: string, type: boolean) => void;
+  /**
+   *
+   * @param id 当时播发视频ID
+   * @param type 鼠标按下状态,true 为按下, false 为松开
+   */
+  onDown?: (id: string, type: boolean) => void;
+  /**
+   *
+   * @param id 当时播发视频ID
+   * @param type 鼠标按下状态,true 为按下, false 为松开
+   */
+  onZoomIn?: (id: string, type: boolean) => void;
+  /**
+   *
+   * @param id 当时播发视频ID
+   * @param type 鼠标按下状态,true 为按下, false 为松开
+   */
+  onZoomOut?: (id: string, type: boolean) => void;
+}
+
+export default (props: ScreenProps) => {
+  const [screen, setScreen] = useState(1);
+  const [players, setPlayers] = useState<Player[]>([]);
+  const [playerActive, setPlayerActive] = useState(0);
+  const fullscreenRef = useRef(null);
+  const [isFullscreen, { setFull }] = useFullscreen(fullscreenRef);
+
+  const replaceVideo = useCallback(
+    (id: string, url: string) => {
+      const videoIndex = players.findIndex((video) => video.url === url);
+      if (videoIndex === -1) {
+        players[playerActive] = { id, url };
+        setPlayers(players);
+
+        if (playerActive === screen) {
+          // 当前位置为分屏最后一位
+          setPlayerActive(0);
+        } else {
+          setPlayerActive(playerActive + 1);
+        }
+      }
+    },
+    [players, playerActive, screen],
+  );
+
+  useEffect(() => {
+    const arr = new Array(screen).fill(1).map(() => ({ id: '', url: '' }));
+    setPlayers(arr);
+    setPlayerActive(0);
+  }, [screen]);
+
+  useEffect(() => {
+    // 查看当前 播放视频位置,如果当前视频位置有视频在播放,则替换
+    if (props.url && props.id) {
+      replaceVideo(props.id, props.url);
+    }
+  }, [props.url]);
+
+  const screenClass = `screen-${screen}`;
+
+  return (
+    <div className={'live-player-warp'}>
+      <div className={'live-player-content'}>
+        <div className={'player-screen-tool'}>
+          <Radio.Group
+            options={[
+              { label: '单屏', value: 1 },
+              { label: '四分屏', value: 4 },
+              { label: '九分屏', value: 9 },
+              { label: '全屏', value: 0 },
+            ]}
+            value={screen}
+            onChange={(e) => {
+              if (e.target.value) {
+                setScreen(e.target.value);
+              } else {
+                // 全屏操作
+                setFull();
+              }
+            }}
+            optionType={'button'}
+            buttonStyle={'solid'}
+          />
+        </div>
+        <div className={'player-body'}>
+          <div className={classNames('player-screen', screenClass)} ref={fullscreenRef}>
+            {players.map((player, index) => {
+              return (
+                <div
+                  key={`player_body_${index}`}
+                  className={classNames({
+                    active: playerActive === index && !isFullscreen,
+                    'full-screen': isFullscreen,
+                  })}
+                  onClick={() => {
+                    setPlayerActive(index);
+                  }}
+                >
+                  <LivePlayer key={player.id || `player_${index}`} url={player.url || ''} />
+                </div>
+              );
+            })}
+          </div>
+        </div>
+      </div>
+      <div className={'live-player-tools'}></div>
+    </div>
+  );
+};

+ 64 - 0
src/components/Player/index.less

@@ -0,0 +1,64 @@
+@padding: 20px;
+
+.live-player-warp {
+  display: flex;
+
+  .live-player-content {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+
+    .player-screen-tool {
+      display: flex;
+      justify-content: center;
+      margin-bottom: 20px;
+
+      .ant-radio-button-wrapper {
+        height: auto;
+        padding: 4px 20px;
+      }
+    }
+
+    .player-body {
+      flex: 1;
+
+      .player-screen {
+        position: relative;
+        display: grid;
+        box-sizing: border-box;
+
+        &.screen-1 {
+          grid-template-columns: 1fr;
+        }
+
+        &.screen-4 {
+          grid-template-rows: 1fr 1fr;
+          grid-template-columns: 1fr 1fr;
+        }
+
+        &.screen-9 {
+          grid-template-rows: 1fr 1fr 1fr;
+          grid-template-columns: 1fr 1fr 1fr;
+        }
+
+        &.screen-4,
+        &.screen-9 {
+          grid-gap: 12px;
+        }
+
+        .active {
+          border: 2px solid red;
+        }
+
+        .full-screen {
+          border: 1px solid #fff;
+        }
+      }
+    }
+  }
+
+  .live-player-tools {
+    flex-basis: 290px;
+    padding: 50px 12px 0 12px;
+  }
+}

+ 109 - 0
src/components/Player/index.tsx

@@ -0,0 +1,109 @@
+import { useEffect, useRef } from 'react';
+import { isFunction } from 'lodash';
+
+export type PlayerProps = {
+  url?: string;
+  live?: boolean;
+  autoplay?: boolean;
+  muted?: boolean;
+  poster?: string;
+  timeout?: number;
+  onDestroy?: () => void;
+  onMessage?: (msg: any) => void;
+  onError?: (err: any) => void;
+  onTimeUpdate?: (time: any) => void;
+  onPause?: () => void;
+  onPlay?: () => void;
+  onFullscreen?: () => void;
+  onSnapOutside?: (base64: any) => void;
+  onSnapInside?: (base64: any) => void;
+  onCustomButtons?: (name: any) => void;
+};
+
+export default (props: PlayerProps) => {
+  const player = useRef<HTMLVideoElement>(null);
+
+  useEffect(() => {
+    return () => {
+      // 销毁播放器
+      if ('onDestroy' in props && isFunction(props.onDestroy)) {
+        props.onDestroy();
+      }
+    };
+  }, []);
+
+  /**
+   * 事件初始化
+   */
+  const EventInit = () => {
+    player.current?.addEventListener('fullscreen', () => {
+      if (props.onFullscreen) {
+        props.onFullscreen();
+      }
+    });
+
+    player.current?.addEventListener('message', (e) => {
+      if (props.onMessage) {
+        props.onMessage(e);
+      }
+    });
+
+    player.current?.addEventListener('error', (e) => {
+      if (props.onError) {
+        props.onError(e);
+      }
+    });
+
+    player.current?.addEventListener('timeupdate', (e) => {
+      if (props.onTimeUpdate) {
+        props.onTimeUpdate(e);
+      }
+    });
+
+    player.current?.addEventListener('pause', () => {
+      if (props.onPause) {
+        props.onPause();
+      }
+    });
+
+    player.current?.addEventListener('play', () => {
+      if (props.onPlay) {
+        props.onPlay();
+      }
+    });
+
+    player.current?.addEventListener('snapOutside', (e) => {
+      if (props.onSnapOutside) {
+        props.onSnapOutside(e);
+      }
+    });
+
+    player.current?.addEventListener('snapInside', (e) => {
+      if (props.onSnapInside) {
+        props.onSnapInside(e);
+      }
+    });
+
+    player.current?.addEventListener('customButtons', (e) => {
+      if (props.onCustomButtons) {
+        props.onCustomButtons(e);
+      }
+    });
+  };
+
+  return (
+    <live-player
+      ref={(r) => {
+        player.current = r;
+        EventInit();
+      }}
+      live={'live' in props ? props.live !== false : true}
+      autoplay={'autoplay' in props ? props.autoplay !== false : true}
+      muted={'muted' in props ? props.muted !== false : true}
+      hide-big-play-button={true}
+      poster={props.poster || ''}
+      timeout={props.timeout || 20}
+      video-url={props.url || ''}
+    />
+  );
+};

+ 2 - 0
src/components/index.ts

@@ -2,3 +2,5 @@ export { default as RadioCard } from './RadioCard';
 export { default as UploadImage } from './Upload/Image';
 export { default as UploadImage } from './Upload/Image';
 export { default as ProTableCard } from './ProTableCard';
 export { default as ProTableCard } from './ProTableCard';
 export { default as BadgeStatus } from './BadgeStatus';
 export { default as BadgeStatus } from './BadgeStatus';
+export { default as Player } from './Player';
+export { default as ScreenPlayer } from './Player/ScreenPlayer';

+ 63 - 0
src/pages/media/SplitScreen/index.less

@@ -0,0 +1,63 @@
+@padding: 20px;
+
+.split-screen {
+  display: flex;
+
+  .left-content {
+    width: 300px;
+    padding-right: @padding;
+    border-right: 1px solid #f0f0f0;
+  }
+
+  .right-content {
+    display: flex;
+    flex-direction: column;
+    flex-grow: 1;
+    padding-left: @padding;
+
+    .top {
+      display: flex;
+      flex-basis: 30px;
+      justify-content: center;
+      padding-bottom: 12px;
+    }
+
+    .live-player {
+      display: flex;
+      flex: 1;
+
+      .live-player-content {
+        position: relative;
+        flex-grow: 1;
+
+        .player-screen {
+          display: grid;
+
+          &.screen-1 {
+            grid-template-columns: 1fr;
+          }
+
+          &.screen-4 {
+            grid-template-rows: 1fr 1fr;
+            grid-template-columns: 1fr 1fr;
+          }
+
+          &.screen-9 {
+            grid-template-rows: 1fr 1fr 1fr;
+            grid-template-columns: 1fr 1fr 1fr;
+          }
+
+          &.screen-4,
+          &.screen-9 {
+            grid-gap: 12px;
+          }
+        }
+      }
+
+      .player-tools {
+        flex-basis: 290px;
+        padding: 50px 12px 0 12px;
+      }
+    }
+  }
+}

+ 22 - 0
src/pages/media/SplitScreen/index.tsx

@@ -0,0 +1,22 @@
+import { PageContainer } from '@ant-design/pro-layout';
+import { Card } from 'antd';
+import LeftTree from './tree';
+import './index.less';
+import { ScreenPlayer } from '@/components';
+
+const SplitScreen = () => {
+  return (
+    <PageContainer>
+      <Card>
+        <div className="split-screen">
+          <LeftTree />
+          <div className="right-content">
+            <ScreenPlayer />
+          </div>
+          ;
+        </div>
+      </Card>
+    </PageContainer>
+  );
+};
+export default SplitScreen;

+ 12 - 0
src/pages/media/SplitScreen/tree.tsx

@@ -0,0 +1,12 @@
+import { Input, Tree } from 'antd';
+
+const LeftTree = () => {
+  return (
+    <div className="left-content">
+      <Input.Search />
+      <Tree />
+    </div>
+  );
+};
+
+export default LeftTree;

+ 3 - 0
src/pages/system/Menu/Detail/buttons.tsx

@@ -265,6 +265,9 @@ export default (props: ButtonsProps) => {
         onOk={() => {
         onOk={() => {
           if (!disabled) {
           if (!disabled) {
             saveData();
             saveData();
+          } else {
+            resetForm();
+            setVisible(false);
           }
           }
         }}
         }}
         onCancel={() => {
         onCancel={() => {

+ 59 - 6
src/utils/menu/index.ts

@@ -8,6 +8,16 @@ export const MENUS_DATA_CACHE = 'MENUS_DATA_CACHE';
 
 
 const DetailCode = 'detail';
 const DetailCode = 'detail';
 
 
+// 额外子级路由
+const extraRouteObj = {
+  notice: {
+    children: [
+      { code: 'Config', name: '通知配置' },
+      { code: 'Template', name: '通知模板' },
+    ],
+  },
+};
+
 /**
 /**
  * 根据url获取映射的组件
  * 根据url获取映射的组件
  * @param files
  * @param files
@@ -36,8 +46,9 @@ export const flatRoute = (routes: MenuItem[]): MenuItem[] => {
 };
 };
 
 
 /**
 /**
- * 获取菜单组件
+ * 获取菜单详情组件
  * @param baseCode
  * @param baseCode
+ * @url 菜单url
  */
  */
 const findDetailRoute = (baseCode: string, url: string): MenuItem | undefined => {
 const findDetailRoute = (baseCode: string, url: string): MenuItem | undefined => {
   if (baseCode) {
   if (baseCode) {
@@ -46,24 +57,66 @@ const findDetailRoute = (baseCode: string, url: string): MenuItem | undefined =>
     const path = `${url}/${DetailCode}/:id`;
     const path = `${url}/${DetailCode}/:id`;
     const component = allComponents[code];
     const component = allComponents[code];
     return component
     return component
-      ? ({ path: path, url: path, name: getDetailNameByCode[code], code } as MenuItem)
+      ? ({ path: path, url: path, name: getDetailNameByCode[code], hideInMenu: true, code } as
+          | MenuItem
+          | any)
       : undefined;
       : undefined;
   }
   }
   return undefined;
   return undefined;
 };
 };
 
 
+const findExtraRoutes = (baseCode: string, children: any[], url: string) => {
+  const allComponents = findComponents(require.context('@/pages', true, /index(\.tsx)$/));
+  return children.map((route) => {
+    const code = `${baseCode}/${route.code}`;
+    const path = `${url}/${route.code}`;
+    const component = allComponents[code];
+
+    const _route: any = {
+      path: path,
+      url: path,
+      name: route.name,
+      hideInMenu: true,
+      code: code,
+    };
+
+    if (route.children && route.children.length) {
+      _route.children = findExtraRoutes(code, route.children, path);
+    }
+    return component ? _route : undefined;
+  });
+};
+
+/**
+ * 处理实际路由情况,会包含不显示的子级路由,比如详情
+ * @param routes
+ * @param level
+ */
 export const handleRoutes = (routes?: MenuItem[], level = 1): MenuItem[] => {
 export const handleRoutes = (routes?: MenuItem[], level = 1): MenuItem[] => {
   return routes
   return routes
     ? routes.map((item) => {
     ? routes.map((item) => {
-        const detailComponent = findDetailRoute(item.code, item.url);
-        if (detailComponent) {
-          item.children = item.children ? [detailComponent, ...item.children] : [detailComponent];
+        // 判断当前是否有额外子路由
+        const extraRoutes = extraRouteObj[item.code];
+
+        if (extraRoutes) {
+          if (extraRoutes) {
+            const eRoutes = findExtraRoutes(item.code, extraRoutes.children, item.url);
+            item.children = item.children ? [...eRoutes, ...item.children] : eRoutes;
+          }
+        } else {
+          const detailComponent = findDetailRoute(item.code, item.url);
+          if (detailComponent) {
+            item.children = item.children ? [detailComponent, ...item.children] : [detailComponent];
+          }
         }
         }
+
         // eslint-disable-next-line @typescript-eslint/no-unused-vars
         // eslint-disable-next-line @typescript-eslint/no-unused-vars
         if (item.children) {
         if (item.children) {
           item.children = handleRoutes(item.children, level + 1);
           item.children = handleRoutes(item.children, level + 1);
         }
         }
         item.level = level;
         item.level = level;
+
+        console.log(item.code, item);
         return item;
         return item;
       })
       })
     : [];
     : [];
@@ -118,7 +171,7 @@ export const getMenus = (extraRoutes: IRouteProps[]): any[] => {
       name: route.name,
       name: route.name,
       path: route.url,
       path: route.url,
       icon: route.icon,
       icon: route.icon,
-      hideChildrenInMenu: children && children.some((item: any) => item.url.includes(DetailCode)),
+      hideInMenu: !!route.hideInMenu,
       exact: route.level !== 1,
       exact: route.level !== 1,
       children: getMenus(children),
       children: getMenus(children),
     };
     };

+ 5 - 1
src/utils/menu/router.ts

@@ -47,9 +47,11 @@ export const MENUS_CODE = {
   'media/Reveal': 'media/Reveal',
   'media/Reveal': 'media/Reveal',
 
 
   'notice/Type': 'notice/Type',
   'notice/Type': 'notice/Type',
+  'media/SplitScreen': 'media/SplitScreen',
   'notice/Config': 'notice/Config',
   'notice/Config': 'notice/Config',
+  'notice/Config/Detail': 'notice/Config/Detail',
   'notice/Template': 'notice/Template',
   'notice/Template': 'notice/Template',
-
+  'notice/Template/Detail': 'notice/Template/Detail',
   'rule-engine/Instance': 'rule-engine/Instance',
   'rule-engine/Instance': 'rule-engine/Instance',
   'rule-engine/SQLRule': 'rule-engine/SQLRule',
   'rule-engine/SQLRule': 'rule-engine/SQLRule',
   'rule-engine/Scene': 'rule-engine/Scene',
   'rule-engine/Scene': 'rule-engine/Scene',
@@ -106,4 +108,6 @@ export const getDetailNameByCode = {
   'system/Role/Detail': '权限配置',
   'system/Role/Detail': '权限配置',
   'link/Type/Detail': '网络组件详情',
   'link/Type/Detail': '网络组件详情',
   'link/AccessConfig/Detail': '配置详情',
   'link/AccessConfig/Detail': '配置详情',
+  'notice/Config/Detail': '配置详情',
+  'notice/Template/Detail': '模板详情',
 };
 };

+ 5 - 0
yarn.lock

@@ -3058,6 +3058,11 @@
   dependencies:
   dependencies:
     stream "^0.0.2"
     stream "^0.0.2"
 
 
+"@liveqing/liveplayer@^2.6.4":
+  version "2.6.4"
+  resolved "https://registry.npmmirror.com/@liveqing/liveplayer/-/liveplayer-2.6.4.tgz#740658abd17417829e8dddecf8710dfe74e8edf7"
+  integrity sha512-HONU7dbLIf/NdcfNIVoY+x8olFXkotL+sULHJZDalnc7bCmH0FHmXtVOBca2KpUGHGy2zDWnAQ74L7UCPUw0jQ==
+
 "@mapbox/geojson-area@0.2.2":
 "@mapbox/geojson-area@0.2.2":
   version "0.2.2"
   version "0.2.2"
   resolved "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz#18d7814aa36bf23fbbcc379f8e26a22927debf10"
   resolved "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz#18d7814aa36bf23fbbcc379f8e26a22927debf10"