diff --git a/web/src/collection/components/CollectionRootPage.js b/web/src/collection/components/CollectionRootPage.js index 358c639b..37945832 100644 --- a/web/src/collection/components/CollectionRootPage.js +++ b/web/src/collection/components/CollectionRootPage.js @@ -9,6 +9,7 @@ import FileBrowserPage from "./FileBrowserPage/FileBrowserPage"; import VideoDetailsPage from "./VideoDetailsPage/VideoDetailsPage"; import FileMatchesPage from "./FileMatchesPage/FileMatchesPage"; import FileClusterPage from "./FileClusterPage"; +import FileComparisonPage from "./FileComparisonPage"; const useStyles = makeStyles(() => ({ body: { @@ -46,6 +47,9 @@ function CollectionRootPage(props) { + + + diff --git a/web/src/collection/components/FileClusterPage/FileClusterPage.js b/web/src/collection/components/FileClusterPage/FileClusterPage.js index 6a61ad36..3be106e7 100644 --- a/web/src/collection/components/FileClusterPage/FileClusterPage.js +++ b/web/src/collection/components/FileClusterPage/FileClusterPage.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback } from "react"; import clsx from "clsx"; import PropTypes from "prop-types"; import { makeStyles } from "@material-ui/styles"; @@ -7,12 +7,10 @@ import FileSummaryHeader from "../FileSummaryHeader"; import { useParams } from "react-router-dom"; import useFile from "../../hooks/useFile"; import FileLoadingHeader from "../FileLoadingHeader"; -import { useDispatch, useSelector } from "react-redux"; -import { selectFileMatches } from "../../state/selectors"; -import { fetchFileMatches, updateFileMatchFilters } from "../../state/actions"; import MatchGraph from "../MatchGraph"; import { useIntl } from "react-intl"; -import Loading from "./Loading"; +import Loading from "../../../common/components/Loading"; +import useMatches from "../../hooks/useMatches"; const useStyles = makeStyles((theme) => ({ root: { @@ -50,39 +48,20 @@ function FileClusterPage(props) { const { id } = useParams(); const messages = useMessages(); const { file, error, loadFile } = useFile(id); - const matchesState = useSelector(selectFileMatches); - const matches = matchesState.matches; - const files = matchesState.files; - const dispatch = useDispatch(); - const hasMore = !(matches.length >= matchesState.total); - useEffect(() => { - dispatch(updateFileMatchFilters(id, { hops: 2 })); - }, [id]); - - useEffect(() => { - if ( - matchesState.loading || - matchesState.error || - matches.length >= matchesState.total - ) { - return; - } - dispatch(fetchFileMatches()); - }, [matchesState]); - - const handleRetry = useCallback(() => { - if (matchesState.total == null) { - dispatch(updateFileMatchFilters(id, { hops: 2 })); - } else { - dispatch(fetchFileMatches()); - } - }, [matchesState]); + const { + matches, + files, + error: matchError, + loadMatches, + hasMore, + total, + } = useMatches(id, { hops: 2 }); const handleLoadFile = useCallback(() => { loadFile(); - handleRetry(); - }, [handleRetry, loadFile]); + loadMatches(); + }, [loadMatches, loadFile]); if (file == null) { return ( @@ -99,15 +78,12 @@ function FileClusterPage(props) { let content; if (hasMore) { - const progress = - matchesState.total == null - ? undefined - : matches.length / matchesState.total; + const progress = total == null ? undefined : matches.length / total; content = ( ({ + root: { + paddingTop: theme.dimensions.content.padding * 2, + }, +})); + +function FileComparisonPage(props) { + const { className } = props; + const classes = useStyles(); + const { id: rawId } = useParams(); + const id = Number(rawId); + + return ( +
+ + + + + + + + +
+ ); +} + +FileComparisonPage.propTypes = { + className: PropTypes.string, +}; + +export default FileComparisonPage; diff --git a/web/src/collection/components/FileComparisonPage/FileDetails/FileDescriptionPane.js b/web/src/collection/components/FileComparisonPage/FileDetails/FileDescriptionPane.js new file mode 100644 index 00000000..145fb9e7 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/FileDetails/FileDescriptionPane.js @@ -0,0 +1,89 @@ +import React, { useCallback, useState } 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 CollapseButton from "../../../../common/components/CollapseButton"; +import { useIntl } from "react-intl"; +import Collapse from "@material-ui/core/Collapse"; +import VideoInformation from "../../VideoDetailsPage/VideoInformation"; + +const useStyles = makeStyles((theme) => ({ + root: { + boxShadow: "0 12px 18px 0 rgba(0,0,0,0.08)", + display: "flex", + flexDirection: "column", + alignItems: "stretch", + }, + header: { + padding: theme.spacing(2), + display: "flex", + alignItems: "center", + }, + title: { + ...theme.mixins.title3, + fontWeight: "bold", + flexGrow: 1, + }, + collapseButton: { + flexGrow: 0, + }, +})); + +/** + * Get i18n text. + */ +function useMessages() { + const intl = useIntl(); + return { + title: intl.formatMessage({ id: "file.details" }), + }; +} + +function FileDescriptionPane(props) { + const { file, onJump, collapsible, className, ...other } = props; + const classes = useStyles(); + const messages = useMessages(); + const [collapsed, setCollapsed] = useState(false); + + const handleCollapse = useCallback(() => setCollapsed(!collapsed), [ + collapsed, + ]); + + return ( + +
+
{messages.title}
+ {collapsible && ( + + )} +
+ + + +
+ ); +} + +FileDescriptionPane.propTypes = { + /** + * Video file + */ + file: FileType.isRequired, + /** + * Jump to a particular object + */ + onJump: PropTypes.func, + /** + * Enable or disable pane collapse feature. + */ + collapsible: PropTypes.bool, + className: PropTypes.string, +}; + +export default FileDescriptionPane; diff --git a/web/src/collection/components/FileComparisonPage/FileDetails/FileDetails.js b/web/src/collection/components/FileComparisonPage/FileDetails/FileDetails.js new file mode 100644 index 00000000..8d226631 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/FileDetails/FileDetails.js @@ -0,0 +1,52 @@ +import React, { useCallback, useState } from "react"; +import clsx from "clsx"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/styles"; +import { FileType } from "../../FileBrowserPage/FileType"; +import VideoPlayerPane from "../../VideoDetailsPage/VideoPlayerPane"; +import { seekTo } from "../../VideoDetailsPage/seekTo"; +import FileDescriptionPane from "./FileDescriptionPane"; + +const useStyles = makeStyles((theme) => ({ + root: { + // display: "block", + }, + pane: { + margin: theme.spacing(2), + }, +})); + +function FileDetails(props) { + const { file, className } = props; + const classes = useStyles(); + const [player, setPlayer] = useState(null); + + const handleJump = useCallback(seekTo(player, file), [player, file]); + + return ( +
+ + +
+ ); +} + +FileDetails.propTypes = { + /** + * Video file to be displayed + */ + file: FileType.isRequired, + className: PropTypes.string, +}; + +export default FileDetails; diff --git a/web/src/collection/components/FileComparisonPage/FileDetails/index.js b/web/src/collection/components/FileComparisonPage/FileDetails/index.js new file mode 100644 index 00000000..43418576 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/FileDetails/index.js @@ -0,0 +1 @@ +export { default } from "./FileDetails"; diff --git a/web/src/collection/components/FileComparisonPage/LoadingHeader.js b/web/src/collection/components/FileComparisonPage/LoadingHeader.js new file mode 100644 index 00000000..e0d22111 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/LoadingHeader.js @@ -0,0 +1,54 @@ +import React from "react"; +import clsx from "clsx"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/styles"; +import Paper from "@material-ui/core/Paper"; +import Loading from "../../../common/components/Loading"; + +const useStyles = makeStyles((theme) => ({ + root: { + boxShadow: "0 12px 18px 0 rgba(0,0,0,0.08)", + minHeight: theme.spacing(12), + display: "flex", + alignItems: "center", + justifyContent: "center", + }, +})); + +function LoadingHeader(props) { + const { error, errorMessage, onRetry, progress, className, ...other } = props; + const classes = useStyles(); + return ( + + + + ); +} + +LoadingHeader.propTypes = { + /** + * Indicate loading error + */ + error: PropTypes.bool, + /** + * The value of the progress indicator for the determinate and static variants. + * Value between 0 and 1. + */ + progress: PropTypes.number, + /** + * Trigger loading of the next portion of files + */ + onRetry: PropTypes.func.isRequired, + /** + * Message displayed when error=true + */ + errorMessage: PropTypes.string.isRequired, + className: PropTypes.string, +}; + +export default LoadingHeader; diff --git a/web/src/collection/components/FileComparisonPage/MatchFiles/FileMatchHeader.js b/web/src/collection/components/FileComparisonPage/MatchFiles/FileMatchHeader.js new file mode 100644 index 00000000..4eb08ebf --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/MatchFiles/FileMatchHeader.js @@ -0,0 +1,53 @@ +import React from "react"; +import clsx from "clsx"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/styles"; +import FileSummary from "../../FileSummary/FileSummary"; +import { FileType } from "../../FileBrowserPage/FileType"; +import Distance from "../../../../common/components/Distance"; + +const useStyles = makeStyles((theme) => ({ + header: { + boxShadow: "0 12px 18px 0 rgba(0,0,0,0.08)", + padding: theme.spacing(2), + }, + name: { + paddingBottom: theme.spacing(2), + }, + distance: { + minWidth: 150, + }, +})); + +function FileMatchHeader(props) { + const { distance, file, className, ...other } = props; + const classes = useStyles(); + return ( +
+ + + + + + + + + + +
+ ); +} + +FileMatchHeader.propTypes = { + /** + * Distance to the matched file. + */ + distance: PropTypes.number.isRequired, + /** + * Video file to be summarized. + */ + file: FileType.isRequired, + className: PropTypes.string, +}; + +export default FileMatchHeader; diff --git a/web/src/collection/components/FileComparisonPage/MatchFiles/MatchFiles.js b/web/src/collection/components/FileComparisonPage/MatchFiles/MatchFiles.js new file mode 100644 index 00000000..a319b029 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/MatchFiles/MatchFiles.js @@ -0,0 +1,110 @@ +import React, { useState } from "react"; +import clsx from "clsx"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/styles"; +import { useIntl } from "react-intl"; +import LoadingHeader from "../LoadingHeader"; +import FileDetails from "../FileDetails"; +import useDirectMatches from "../../../hooks/useDirectMatches"; +import FileMatchHeader from "./FileMatchHeader"; +import MatchSelector from "./MatchSelector"; + +const useStyles = makeStyles((theme) => ({ + root: {}, + header: { + height: theme.spacing(10), + padding: theme.spacing(2), + display: "flex", + alignItems: "center", + }, + title: { + ...theme.mixins.title3, + fontWeight: "bold", + flexGrow: 1, + }, + loading: { + margin: theme.spacing(2), + }, + fileHeader: { + marginTop: 0, + margin: theme.spacing(2), + }, +})); + +/** + * Get i18n text. + */ +function useMessages() { + const intl = useIntl(); + return { + title: intl.formatMessage({ id: "file.match" }), + loadError: intl.formatMessage({ id: "match.load.error" }), + }; +} + +function MatchFiles(props) { + const { motherFileId, className, ...other } = props; + const classes = useStyles(); + const messages = useMessages(); + const [selected, setSelected] = useState(0); + + const { + matches, + error: matchError, + loadMatches, + hasMore, + progress, + } = useDirectMatches(motherFileId); + + let content; + if (hasMore) { + content = ( + + ); + } else if (matches.length > 0) { + content = ( +
+ + +
+ ); + } else { + content = null; + } + + return ( +
+
+
{messages.title}
+ {!hasMore && ( + + )} +
+ {content} +
+ ); +} + +MatchFiles.propTypes = { + /** + * Mother file id. + */ + motherFileId: PropTypes.number.isRequired, + className: PropTypes.string, +}; + +export default MatchFiles; diff --git a/web/src/collection/components/FileComparisonPage/MatchFiles/MatchSelector.js b/web/src/collection/components/FileComparisonPage/MatchFiles/MatchSelector.js new file mode 100644 index 00000000..6b327716 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/MatchFiles/MatchSelector.js @@ -0,0 +1,131 @@ +import React, { useCallback } from "react"; +import clsx from "clsx"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/styles"; +import FileType from "../../FileBrowserPage/FileType"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import Select from "@material-ui/core/Select"; +import MenuItem from "@material-ui/core/MenuItem"; +import { useIntl } from "react-intl"; +import useUniqueId from "../../../../common/hooks/useUniqueId"; +import SquaredIconButton from "../../../../common/components/SquaredIconButton"; +import ButtonGroup from "@material-ui/core/ButtonGroup"; +import NavigateNextOutlinedIcon from "@material-ui/icons/NavigateNextOutlined"; +import NavigateBeforeOutlinedIcon from "@material-ui/icons/NavigateBeforeOutlined"; +import { basename } from "../../../../common/helpers/paths"; + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + alignItems: "center", + }, + formControl: { + width: 300, + }, + button: { + marginLeft: theme.spacing(1), + }, +})); + +/** + * Get i18n text. + */ +function useMessages() { + const intl = useIntl(); + return { + label: intl.formatMessage({ id: "file.match" }), + nextLabel: intl.formatMessage({ id: "aria.label.nextMatch" }), + prevLabel: intl.formatMessage({ id: "aria.label.prevMatch" }), + }; +} + +function MatchSelector(props) { + const { matches, onChange, selected, className } = props; + const classes = useStyles(); + const messages = useMessages(); + const labelId = useUniqueId("label"); + + const handleSelect = useCallback((event) => onChange(event.target.value), [ + onChange, + ]); + + const handleNext = useCallback( + () => onChange(Math.min(matches.length - 1, selected + 1)), + [matches, selected] + ); + + const handlePrev = useCallback(() => onChange(Math.max(0, selected - 1)), [ + matches, + selected, + ]); + + return ( +
+ + {messages.label} + + + + + + + = matches.length - 1} + className={classes.button} + aria-label={messages.nextLabel} + onClick={handleNext} + > + + + +
+ ); +} + +MatchSelector.propTypes = { + /** + * Selected match index. + */ + selected: PropTypes.number.isRequired, + /** + * Handle selection change. + */ + onChange: PropTypes.func.isRequired, + /** + * Single file matches. + */ + matches: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.any.isRequired, + distance: PropTypes.number.isRequired, + file: FileType.isRequired, + }) + ), + className: PropTypes.string, +}; + +export default MatchSelector; diff --git a/web/src/collection/components/FileComparisonPage/MatchFiles/index.js b/web/src/collection/components/FileComparisonPage/MatchFiles/index.js new file mode 100644 index 00000000..de52668e --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/MatchFiles/index.js @@ -0,0 +1 @@ +export { default } from "./MatchFiles"; diff --git a/web/src/collection/components/FileComparisonPage/MotherFile/FileDetailsHeader.js b/web/src/collection/components/FileComparisonPage/MotherFile/FileDetailsHeader.js new file mode 100644 index 00000000..46114f3e --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/MotherFile/FileDetailsHeader.js @@ -0,0 +1,44 @@ +import React from "react"; +import clsx from "clsx"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/styles"; +import FileSummary from "../../FileSummary/FileSummary"; +import { FileType } from "../../FileBrowserPage/FileType"; + +const useStyles = makeStyles((theme) => ({ + header: { + boxShadow: "0 12px 18px 0 rgba(0,0,0,0.08)", + padding: theme.spacing(2), + }, + name: { + paddingBottom: theme.spacing(2), + }, +})); + +function FileDetailsHeader(props) { + const { file, className } = props; + const classes = useStyles(); + return ( +
+ + + + + + + + + +
+ ); +} + +FileDetailsHeader.propTypes = { + /** + * Video file to be summarized. + */ + file: FileType.isRequired, + className: PropTypes.string, +}; + +export default FileDetailsHeader; diff --git a/web/src/collection/components/FileComparisonPage/MotherFile/MotherFile.js b/web/src/collection/components/FileComparisonPage/MotherFile/MotherFile.js new file mode 100644 index 00000000..23f30d34 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/MotherFile/MotherFile.js @@ -0,0 +1,87 @@ +import React from "react"; +import clsx from "clsx"; +import PropTypes from "prop-types"; +import { makeStyles } from "@material-ui/styles"; +import { useIntl } from "react-intl"; +import useFile from "../../../hooks/useFile"; +import LoadingHeader from "../LoadingHeader"; +import FileDetails from "../FileDetails"; +import FileDetailsHeader from "./FileDetailsHeader"; + +const useStyles = makeStyles((theme) => ({ + root: {}, + header: { + height: theme.spacing(10), + padding: theme.spacing(2), + display: "flex", + alignItems: "center", + }, + title: { + ...theme.mixins.title3, + fontWeight: "bold", + flexGrow: 1, + }, + loading: { + margin: theme.spacing(2), + }, + fileHeader: { + marginTop: 0, + margin: theme.spacing(2), + }, +})); + +/** + * Get i18n text. + */ +function useMessages() { + const intl = useIntl(); + return { + title: intl.formatMessage({ id: "file.mother" }), + loadError: intl.formatMessage({ id: "file.load.error.single" }), + }; +} + +function MotherFile(props) { + const { motherFileId, className, ...other } = props; + const classes = useStyles(); + const messages = useMessages(); + const { file, error, loadFile } = useFile(motherFileId); + + let content; + if (file == null) { + content = ( + + ); + } else { + content = ( +
+ + +
+ ); + } + + return ( +
+
+
{messages.title}
+
+ {content} +
+ ); +} + +MotherFile.propTypes = { + /** + * Mother file id. + */ + motherFileId: PropTypes.number.isRequired, + className: PropTypes.string, +}; + +export default MotherFile; diff --git a/web/src/collection/components/FileComparisonPage/MotherFile/index.js b/web/src/collection/components/FileComparisonPage/MotherFile/index.js new file mode 100644 index 00000000..46c75ec2 --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/MotherFile/index.js @@ -0,0 +1 @@ +export { default } from "./MotherFile"; diff --git a/web/src/collection/components/FileComparisonPage/index.js b/web/src/collection/components/FileComparisonPage/index.js new file mode 100644 index 00000000..692653ab --- /dev/null +++ b/web/src/collection/components/FileComparisonPage/index.js @@ -0,0 +1 @@ +export { default } from "./FileComparisonPage"; diff --git a/web/src/collection/components/FileMatchesPage/FileMatchesActions.js b/web/src/collection/components/FileMatchesPage/FileMatchesActions.js index c71b61ca..ceed863a 100644 --- a/web/src/collection/components/FileMatchesPage/FileMatchesActions.js +++ b/web/src/collection/components/FileMatchesPage/FileMatchesActions.js @@ -15,7 +15,6 @@ const useStyles = makeStyles((theme) => ({ alignItems: "center", }, button: { - color: theme.palette.action.textInactive, marginRight: theme.spacing(2), }, })); @@ -39,7 +38,8 @@ function FileMatchesActions(props) { return (
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 ( -
+
{messages.score}