Kaynağa Gözat

Cleanup video player and use consistently across recordings and events.

Jason Hunter 3 yıl önce
ebeveyn
işleme
b70c11e7a7

+ 3 - 12
web/package-lock.json

@@ -11947,15 +11947,6 @@
       "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz",
       "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA=="
     },
-    "videojs-mobile-ui": {
-      "version": "0.5.3",
-      "resolved": "https://registry.npmjs.org/videojs-mobile-ui/-/videojs-mobile-ui-0.5.3.tgz",
-      "integrity": "sha512-rY+JFLUq2edqoWB4CHVxPLYQEYhSNdGylGe44MEdfxzqYaEgkf/qyDlmmpdN9BFIQ6vJ7eaQBxgTOHha8UpOGA==",
-      "requires": {
-        "global": "^4.3.2",
-        "video.js": "^5.19.2 || ^6.6.0 || ^7.0.0"
-      }
-    },
     "videojs-playlist": {
       "version": "4.3.1",
       "resolved": "https://registry.npmjs.org/videojs-playlist/-/videojs-playlist-4.3.1.tgz",
@@ -11966,9 +11957,9 @@
       }
     },
     "videojs-seek-buttons": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/videojs-seek-buttons/-/videojs-seek-buttons-2.0.0.tgz",
-      "integrity": "sha512-fSq2COvwTT5OwD5urc3E+ktQRwdjptXNaeuv1Tld2yfoV1ep9Am9gE/O07ADgHJVedFatVUXnifTh6wlUWSyTA==",
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/videojs-seek-buttons/-/videojs-seek-buttons-2.0.1.tgz",
+      "integrity": "sha512-FIWIy0l1cy8zbJEcjBpL7m8t54219HNPRLfGcvs++CV3J7E6HbmF1bVwVMyh3Iev/Y95s0tnn0x5P6w/HTulfw==",
       "requires": {
         "global": "^4.4.0",
         "video.js": "^6 || ^7"

+ 1 - 2
web/package.json

@@ -18,9 +18,8 @@
     "preact-async-route": "^2.2.1",
     "preact-router": "^3.2.1",
     "video.js": "^7.11.8",
-    "videojs-mobile-ui": "^0.5.3",
     "videojs-playlist": "^4.3.1",
-    "videojs-seek-buttons": "^2.0.0"
+    "videojs-seek-buttons": "^2.0.1"
   },
   "devDependencies": {
     "@babel/eslint-parser": "^7.12.13",

+ 1 - 1
web/snowpack.config.js

@@ -4,7 +4,7 @@ module.exports = {
     src: { url: '/dist' },
   },
   plugins: ['@snowpack/plugin-postcss', '@prefresh/snowpack', 'snowpack-plugin-hash'],
-  routes: [{ match: 'routes', src: '.*', dest: '/index.html' }],
+  routes: [{ match: 'all', src: '(?!.*(.svg|.gif|.json|.jpg|.jpeg|.png|.js)).*', dest: '/index.html' }],
   optimize: {
     bundle: false,
     minify: true,

+ 77 - 41
web/src/components/VideoPlayer.jsx

@@ -1,6 +1,6 @@
 import { h, Component } from 'preact';
+import { useRef, useEffect } from 'preact/hooks';
 import videojs from 'video.js';
-import 'videojs-mobile-ui';
 import 'videojs-playlist';
 import 'videojs-seek-buttons';
 import 'video.js/dist/video-js.css';
@@ -11,49 +11,85 @@ const defaultOptions = {
   playbackRates: [0.5, 1, 2, 4, 8],
   fluid: true,
 };
+const defaultSeekOptions = {
+  forward: 30,
+  back: 10,
+};
 
-export default class VideoPlayer extends Component {
-  componentDidMount() {
-    const { options, onReady = () => {} } = this.props;
-    const videoJsOptions = {
-      ...defaultOptions,
-      ...options,
-    };
-    this.player = videojs(this.videoNode, videoJsOptions, function onPlayerReady() {
-      onReady(this);
-    });
-    this.player.seekButtons({
-      forward: 30,
-      back: 10,
+export default function VideoPlayer({ children, options, seekOptions = {}, onReady = () => {}, onDispose = () => {} }) {
+  const playerRef = useRef();
+
+  useEffect(() => {
+    const player = videojs(playerRef.current, { ...defaultOptions, ...options }, () => {
+      onReady(player);
     });
-    this.player.mobileUi({
-      fullscreen: {
-        iOS: true,
-      },
+    player.seekButtons({
+      ...defaultSeekOptions,
+      ...seekOptions,
     });
-  }
 
-  componentWillUnmount() {
-    const { onDispose = () => {} } = this.props;
-    if (this.player) {
-      this.player.dispose();
-      onDispose();
+    // Disable fullscreen on iOS if we have children
+    if (
+      children &&
+      videojs.browser.IS_IOS &&
+      videojs.browser.IOS_VERSION > 9 &&
+      !player.el_.ownerDocument.querySelector('.bc-iframe')
+    ) {
+      player.tech_.el_.setAttribute('playsinline', 'playsinline');
+      player.tech_.supportsFullScreen = function () {
+        return false;
+      };
+    }
+
+    const screen = window.screen;
+
+    const angle = () => {
+      // iOS
+      if (typeof window.orientation === 'number') {
+        return window.orientation;
+      }
+      // Android
+      if (screen && screen.orientation && screen.orientation.angle) {
+        return window.orientation;
+      }
+      videojs.log('angle unknown');
+      return 0;
+    };
+
+    const rotationHandler = () => {
+      const currentAngle = angle();
+
+      if (currentAngle === 90 || currentAngle === 270 || currentAngle === -90) {
+        if (player.paused() === false) {
+          player.requestFullscreen();
+        }
+      }
+
+      if ((currentAngle === 0 || currentAngle === 180) && player.isFullscreen()) {
+        player.exitFullscreen();
+      }
+    };
+
+    if (videojs.browser.IS_IOS) {
+      window.addEventListener('orientationchange', rotationHandler);
+    } else if (videojs.browser.IS_ANDROID && screen.orientation) {
+      // addEventListener('orientationchange') is not a user interaction on Android
+      screen.orientation.onchange = rotationHandler;
     }
-  }
-
-  // shouldComponentUpdate() {
-  //   return false;
-  // }
-
-  render() {
-    const { style, children } = this.props;
-    return (
-      <div style={style}>
-        <div data-vjs-player>
-          <video ref={(node) => (this.videoNode = node)} className="video-js vjs-default-skin" controls playsinline />
-          {children}
-        </div>
-      </div>
-    );
-  }
+
+    return () => {
+      if (videojs.browser.IS_IOS) {
+        window.removeEventListener('orientationchange', rotationHandler);
+      }
+      player.dispose();
+      onDispose();
+    };
+  }, []);
+
+  return (
+    <div data-vjs-player>
+      <video ref={playerRef} className="video-js vjs-default-skin" controls playsinline />
+      {children}
+    </div>
+  );
 }

+ 0 - 4
web/src/index.css

@@ -25,7 +25,3 @@
     transform: rotate(360deg);
   }
 }
-
-.video-js.vjs-has-started .vjs-touch-overlay {
-  display: none;
-}

+ 39 - 23
web/src/routes/Event.jsx

@@ -3,10 +3,13 @@ import { useCallback, useState } from 'preact/hooks';
 import { route } from 'preact-router';
 import ActivityIndicator from '../components/ActivityIndicator';
 import Button from '../components/Button';
-import Delete from '../icons/Delete'
+import Clip from '../icons/Clip';
+import Delete from '../icons/Delete';
+import Snapshot from '../icons/Snapshot';
 import Dialog from '../components/Dialog';
 import Heading from '../components/Heading';
 import Link from '../components/Link';
+import VideoPlayer from '../components/VideoPlayer';
 import { FetchStatus, useApiHost, useEvent } from '../api';
 import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
 
@@ -24,9 +27,7 @@ export default function Event({ eventId }) {
     setShowDialog(false);
   };
 
-
   const handleClickDeleteDialog = useCallback(async () => {
-
     let success;
     try {
       const response = await fetch(`${apiHost}/api/events/${eventId}`, { method: 'DELETE' });
@@ -40,12 +41,11 @@ export default function Event({ eventId }) {
       setDeleteStatus(FetchStatus.LOADED);
       setShowDialog(false);
       route('/events', true);
-
     }
   }, [apiHost, eventId, setShowDialog]);
 
   if (status !== FetchStatus.LOADED) {
-    return <ActivityIndicator />
+    return <ActivityIndicator />;
   }
 
   const startime = new Date(data.start_time * 1000);
@@ -106,28 +106,44 @@ export default function Event({ eventId }) {
 
       {data.has_clip ? (
         <Fragment>
-          <Heading size="sm">Clip</Heading>
-          <video
-            aria-label={`Clip for event ${data.id}`}
-            autoPlay
-            className="w-100"
-            src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`}
-            controls
+          <Heading size="lg">Clip</Heading>
+          <VideoPlayer
+            options={{
+              sources: [
+                {
+                  src: `${apiHost}/clips/${data.camera}-${eventId}.mp4`,
+                  type: 'video/mp4',
+                },
+              ],
+              poster: data.has_snapshot
+                ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
+                : `data:image/jpeg;base64,${data.thumbnail}`,
+            }}
+            seekOptions={{ forward: 10, back: 5 }}
+            onReady={(player) => {}}
           />
+          <div className="text-center">
+            <Button className="mx-2" color="blue" href={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} download>
+              <Clip className="w-6" /> Download Clip
+            </Button>
+            <Button className="mx-2" color="blue" href={`${apiHost}/clips/${data.camera}-${eventId}.jpg`} download>
+              <Snapshot className="w-6" /> Download Snapshot
+            </Button>
+          </div>
         </Fragment>
       ) : (
-        <p>No clip available</p>
+        <Fragment>
+          <Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
+          <img
+            src={
+              data.has_snapshot
+                ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
+                : `data:image/jpeg;base64,${data.thumbnail}`
+            }
+            alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
+          />
+        </Fragment>
       )}
-
-      <Heading size="sm">{data.has_snapshot ? 'Best image' : 'Thumbnail'}</Heading>
-      <img
-        src={
-          data.has_snapshot
-            ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
-            : `data:image/jpeg;base64,${data.thumbnail}`
-        }
-        alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
-      />
     </div>
   );
 }