From 1ce0a9eb35fd75c3555a499ef3cb94e54c296cae Mon Sep 17 00:00:00 2001
From: Stefan Kraus <stefan.kraus@methodpark.de>
Date: Sun, 13 Dec 2020 14:30:52 +0100
Subject: [PATCH] Add subtitles

---
 frontend/package.json          |  5 ++++
 frontend/src/App.tsx           |  1 +
 frontend/src/player/Player.tsx | 30 +++++++++++++++----
 frontend/src/url/UrlBox.tsx    | 54 ++++++++++++++++++++++++++++++++--
 frontend/src/url/UrlSlice.ts   | 12 ++++++--
 frontend/tsconfig.json         |  2 +-
 6 files changed, 91 insertions(+), 13 deletions(-)

diff --git a/frontend/package.json b/frontend/package.json
index 75757f7..8673b94 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -3,6 +3,8 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@ffmpeg/core": "^0.8.5",
+    "@ffmpeg/ffmpeg": "^0.9.6",
     "@giantmachines/redux-websocket": "^1.4.0",
     "@material-ui/core": "^4.11.0",
     "@material-ui/icons": "^4.11.2",
@@ -10,7 +12,10 @@
     "@testing-library/jest-dom": "^5.11.4",
     "@testing-library/react": "^11.1.0",
     "@testing-library/user-event": "^12.1.10",
+    "@types/ffmpeg.js": "^3.1.1",
     "@types/react-redux": "^7.1.11",
+    "ffmpeg.js": "^4.2.9003",
+    "memfs": "^3.2.0",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-player": "^2.7.0",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 78106f5..ec8ab32 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import StopButton from "./player/StopButton";
 import SyncToMeButton from "./player/SyncToMeButton";
 import SyncAllButton from "./player/SyncAllButton";
 
+
 import "./App.css";
 import UrlBox from "./url/UrlBox";
 import { Box, Container, Grid } from "@material-ui/core";
diff --git a/frontend/src/player/Player.tsx b/frontend/src/player/Player.tsx
index 63cc3f6..9c32b6b 100644
--- a/frontend/src/player/Player.tsx
+++ b/frontend/src/player/Player.tsx
@@ -2,6 +2,11 @@ import React, { RefObject } from "react";
 import ReactPlayer from "react-player";
 import { RootState } from "../store/store";
 import { connect } from "react-redux";
+//import { createFFmpeg, FFmpeg } from "@ffmpeg/ffmpeg"
+import { readFileSync }  from "fs";
+import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
+import { fs } from 'memfs';
+
 
 import {
   selectPlaying,
@@ -11,12 +16,15 @@ import {
   PlayerPlayingAction,
   PlayerSecondsAction,
 } from "./PlayerSlice";
-import { selectUrl } from "../url/UrlSlice";
+import { selectSubtibleFile, selectUrl } from "../url/UrlSlice";
+import { TableBody } from "@material-ui/core";
+import { findNonSerializableValue } from "@reduxjs/toolkit";
 
 interface StateProps {
   playing: boolean;
   seconds: number;
   url: string;
+  subtitleFile: string;
 }
 
 interface DispatchProps {
@@ -53,7 +61,7 @@ class Player extends React.Component<Props> {
     this.props.setSeconds(seconds);
   }
 
-  componentDidUpdate() {
+  async componentDidUpdate() {
     if (this.playerref.current === null) return;
 
     if (!ReactPlayer.canPlay(this.props.url)) return;
@@ -62,20 +70,24 @@ class Player extends React.Component<Props> {
     if (
       Math.abs(this.playerref.current.getCurrentTime() - this.props.seconds) <=
       0.05
-    )
+    ) {
       return;
-    /*
-     */
+    }
 
     // Enough difference now seek to it!
     this.playerref.current.seekTo(this.props.seconds);
   }
 
   render() {
+
+    let url = this.props.url || "?"
+    let subs = this.props.subtitleFile || "?"
+
     return (
       <div style={{ position: "relative", paddingTop: "56.25%" }}>
         <ReactPlayer
-          url={this.props.url || "?"}
+          key={url + subs}
+          url={url}
           style={{
             position: "absolute",
             top: 0,
@@ -91,6 +103,11 @@ class Player extends React.Component<Props> {
           onPause={this.props.pause}
           onSeek={this.handleSeek}
           ref={this.playerref}
+          config={{ file: {
+            tracks: [
+              {kind: 'subtitles', src: subs, srcLang: 'en', default: true, label: 'en'},
+            ]
+          }}}
         />
       </div>
     );
@@ -101,6 +118,7 @@ const mapStateToProps = (state: RootState): StateProps => ({
   playing: selectPlaying(state),
   seconds: selectSeconds(state),
   url: selectUrl(state),
+  subtitleFile: selectSubtibleFile(state),
 });
 
 type Dispatch = (action: PlayerPlayingAction | PlayerSecondsAction) => {};
diff --git a/frontend/src/url/UrlBox.tsx b/frontend/src/url/UrlBox.tsx
index 0a723b8..93a9c97 100644
--- a/frontend/src/url/UrlBox.tsx
+++ b/frontend/src/url/UrlBox.tsx
@@ -2,8 +2,10 @@ import React from "react";
 import { RootState } from "../store/store";
 import { connect } from "react-redux";
 
-import { UrlAction, selectUrl, setUrl } from "./UrlSlice";
+import { UrlAction, selectUrl, setUrl, setSubtitleFile } from "./UrlSlice";
 import { Button, Grid, TextField } from "@material-ui/core";
+import { createFFmpeg, fetchFile } from "@ffmpeg/ffmpeg";
+import { fs } from "memfs";
 
 interface StateProps {
   url: string;
@@ -11,6 +13,7 @@ interface StateProps {
 
 interface DispatchProps {
   dispatchUrl: (url: string) => void;
+  dispatchSubtitleFile: (file: string) => void;
 }
 
 export type Props = StateProps & DispatchProps;
@@ -21,6 +24,7 @@ class UrlBox extends React.Component<Props> {
     this.handleChange = this.handleChange.bind(this);
     this.handleServerClick = this.handleServerClick.bind(this);
     this.handleInput = this.handleInput.bind(this);
+    this.handleSubtitleClick = this.handleSubtitleClick.bind(this);
   }
 
   handleChange(event: React.ChangeEvent<HTMLInputElement>): void | undefined {
@@ -31,6 +35,37 @@ class UrlBox extends React.Component<Props> {
     this.props.dispatchUrl("https://mpvsync.de/video/index.mp4");
   }
 
+  async handleSubtitleClick(): Promise<void | undefined> {
+
+    try {
+
+      //let buffer = new Uint8Array(readFileSync(this.props.url))
+      const ffmpeg = createFFmpeg({
+        log: true,
+      });
+      await ffmpeg.load()
+
+      // load file in browser
+      ffmpeg.FS('writeFile', 'video.avi', await fetchFile(this.props.url))
+      // convert to vtt file
+      await ffmpeg.run('-i', 'video.avi', 'outfile.vtt')
+      // free memory from video file
+      ffmpeg.FS('unlink', 'video.avi');
+
+      // create blob-url to read from
+      let data: Uint8Array = ffmpeg.FS('readFile', 'outfile.vtt')
+      let blob = new Blob([data])
+      let u = URL.createObjectURL(blob)
+
+      this.props.dispatchSubtitleFile(u);
+
+    } catch (error) {
+      console.log(error)
+    }
+
+
+  }
+
   handleInput(event: React.ChangeEvent<HTMLInputElement>): void | undefined {
     if (event.target.files === null || event.target.files.length < 1) {
       return;
@@ -45,7 +80,7 @@ class UrlBox extends React.Component<Props> {
   render() {
     return (
       <Grid container alignItems="center" spacing={2}>
-        <Grid item xs={8}>
+        <Grid item xs={6}>
           <TextField
             label="Source URL"
             variant="outlined"
@@ -59,7 +94,7 @@ class UrlBox extends React.Component<Props> {
             hidden
             id="contained-button-file"
             type="file"
-            accept="video/*"
+            accept="*"
             onInput={this.handleInput}
           />
           <label htmlFor="contained-button-file">
@@ -83,6 +118,16 @@ class UrlBox extends React.Component<Props> {
             Stream from server
           </Button>
         </Grid>
+        <Grid item xs={2}>
+          <Button
+            size="large"
+            variant="outlined"
+            fullWidth
+            onClick={this.handleSubtitleClick}
+          >
+            Extract subtitles (beta)
+          </Button>
+        </Grid>
       </Grid>
     );
   }
@@ -98,6 +143,9 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
   dispatchUrl(url: string): void {
     dispatch(setUrl(url));
   },
+  dispatchSubtitleFile(file: string): void {
+    dispatch(setSubtitleFile(file));
+  },
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(UrlBox);
diff --git a/frontend/src/url/UrlSlice.ts b/frontend/src/url/UrlSlice.ts
index 6c9bf57..58c5a16 100644
--- a/frontend/src/url/UrlSlice.ts
+++ b/frontend/src/url/UrlSlice.ts
@@ -3,10 +3,12 @@ import { RootState } from "../store/store";
 
 export interface UrlState {
   url: string;
+  subtitleFile: string;
 }
 
 const initialState: UrlState = {
   url: "",
+  subtitleFile: "",
 };
 
 export type UrlAction = PayloadAction<string>;
@@ -16,15 +18,19 @@ export const UrlSlice = createSlice({
   initialState,
   reducers: {
     setUrl: (state: UrlState, action: UrlAction): void => {
-      console.log(action.payload);
+      console.log("set url: " + action.payload);
       state.url = action.payload;
-      console.log(state.url);
     },
+    setSubtitleFile: (state: UrlState, action: UrlAction): void => {
+      console.log("set subtitles: " + action.payload);
+      state.subtitleFile = action.payload;
+    }
   },
 });
 
-export const { setUrl } = UrlSlice.actions;
+export const { setUrl, setSubtitleFile } = UrlSlice.actions;
 
 export const selectUrl = (state: RootState) => state.url.url;
+export const selectSubtibleFile  = (state: RootState) => state.url.subtitleFile;
 
 export default UrlSlice.reducer;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index a0553fb..e88c508 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -18,4 +18,4 @@
     "jsx": "react"
   },
   "include": ["src"]
-}
+}
\ No newline at end of file
-- 
GitLab