-
diff --git a/web/src/collection/components/VideoDetailsPage/VideoInformation.js b/web/src/collection/components/VideoDetailsPage/VideoInformation.js
new file mode 100644
index 00000000..69d3977a
--- /dev/null
+++ b/web/src/collection/components/VideoDetailsPage/VideoInformation.js
@@ -0,0 +1,95 @@
+import React, { useState } from "react";
+import clsx from "clsx";
+import PropTypes from "prop-types";
+import { makeStyles } from "@material-ui/styles";
+import { FileType } from "../FileBrowserPage/FileType";
+import FileInfoPanel from "./FileInfoPanel";
+import ObjectsPanel from "./ObjectsPanel";
+import ExifPanel from "./ExifPanel";
+import { useIntl } from "react-intl";
+import {
+ SelectableTab,
+ SelectableTabs,
+} from "../../../common/components/SelectableTabs";
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "stretch",
+ },
+ tabs: {
+ maxWidth: 400,
+ margin: theme.spacing(3),
+ },
+ data: {},
+}));
+
+/**
+ * Tabs enum for ideomatic access
+ */
+const Tab = {
+ info: "info",
+ objects: "objects",
+ exif: "exif",
+};
+
+/**
+ * Select data-presentation panel
+ */
+function dataComponent(tab) {
+ switch (tab) {
+ case Tab.info:
+ return FileInfoPanel;
+ case Tab.objects:
+ return ObjectsPanel;
+ case Tab.exif:
+ return ExifPanel;
+ default:
+ console.error(`Unknown tab: ${tab}`);
+ return "div";
+ }
+}
+
+function useMessages() {
+ const intl = useIntl();
+ return {
+ info: intl.formatMessage({ id: "file.tabInfo" }),
+ objects: intl.formatMessage({ id: "file.tabObjects" }),
+ exif: intl.formatMessage({ id: "file.tabExif" }),
+ };
+}
+
+function VideoInformation(props) {
+ const { file, onJump, className, ...other } = props;
+ const classes = useStyles();
+ const messages = useMessages();
+ const [tab, setTab] = useState(Tab.info);
+
+ const DataPanel = dataComponent(tab);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+VideoInformation.propTypes = {
+ /**
+ * Video file
+ */
+ file: FileType.isRequired,
+ /**
+ * Jump to a particular object
+ */
+ onJump: PropTypes.func,
+ className: PropTypes.string,
+};
+
+export default VideoInformation;
diff --git a/web/src/collection/components/VideoDetailsPage/VideoInformationPane.js b/web/src/collection/components/VideoDetailsPage/VideoInformationPane.js
index 68baea80..db212000 100644
--- a/web/src/collection/components/VideoDetailsPage/VideoInformationPane.js
+++ b/web/src/collection/components/VideoDetailsPage/VideoInformationPane.js
@@ -1,64 +1,21 @@
-import React, { useState } from "react";
+import React from "react";
import clsx from "clsx";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/styles";
import { FileType } from "../FileBrowserPage/FileType";
import Paper from "@material-ui/core/Paper";
-import {
- SelectableTab,
- SelectableTabs,
-} from "../../../common/components/SelectableTabs";
-import ExifPanel from "./ExifPanel";
-import ObjectsPanel from "./ObjectsPanel";
-import FileInfoPanel from "./FileInfoPanel";
import { useIntl } from "react-intl";
+import VideoInformation from "./VideoInformation";
-const useStyles = makeStyles((theme) => ({
+const useStyles = makeStyles({
root: {
boxShadow: "0 12px 18px 0 rgba(0,0,0,0.08)",
- display: "flex",
- flexDirection: "column",
- alignItems: "stretch",
},
- tabs: {
- maxWidth: 400,
- margin: theme.spacing(3),
- },
- data: {},
-}));
-
-/**
- * Tabs enum for ideomatic access
- */
-const Tab = {
- info: "info",
- objects: "objects",
- exif: "exif",
-};
-
-/**
- * Select data-presentation panel
- */
-function dataComponent(tab) {
- switch (tab) {
- case Tab.info:
- return FileInfoPanel;
- case Tab.objects:
- return ObjectsPanel;
- case Tab.exif:
- return ExifPanel;
- default:
- console.error(`Unknown tab: ${tab}`);
- return "div";
- }
-}
+});
function useMessages() {
const intl = useIntl();
return {
- info: intl.formatMessage({ id: "file.tabInfo" }),
- objects: intl.formatMessage({ id: "file.tabObjects" }),
- exif: intl.formatMessage({ id: "file.tabExif" }),
ariaLabel: intl.formatMessage({ id: "aria.label.fileAttributesRegion" }),
};
}
@@ -67,9 +24,6 @@ function VideoInformationPane(props) {
const { file, onJump, className } = props;
const classes = useStyles();
const messages = useMessages();
- const [tab, setTab] = useState(Tab.info);
-
- const DataPanel = dataComponent(tab);
return (
-
-
-
-
-
-
+
);
}
diff --git a/web/src/collection/components/VideoDetailsPage/VideoPlayer.js b/web/src/collection/components/VideoDetailsPage/VideoPlayer.js
index a47c3b9d..318f7e8b 100644
--- a/web/src/collection/components/VideoDetailsPage/VideoPlayer.js
+++ b/web/src/collection/components/VideoDetailsPage/VideoPlayer.js
@@ -107,6 +107,14 @@ const VideoPlayer = function VideoPlayer(props) {
const controller = useMemo(() => new VideoController(player, setWatch), []);
const previewActions = useMemo(() => makePreviewActions(handleWatch), []);
+ // Reset player on file change
+ useEffect(() => {
+ setWatch(false);
+ setPlayer(null);
+ setError(null);
+ controller._setPlayer(null);
+ }, [file]);
+
// Make sure flv.js is available
useEffect(() => setupBundledFlvJs({ suppressLogs: suppressErrors }), []);
diff --git a/web/src/collection/components/VideoDetailsPage/VideoPlayerPane.js b/web/src/collection/components/VideoDetailsPage/VideoPlayerPane.js
index ca047e33..4a786be2 100644
--- a/web/src/collection/components/VideoDetailsPage/VideoPlayerPane.js
+++ b/web/src/collection/components/VideoDetailsPage/VideoPlayerPane.js
@@ -9,6 +9,8 @@ import SceneSelector from "./SceneSelector";
import ObjectTimeLine from "./ObjectTimeLine";
import { seekTo } from "./seekTo";
import { useIntl } from "react-intl";
+import CollapseButton from "../../../common/components/CollapseButton";
+import Collapse from "@material-ui/core/Collapse";
const useStyles = makeStyles((theme) => ({
root: {
@@ -17,14 +19,27 @@ const useStyles = makeStyles((theme) => ({
flexDirection: "column",
alignItems: "stretch",
},
+ header: {
+ padding: theme.spacing(2),
+ display: "flex",
+ alignItems: "center",
+ },
title: {
...theme.mixins.title3,
fontWeight: "bold",
+ flexGrow: 1,
+ },
+ collapseButton: {
+ flexGrow: 0,
+ },
+ playerArea: {
padding: theme.spacing(2),
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "stretch",
},
player: {
height: 300,
- margin: theme.spacing(2),
},
objects: {
margin: theme.spacing(2),
@@ -53,13 +68,17 @@ function useMessages() {
}
function VideoPlayerPane(props) {
- const { file, onPlayerReady, className } = props;
+ const { file, onPlayerReady, collapsible = false, className } = props;
const classes = useStyles();
const messages = useMessages();
const [player, setPlayer] = useState(null);
const [progress, setProgress] = useState({ played: 0 });
+ const [collapsed, setCollapsed] = useState(false);
const handleJump = useCallback(seekTo(player, file), [player, file]);
+ const handleCollapse = useCallback(() => setCollapsed(!collapsed), [
+ collapsed,
+ ]);
return (
- {messages.video}
-
-
+
+
{messages.video}
+ {collapsible && (
+
+ )}
+
+
+
+
+
+
+
);
@@ -100,6 +133,10 @@ VideoPlayerPane.propTypes = {
* Return video-player controller
*/
onPlayerReady: PropTypes.func,
+ /**
+ * Enable or disable pane collapse feature.
+ */
+ collapsible: PropTypes.bool,
className: PropTypes.string,
};
diff --git a/web/src/collection/hooks/useDirectMatches.js b/web/src/collection/hooks/useDirectMatches.js
new file mode 100644
index 00000000..20f2f37d
--- /dev/null
+++ b/web/src/collection/hooks/useDirectMatches.js
@@ -0,0 +1,49 @@
+import useMatches from "./useMatches";
+
+/**
+ * Load direct matches of the given file.
+ * @param id mother file id.
+ */
+export function useDirectMatches(id) {
+ const { matches, files, error, loadMatches, hasMore, total } = useMatches(
+ id,
+ {
+ hops: 1,
+ }
+ );
+
+ const motherFile = files[id];
+
+ const seen = new Set();
+ const directMatches = [];
+ for (let match of matches) {
+ if (match.source === id && !seen.has(match.target)) {
+ seen.add(match.target);
+ directMatches.push({
+ id: match.id,
+ file: files[match.target],
+ distance: match.distance,
+ });
+ } else if (match.target === id && !seen.has(match.source)) {
+ seen.add(match.source);
+ directMatches.push({
+ id: match.id,
+ file: files[match.source],
+ distance: match.distance,
+ });
+ }
+ }
+
+ const progress = total == null ? undefined : matches.length / total;
+
+ return {
+ file: motherFile,
+ matches: directMatches,
+ error,
+ loadMatches,
+ hasMore,
+ progress,
+ };
+}
+
+export default useDirectMatches;
diff --git a/web/src/collection/hooks/useMatches.js b/web/src/collection/hooks/useMatches.js
new file mode 100644
index 00000000..eb4ab080
--- /dev/null
+++ b/web/src/collection/hooks/useMatches.js
@@ -0,0 +1,70 @@
+import lodash from "lodash";
+import { useCallback, useEffect, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { selectFileMatches } from "../state/selectors";
+import { fetchFileMatches, updateFileMatchFilters } from "../state/actions";
+
+/**
+ * Fetch all file matches satisfying filter criteria.
+ * @param id file id
+ * @param filters match loading filters
+ */
+export function useMatches(id, filters = { hops: 1 }) {
+ const state = useSelector(selectFileMatches);
+ const dispatch = useDispatch();
+ const [mergedFilters, setMergedFilters] = useState({
+ ...state.filters,
+ ...filters,
+ });
+
+ // Update merged filters.
+ // Do not update if the result is not changed.
+ useEffect(() => {
+ const newFilters = { ...state.filters, ...filters };
+ if (!lodash.isEqual(mergedFilters, newFilters)) {
+ setMergedFilters(newFilters);
+ }
+ }, [filters, state.filters]);
+
+ // Start/Resume loading handler.
+ const loadMatches = useCallback(() => {
+ if (state.fileId !== id || !lodash.isEqual(mergedFilters, state.filters)) {
+ dispatch(updateFileMatchFilters(id, mergedFilters));
+ } else {
+ dispatch(fetchFileMatches());
+ }
+ }, [id, mergedFilters, state.filters]);
+
+ /**
+ * Initiate fetching.
+ */
+ useEffect(() => {
+ dispatch(updateFileMatchFilters(id, mergedFilters));
+ }, [id, mergedFilters]);
+
+ /**
+ * Fetch next page if available.
+ */
+ useEffect(() => {
+ if (state.loading || state.error || state.matches.length >= state.total) {
+ return;
+ }
+ dispatch(fetchFileMatches());
+ }, [state]);
+
+ const hasMore =
+ state.total === undefined ||
+ state.matches.length < state.total ||
+ state.fileId !== id;
+
+ return {
+ matches: state.matches,
+ files: state.files,
+ total: state.total,
+ error: state.error,
+ loadMatches,
+ hasMore,
+ };
+}
+
+export default useMatches;
diff --git a/web/src/common/components/CollapseButton/CollapseButton.js b/web/src/common/components/CollapseButton/CollapseButton.js
new file mode 100644
index 00000000..e7732481
--- /dev/null
+++ b/web/src/common/components/CollapseButton/CollapseButton.js
@@ -0,0 +1,31 @@
+import React from "react";
+import clsx from "clsx";
+import PropTypes from "prop-types";
+import { IconButton } from "@material-ui/core";
+import ExpandLessOutlinedIcon from "@material-ui/icons/ExpandLessOutlined";
+import ExpandMoreOutlinedIcon from "@material-ui/icons/ExpandMoreOutlined";
+
+function CollapseButton(props) {
+ const { collapsed, onClick, className, ...other } = props;
+ const Icon = collapsed ? ExpandMoreOutlinedIcon : ExpandLessOutlinedIcon;
+
+ return (
+
+
+
+ );
+}
+
+CollapseButton.propTypes = {
+ /**
+ * Button state (collapsed or not).
+ */
+ collapsed: PropTypes.bool,
+ /**
+ * Mouse click handler.
+ */
+ onClick: PropTypes.func,
+ className: PropTypes.string,
+};
+
+export default CollapseButton;
diff --git a/web/src/common/components/CollapseButton/index.js b/web/src/common/components/CollapseButton/index.js
new file mode 100644
index 00000000..030a2096
--- /dev/null
+++ b/web/src/common/components/CollapseButton/index.js
@@ -0,0 +1 @@
+export { default } from "./CollapseButton";
diff --git a/web/src/collection/components/FileMatchesPage/MatchPreview/Distance.js b/web/src/common/components/Distance/Distance.js
similarity index 79%
rename from web/src/collection/components/FileMatchesPage/MatchPreview/Distance.js
rename to web/src/common/components/Distance/Distance.js
index 4a0f2f29..105ff995 100644
--- a/web/src/collection/components/FileMatchesPage/MatchPreview/Distance.js
+++ b/web/src/common/components/Distance/Distance.js
@@ -10,7 +10,7 @@ const useStyles = makeStyles((theme) => ({
display: "flex",
flexDirection: "column",
alignItems: "stretch",
- padding: theme.spacing(1),
+ padding: (props) => (props.dense ? 0 : theme.spacing(1)),
},
title: {
...theme.mixins.text,
@@ -19,11 +19,11 @@ const useStyles = makeStyles((theme) => ({
valueContainer: {
display: "flex",
alignItems: "center",
- marginTop: theme.spacing(1),
+ marginTop: (props) => (props.dense ? 0 : theme.spacing(1)),
},
indicator: {
flexGrow: 1,
- margin: theme.spacing(1),
+ margin: (props) => (props.dense ? theme.spacing(0.5) : theme.spacing(1)),
},
}));
@@ -62,11 +62,11 @@ function score(value) {
}
function Distance(props) {
- const { value, className } = props;
- const classes = useStyles();
+ const { value, dense = false, className, ...other } = props;
+ const classes = useStyles({ dense });
const messages = useMessages(value);
return (
-