Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle playback issues #165

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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