Skip to content

Commit

Permalink
Handle playback issues (#165)
Browse files Browse the repository at this point in the history
* Handle missing file in thumbnail endpoint

* Handle missing file in watch endpoint

* Display playback/loading errors

* Handle file missing in database case

* Use bundled flv.js

By default react-player tries to lazy-load playback SDK from CDN.
But the application must be able play video files when Internet
connection is not available. To solve that we bundle flv.js and
initialize global variable consumed by react-player's FilePlayer.

See https://www.npmjs.com/package/react-player#sdk-overrides
See cookpete/react-player#605 (comment)

* Suppress excessive flv.js error logs (#149)
  • Loading branch information
stepan-anokhin committed Oct 26, 2020
1 parent 10e9532 commit e802bad
Show file tree
Hide file tree
Showing 10 changed files with 169 additions and 16 deletions.
3 changes: 3 additions & 0 deletions server/server/api/files.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from http import HTTPStatus
from os.path import dirname, basename

Expand Down Expand Up @@ -91,6 +92,8 @@ def get_thumbnail(file_id):
thumbnail = thumbnails_cache.get(file.file_path, file.sha256, position=time)
if thumbnail is None:
video_path = resolve_video_file_path(file.file_path)
if not os.path.isfile(video_path):
abort(HTTPStatus.NOT_FOUND.value, f"Video file is missing: {file.file_path}")
thumbnail = extract_frame_tmp(video_path, position=time)
if thumbnail is None:
abort(HTTPStatus.NOT_FOUND.value, f"Timestamp exceeds video length: {time}")
Expand Down
4 changes: 4 additions & 0 deletions server/server/api/videos.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from http import HTTPStatus
from os.path import dirname, basename

Expand All @@ -18,4 +19,7 @@ def watch_video(file_id):
abort(HTTPStatus.NOT_FOUND.value, f"File id not found: {file_id}")

path = resolve_video_file_path(file.file_path)
if not os.path.isfile(path):
abort(HTTPStatus.NOT_FOUND.value, f"Video file is missing: {file.file_path}")

return send_from_directory(dirname(path), basename(path))
19 changes: 19 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"clsx": "^1.1.1",
"d3": "^6.2.0",
"date-fns": "^2.16.1",
"flv.js": "^1.5.0",
"fontsource-roboto": "^2.1.4",
"get-user-locale": "^1.4.0",
"http-status-codes": "^1.4.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import IconButton from "@material-ui/core/IconButton";
import ArrowBackOutlinedIcon from "@material-ui/icons/ArrowBackOutlined";
import { useHistory } from "react-router";
import { ButtonBase } from "@material-ui/core";
import { Status } from "../../../server-api/Response";

const useStyles = makeStyles((theme) => ({
header: {
Expand Down Expand Up @@ -51,6 +52,7 @@ function useMessages() {
return {
retry: intl.formatMessage({ id: "actions.retry" }),
error: intl.formatMessage({ id: "file.load.error.single" }),
notFound: intl.formatMessage({ id: "file.load.error.notFound" }),
goBack: intl.formatMessage({ id: "actions.goBack" }),
};
}
Expand Down Expand Up @@ -80,13 +82,11 @@ function FileLoadingHeader(props) {
);
}

return (
<div className={clsx(classes.header, className)}>
{back && (
<IconButton onClick={handleBack} aria-label={messages.goBack}>
<ArrowBackOutlinedIcon />
</IconButton>
)}
let content;
if (error.status === Status.NOT_FOUND) {
content = <div className={classes.errorMessage}>{messages.notFound}</div>;
} else {
content = (
<div className={classes.errorMessage}>
{messages.error}
<ButtonBase
Expand All @@ -98,6 +98,17 @@ function FileLoadingHeader(props) {
{messages.retry}
</ButtonBase>
</div>
);
}

return (
<div className={clsx(classes.header, className)}>
{back && (
<IconButton onClick={handleBack} aria-label={messages.goBack}>
<ArrowBackOutlinedIcon />
</IconButton>
)}
{content}
</div>
);
}
Expand All @@ -107,7 +118,9 @@ FileLoadingHeader.propTypes = {
* True iff file is not loading and previous
* attempt resulted in failure.
*/
error: PropTypes.bool.isRequired,
error: PropTypes.shape({
status: PropTypes.any,
}),
/**
* Fires on retry.
*/
Expand Down
104 changes: 101 additions & 3 deletions web/src/collection/components/VideoDetailsPage/VideoPlayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,47 @@ import { makeStyles } from "@material-ui/styles";
import { FileType } from "../FileBrowserPage/FileType";
import MediaPreview from "../../../common/components/MediaPreview";
import ReactPlayer from "react-player";
import { FLV_GLOBAL } from "react-player/lib/players/FilePlayer";
import flvjs from "flv.js";
import TimeCaption from "./TimeCaption";
import VideoController from "./VideoController";
import { useServer } from "../../../server-api/context";
import { Status } from "../../../server-api/Response";
import { useIntl } from "react-intl";
import WarningOutlinedIcon from "@material-ui/icons/WarningOutlined";

const useStyles = makeStyles(() => ({
/**
* Setup bundled flv.js.
*
* By default react-player tries to lazy-load playback SDK from CDN.
* But the application must be able play video files when Internet
* connection is not available. To solve that we bundle flv.js and
* initialize global variable consumed by react-player's FilePlayer.
*
* See https://www.npmjs.com/package/react-player#sdk-overrides
* See https://github.com/CookPete/react-player/issues/605#issuecomment-492561909
*/
function setupBundledFlvJs(options = { suppressLogs: false }) {
const FLV_VAR = FLV_GLOBAL || "flvjs";
if (window[FLV_VAR] == null) {
window[FLV_VAR] = flvjs;
}

// Disable flv.js error messages and info messages (#149)
if (options.suppressLogs) {
flvjs.LoggingControl.enableError = false;
flvjs.LoggingControl.enableVerbose = false;

const doCreatePlayer = flvjs.createPlayer;
flvjs.createPlayer = (mediaDataSource, optionalConfig) => {
const player = doCreatePlayer(mediaDataSource, optionalConfig);
player.on("error", () => {});
return player;
};
}
}

const useStyles = makeStyles((theme) => ({
container: {},
preview: {
width: "100%",
Expand All @@ -19,28 +56,77 @@ const useStyles = makeStyles(() => ({
height: "100%",
maxHeight: 300,
},
error: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
backgroundColor: theme.palette.common.black,
color: theme.palette.grey[500],
...theme.mixins.text,
},
errorIcon: {
margin: theme.spacing(2),
},
}));

function makePreviewActions(handleWatch) {
return [{ name: "Watch Video", handler: handleWatch }];
}

/**
* Get i18n text.
*/
function useMessages() {
const intl = useIntl();
return {
notFoundError: intl.formatMessage({ id: "video.error.missing" }),
loadError: intl.formatMessage({ id: "video.error.load" }),
playbackError: intl.formatMessage({ id: "video.error.playback" }),
};
}

const VideoPlayer = function VideoPlayer(props) {
const { file, onReady, onProgress, className } = props;
const {
file,
onReady,
onProgress,
suppressErrors = false,
className,
} = props;
const classes = useStyles();
const server = useServer();
const messages = useMessages();
const [watch, setWatch] = useState(false);
const [player, setPlayer] = useState(null);
const [error, setError] = useState(null);

const handleWatch = useCallback(() => setWatch(true), []);
const controller = useMemo(() => new VideoController(player, setWatch), []);
const previewActions = useMemo(() => makePreviewActions(handleWatch), []);

// Make sure flv.js is available
useEffect(() => setupBundledFlvJs({ suppressLogs: suppressErrors }), []);

// Provide controller to the consumer
useEffect(() => onReady && onReady(controller), [onReady]);

// Update controlled player
useEffect(() => controller._setPlayer(player), [player]);

// Check if video is available
useEffect(() => {
server.probeVideoFile({ id: file.id }).then((response) => {
if (response.status === Status.NOT_FOUND) {
setError(messages.notFoundError);
} else if (response.status !== Status.OK) {
setError(messages.loadError);
}
});
}, [server, file.id]);

// Enable support for flv files.
// See https://github.com/CookPete/react-player#config-prop
const exifType = file?.exif?.General_FileExtension?.trim();
Expand All @@ -59,7 +145,7 @@ const VideoPlayer = function VideoPlayer(props) {
onMediaClick={handleWatch}
/>
)}
{watch && (
{watch && error == null && (
<ReactPlayer
playing
ref={setPlayer}
Expand All @@ -68,13 +154,20 @@ const VideoPlayer = function VideoPlayer(props) {
controls
url={file.playbackURL}
onProgress={onProgress}
onError={() => setError(messages.playbackError)}
config={{
file: {
forceFLV,
},
}}
/>
)}
{watch && error != null && (
<div className={classes.error}>
<WarningOutlinedIcon fontSize="large" className={classes.errorIcon} />
{error}
</div>
)}
</div>
);
};
Expand Down Expand Up @@ -110,6 +203,11 @@ VideoPlayer.propTypes = {
* https://www.npmjs.com/package/react-player#callback-props
*/
onProgress: PropTypes.func,

/**
* Suppress error logs.
*/
suppressErrors: PropTypes.bool,
className: PropTypes.string,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function VideoPlayerPane(props) {
className={classes.player}
onReady={callEach(setPlayer, onPlayerReady)}
onProgress={setProgress}
suppressErrors
/>
<ObjectTimeLine
file={file}
Expand Down
9 changes: 5 additions & 4 deletions web/src/collection/hooks/useFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,34 @@ import { useDispatch, useSelector } from "react-redux";
import { selectCachedFile } from "../state/selectors";
import { useServer } from "../../server-api/context";
import { cacheFile } from "../state/actions";
import { Status } from "../../server-api/Response";

/**
* Fetch file by id.
* @param id
*/
export function useFile(id) {
const file = useSelector(selectCachedFile(id));
const [error, setError] = useState(false);
const [error, setError] = useState(null);
const server = useServer();
const dispatch = useDispatch();

const loadFile = useCallback(() => {
const doLoad = async () => {
setError(false);
setError(null);
const response = await server.fetchFile({ id });
if (response.success) {
const file = response.data;
dispatch(cacheFile(file));
} else {
console.error(response.error);
setError(true);
setError({ status: response.status });
}
};

doLoad().catch((error) => {
console.error(error);
setError(true);
setError({ status: Status.CLIENT_ERROR });
});
}, [id]);

Expand Down
6 changes: 5 additions & 1 deletion web/src/i18n/locales/default.en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"file.tabExif": "EXIF Data",
"file.load.error": "Error loading files.",
"file.load.error.single": "Error loading file.",
"file.load.error.notFound": "File not found.",
"file.details": "Details",
"file.oneMatch": "01 File Matched",
"file.manyMatches": "{count} Files Matched",
Expand Down Expand Up @@ -171,6 +172,9 @@
"filter.creationDate": "Creation date (mm/dd/yyyy)",
"filter.creationDate.help": "Based on file creation date.",
"preview.notAvailable": "Preview not available.",
"match.load.error": "Error loading matches."
"match.load.error": "Error loading matches.",
"video.error.missing": "File Missing",
"video.error.load": "Loading Error",
"video.error.playback": "Playback Error"
}
}
9 changes: 9 additions & 0 deletions web/src/server-api/Server/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ export default class Server {
}
}

async probeVideoFile({ id }) {
try {
await this.axios.head(`/files/${id}/watch`);
return Response.ok(null);
} catch (error) {
return this.errorResponse(error);
}
}

errorResponse(error) {
if (error.response == null) {
return Response.clientError(error);
Expand Down

0 comments on commit e802bad

Please sign in to comment.