diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec8ab3290b3c4deda60897439860b91288d2379e..547a910290866e4bfc111ef1a204e1109668699c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,57 +1,28 @@ import React from "react"; import Player from "./player/Player"; -import StartButton from "./player/StartButton"; -import StopButton from "./player/StopButton"; -import SyncToMeButton from "./player/SyncToMeButton"; -import SyncAllButton from "./player/SyncAllButton"; - import "./App.css"; -import UrlBox from "./url/UrlBox"; +import InputBar from "./url/InputBar"; import { Box, Container, Grid } from "@material-ui/core"; import ConnectionStatusBar from "./connectionstatus/ConnectionStatusBar"; -import InstantReplayButton from "./player/InstantReplayButton"; +import PlayerButtonBar from "./player/PlayerButtonBar"; function App() { return ( <Container maxWidth={window.innerWidth >= 2000 ? "xl" : "lg"}> - <ConnectionStatusBar /> <Box my={1}> <Grid container spacing={1}> <Grid item xs={12}> - <UrlBox /> + <ConnectionStatusBar /> + </Grid> + <Grid item xs={12}> + <InputBar /> </Grid> <Grid item xs={12}> - <Player></Player> + <Player /> </Grid> <Grid item xs={12}> - <Grid container spacing={1}> - <Grid item xs={6}> - <Grid container spacing={1}> - <Grid item> - {" "} - <StartButton />{" "} - </Grid> - <Grid item> - {" "} - <StopButton />{" "} - </Grid> - </Grid> - </Grid> - <Grid item xs={6}> - <Grid container justify="flex-end" spacing={1}> - <Grid item> - <InstantReplayButton /> - </Grid> - <Grid item> - <SyncToMeButton /> - </Grid> - <Grid item> - <SyncAllButton /> - </Grid> - </Grid> - </Grid> - </Grid> + <PlayerButtonBar /> </Grid> </Grid> </Box> diff --git a/frontend/src/backendconnection/communication.ts b/frontend/src/backendconnection/communication.ts index 9cb22b36a13a2b997bbfc65df9e4077a2f9436b6..e54d584d22d95c04766197d055a208c61f8bf1fe 100644 --- a/frontend/src/backendconnection/communication.ts +++ b/frontend/src/backendconnection/communication.ts @@ -4,7 +4,10 @@ import { setPlaySeconds, } from "../player/PlayerSlice"; -import { selectConnected, setConnectionStatus } from "../connectionstatus/ConnectionStatusSlice"; +import { + selectConnected, + setConnectionStatus, +} from "../connectionstatus/ConnectionStatusSlice"; import { all, put, select, takeEvery } from "redux-saga/effects"; diff --git a/frontend/src/connectionstatus/ConnectionStatusBar.tsx b/frontend/src/connectionstatus/ConnectionStatusBar.tsx index 5de5c46b8c9f439713e8d5f2746307d2f963cf78..a5c2d9a4ef6b023d274b96f53f1c78934ef7527b 100644 --- a/frontend/src/connectionstatus/ConnectionStatusBar.tsx +++ b/frontend/src/connectionstatus/ConnectionStatusBar.tsx @@ -25,18 +25,35 @@ interface StateProps { export type Props = StateProps; -export function ConnectionStatusBar( - props: Props -) { - - function TypographyHelper(){ - console.log(props.connected) - if (props.connected === true){ - return <Typography variant="h6" className={classes.title}>Connected to server</Typography> - } else if (props.connected === false){ - return <Typography style={{color: 'red'}} variant="h6" className={classes.title}>Lost connection to server</Typography> +export function ConnectionStatusBar(props: Props) { + function TypographyHelper() { + console.log(props.connected); + if (props.connected === true) { + return ( + <Typography variant="h6" className={classes.title}> + Connected to server + </Typography> + ); + } else if (props.connected === false) { + return ( + <Typography + style={{ color: "red" }} + variant="h6" + className={classes.title} + > + Lost connection to server + </Typography> + ); } else if (props.connected === null) { - return <Typography style={{color: 'red'}} variant="h1" className={classes.title}>Server is offline, pls restart website to connect</Typography> + return ( + <Typography + style={{ color: "red" }} + variant="h1" + className={classes.title} + > + Server is offline, pls restart website to connect + </Typography> + ); } } @@ -44,9 +61,7 @@ export function ConnectionStatusBar( return ( <div className={classes.root}> <AppBar position="static" color="default"> - <Toolbar> - { TypographyHelper() } - </Toolbar> + <Toolbar>{TypographyHelper()}</Toolbar> </AppBar> </div> ); diff --git a/frontend/src/player/InstantReplayButton.tsx b/frontend/src/player/InstantReplayButton.tsx index e231fa0eaf8fc09ac01a5c14327a87b8bf2a3df3..b3ea8800bb7e18decaefd777cedff91fac35a7ae 100644 --- a/frontend/src/player/InstantReplayButton.tsx +++ b/frontend/src/player/InstantReplayButton.tsx @@ -48,4 +48,7 @@ const mapDispatchToProps = (dispatch: Function): DispatchProps => ({ }, }); -export default connect(mapStateToProps, mapDispatchToProps)(InstantReplayButton); +export default connect( + mapStateToProps, + mapDispatchToProps +)(InstantReplayButton); diff --git a/frontend/src/player/Player.tsx b/frontend/src/player/Player.tsx index 9c32b6b0115d6e06df1e5e202858b1deaf5b1dfa..7ec33071c702e8b21c1d73c0df39b175f59fd090 100644 --- a/frontend/src/player/Player.tsx +++ b/frontend/src/player/Player.tsx @@ -2,11 +2,6 @@ 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, @@ -17,8 +12,6 @@ import { PlayerSecondsAction, } from "./PlayerSlice"; import { selectSubtibleFile, selectUrl } from "../url/UrlSlice"; -import { TableBody } from "@material-ui/core"; -import { findNonSerializableValue } from "@reduxjs/toolkit"; interface StateProps { playing: boolean; @@ -79,9 +72,8 @@ class Player extends React.Component<Props> { } render() { - - let url = this.props.url || "?" - let subs = this.props.subtitleFile || "?" + let url = this.props.url || "?"; + let subs = this.props.subtitleFile || "?"; return ( <div style={{ position: "relative", paddingTop: "56.25%" }}> @@ -103,11 +95,19 @@ 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'}, - ] - }}} + config={{ + file: { + tracks: [ + { + kind: "subtitles", + src: subs, + srcLang: "en", + default: true, + label: "en", + }, + ], + }, + }} /> </div> ); diff --git a/frontend/src/player/PlayerButtonBar.tsx b/frontend/src/player/PlayerButtonBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a2d81a47611f23474265bb169b8e20862f6ed799 --- /dev/null +++ b/frontend/src/player/PlayerButtonBar.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { connect } from "react-redux"; +import SyncAllButton from "./SyncAllButton"; +import SyncToMeButton from "./SyncToMeButton"; +import { Grid } from "@material-ui/core"; +import InstantReplayButton from "./InstantReplayButton"; +import StartButton from "./StartButton"; +import StopButton from "./StopButton"; + +class PlayerButtonBar extends React.Component<{}> { + render() { + return ( + <Grid container> + <Grid item xs={6}> + <Grid container spacing={1}> + <Grid item> + <StartButton /> + </Grid> + <Grid item> + <StopButton /> + </Grid> + </Grid> + </Grid> + <Grid item xs={6}> + <Grid container justify="flex-end" spacing={1}> + <Grid item> + <InstantReplayButton /> + </Grid> + <Grid item> + <SyncToMeButton /> + </Grid> + <Grid item> + <SyncAllButton /> + </Grid> + </Grid> + </Grid> + </Grid> + ); + } +} + +export default connect(null, null)(PlayerButtonBar); diff --git a/frontend/src/player/PlayerControlButton.tsx b/frontend/src/player/PlayerControlButton.tsx index 82baf03753eb19293e7b518e57d40ef752b3daf7..dc137a90a863d4ac354e11f6a50e036e7ef0c574 100644 --- a/frontend/src/player/PlayerControlButton.tsx +++ b/frontend/src/player/PlayerControlButton.tsx @@ -13,7 +13,10 @@ export type Props = { class PlayerControlButton extends React.Component<Props> { render() { return ( - <Button size="large" variant="contained" onClick={this.props.onClick} + <Button + size="large" + variant="contained" + onClick={this.props.onClick} startIcon={this.props.icon} > {this.props.content}{" "} diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index 9b21c9af88a052252d52f0b264e8d4d372102bcd..b6d0f2da0d0666adbcdefbf4f7b1187fb54805b8 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -57,7 +57,7 @@ export const store = configureStore({ ], }); -const path = window.location.pathname +const path = window.location.pathname; store.dispatch(connect("wss://mpvsync.de:8432/" + path)); sagaMiddleware.run(rootSaga); diff --git a/frontend/src/url/InputBar.tsx b/frontend/src/url/InputBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3fad070a659314d5159796d6081f3b3834220c38 --- /dev/null +++ b/frontend/src/url/InputBar.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { RootState } from "../store/store"; +import { connect } from "react-redux"; + +import "./style.css"; + +import { + UrlAction, + selectUrl, + setUrl, + setSubtitleFile, + selectSubtibleFile, +} from "./UrlSlice"; +import { Button, Grid } from "@material-ui/core"; +import { createFFmpeg, fetchFile } from "@ffmpeg/ffmpeg"; +import UploadButton from "./UploadButton"; +import UrlTextfield from "./UrlTextfield"; + +interface StateProps { + url: string; + subtitle: string; +} + +interface DispatchProps { + dispatchUrl: (url: string) => void; + dispatchSubtitleFile: (file: string) => void; +} + +export type Props = StateProps & DispatchProps; + +class UrlBox extends React.Component<Props> { + constructor(props: Props) { + super(props); + this.handleURLChange = this.handleURLChange.bind(this); + this.handleSubtitleChange = this.handleSubtitleChange.bind(this); + this.handleServerClick = this.handleServerClick.bind(this); + this.handleInput = this.handleInput.bind(this); + this.handleSubtitleClick = this.handleSubtitleClick.bind(this); + } + + handleURLChange( + event: React.ChangeEvent<HTMLInputElement> + ): void | undefined { + this.props.dispatchUrl(event.target.value); + } + + handleSubtitleChange( + event: React.ChangeEvent<HTMLInputElement> + ): void | undefined { + this.props.dispatchSubtitleFile(event.target.value); + } + + handleServerClick(): void | undefined { + this.props.dispatchUrl("https://mpvsync.de/video/index.mp4"); + this.props.dispatchSubtitleFile("https://mpvsync.de/video/index.vtt"); + } + + async handleSubtitleClick(): Promise<void | undefined> { + try { + //let buffer = new Uint8Array(readFileSync(this.props.url)) + const ffmpeg = createFFmpeg({ + log: true, + }); + await ffmpeg.load(); + this.props.dispatchSubtitleFile("Extracting..."); + + // 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) { + this.props.dispatchSubtitleFile("Error..."); + console.log(error); + } + } + + handleInput( + event: React.ChangeEvent<HTMLInputElement>, + dispatchFunction: (f: string) => void + ): void | undefined { + if (event.target.files === null || event.target.files.length < 1) { + return; + } + + const selectedFile = event.target.files[0]; + console.log(selectedFile); + event.target.files = null; // needed for proper update again + let url = URL.createObjectURL(selectedFile); + dispatchFunction(url); + } + + render() { + return ( + <Grid container> + <Grid item xs={8}> + <Grid container spacing={1}> + <Grid item xs={9}> + <UrlTextfield + label="Video Source" + value={this.props.url} + onChangeFunc={this.handleURLChange} + /> + </Grid> + <Grid item xs={3}> + <UploadButton + onChangeFunc={(event: React.ChangeEvent<HTMLInputElement>) => + this.handleInput(event, this.props.dispatchUrl) + } + /> + </Grid> + + <Grid item xs={9}> + <UrlTextfield + label="Subtitle Source" + value={this.props.subtitle} + onChangeFunc={this.handleSubtitleChange} + /> + </Grid> + <Grid item xs={3}> + <UploadButton + onChangeFunc={(event: React.ChangeEvent<HTMLInputElement>) => + this.handleInput(event, this.props.dispatchSubtitleFile) + } + /> + </Grid> + </Grid> + </Grid> + + <Grid item xs={4}> + <Grid container justify="flex-end" spacing={1}> + <Grid item xs={10}> + <Button + className="urlElemHeight" + size="large" + variant="outlined" + fullWidth + onClick={this.handleServerClick} + > + Stream from server + </Button> + </Grid> + <Grid item xs={10}> + <Button + className="urlElemHeight" + size="large" + fullWidth + variant="outlined" + onClick={this.handleSubtitleClick} + > + Extract subtitles (beta) + </Button> + </Grid> + </Grid> + </Grid> + </Grid> + ); + } +} + +//<Button variant="outlined" color="primary" onClick={this.handleLocalClick}>Stream from local Stream from server</Button> +const mapStateToProps = (state: RootState): StateProps => ({ + url: selectUrl(state), + subtitle: selectSubtibleFile(state), +}); + +type Dispatch = (action: UrlAction) => {}; +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/UploadButton.tsx b/frontend/src/url/UploadButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cafaa6d6702a2e8d37f253b95c846539a2351b30 --- /dev/null +++ b/frontend/src/url/UploadButton.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { connect } from "react-redux"; + +import { Button, Grid } from "@material-ui/core"; + +interface ComponentProps { + onChangeFunc: (event: React.ChangeEvent<HTMLInputElement>) => void; +} + +export type Props = ComponentProps; + +class UploadButton extends React.Component<Props> { + // We need a random id for the text field + id: string; + + constructor(props: Props) { + super(props); + this.id = Math.random().toString(16).slice(-13); + } + + render() { + return ( + <Grid> + <input + hidden + id={this.id} + type="file" + accept="*" + onChange={this.props.onChangeFunc} + /> + <label htmlFor={this.id}> + <Button + className="urlElemHeight" + size="large" + variant="outlined" + fullWidth + style={{ display: "flex" }} + component="span" + > + From local + </Button> + </label> + </Grid> + ); + } +} + +export default connect(null, null)(UploadButton); diff --git a/frontend/src/url/UrlBox.tsx b/frontend/src/url/UrlBox.tsx deleted file mode 100644 index eb7e81787f6cee84b4c91cfb16820a998de8920a..0000000000000000000000000000000000000000 --- a/frontend/src/url/UrlBox.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import React from "react"; -import { RootState } from "../store/store"; -import { connect } from "react-redux"; - -import { UrlAction, selectUrl, setUrl, setSubtitleFile, selectSubtibleFile } from "./UrlSlice"; -import { Button, Grid, TextField } from "@material-ui/core"; -import { createFFmpeg, fetchFile } from "@ffmpeg/ffmpeg"; - -interface StateProps { - url: string; - subtitle: string; -} - -interface DispatchProps { - dispatchUrl: (url: string) => void; - dispatchSubtitleFile: (file: string) => void; -} - -export type Props = StateProps & DispatchProps; - -class UrlBox extends React.Component<Props> { - constructor(props: Props) { - super(props); - this.handleURLChange = this.handleURLChange.bind(this); - this.handleSubtitleChange = this.handleSubtitleChange.bind(this); - this.handleServerClick = this.handleServerClick.bind(this); - this.handleInput = this.handleInput.bind(this); - this.handleSubtitleClick = this.handleSubtitleClick.bind(this); - } - - handleURLChange(event: React.ChangeEvent<HTMLInputElement>): void | undefined { - this.props.dispatchUrl(event.target.value); - } - - handleSubtitleChange(event: React.ChangeEvent<HTMLInputElement>): void | undefined { - this.props.dispatchSubtitleFile(event.target.value); - } - - handleServerClick(): void | undefined { - this.props.dispatchUrl("https://mpvsync.de/video/index.mp4"); - this.props.dispatchSubtitleFile("https://mpvsync.de/video/index.vtt"); - } - - async handleSubtitleClick(): Promise<void | undefined> { - - try { - - //let buffer = new Uint8Array(readFileSync(this.props.url)) - const ffmpeg = createFFmpeg({ - log: true, - }); - await ffmpeg.load() - this.props.dispatchSubtitleFile("Extracting...") - - // 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) { - this.props.dispatchSubtitleFile("Error...") - console.log(error) - } - - - } - - handleInput(event: React.ChangeEvent<HTMLInputElement>, dispatchFunction: (f: string) => void): void | undefined { - if (event.target.files === null || event.target.files.length < 1) { - return; - } - - - const selectedFile = event.target.files[0]; - console.log(selectedFile) - event.target.files = null; // needed for proper update again - let url = URL.createObjectURL(selectedFile); - dispatchFunction(url); - } - - render() { - return ( - <Grid container alignItems="center" spacing={1}> - <Grid container alignItems="center" spacing={1} xs={10}> - <Grid item xs={6}> - <TextField - label="Video Source" - variant="outlined" - fullWidth - value={this.props.url} - onChange={this.handleURLChange} - /> - </Grid> - <Grid item xs={3}> - <input - hidden - id="url-button-file" - type="file" - accept="*" - onChange={(event) => this.handleInput(event, this.props.dispatchUrl)} - /> - <label htmlFor="url-button-file"> - <Button - size="large" - variant="outlined" - style={{ display: "flex" }} - component="span" - > - From local - </Button> - </label> - </Grid> - - <Grid item xs={6}> - <TextField - label="Subtitle Source" - variant="outlined" - fullWidth - value={this.props.subtitle} - onChange={this.handleSubtitleChange} - /> - </Grid> - <Grid item xs={3}> - <input - hidden - id="sub-button-file" - type="file" - accept="*" - onChange={(event) => this.handleInput(event, this.props.dispatchSubtitleFile)} - /> - <label htmlFor="sub-button-file"> - <Button - size="large" - variant="outlined" - style={{ display: "flex" }} - component="span" - > - From local - </Button> - </label> - </Grid> - <Grid item xs={3}> - <Button - size="large" - variant="outlined" - fullWidth - onClick={this.handleSubtitleClick} - > - Extract subtitles (beta) - </Button> - </Grid> - - </Grid> - <Grid item xs={2} > - <Button - size="large" - variant="outlined" - fullWidth - onClick={this.handleServerClick} - > - Stream from server - </Button> - </Grid> - - - </Grid > - ); - } -} - -//<Button variant="outlined" color="primary" onClick={this.handleLocalClick}>Stream from local Stream from server</Button> -const mapStateToProps = (state: RootState): StateProps => ({ - url: selectUrl(state), - subtitle: selectSubtibleFile(state), -}); - -type Dispatch = (action: UrlAction) => {}; -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 58c5a16850b88727e267ad34d6c0d62edbbf36e5..e0e6b951c7c1389fd6099c2706e4a3cc54a1d0c8 100644 --- a/frontend/src/url/UrlSlice.ts +++ b/frontend/src/url/UrlSlice.ts @@ -24,13 +24,13 @@ export const UrlSlice = createSlice({ setSubtitleFile: (state: UrlState, action: UrlAction): void => { console.log("set subtitles: " + action.payload); state.subtitleFile = action.payload; - } + }, }, }); export const { setUrl, setSubtitleFile } = UrlSlice.actions; export const selectUrl = (state: RootState) => state.url.url; -export const selectSubtibleFile = (state: RootState) => state.url.subtitleFile; +export const selectSubtibleFile = (state: RootState) => state.url.subtitleFile; export default UrlSlice.reducer; diff --git a/frontend/src/url/UrlTextfield.tsx b/frontend/src/url/UrlTextfield.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d143e1b4ed9e5bb3c0fc14f1307d5435eaeeb7f --- /dev/null +++ b/frontend/src/url/UrlTextfield.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { connect } from "react-redux"; + +import "./style.css"; + +import { TextField } from "@material-ui/core"; + +interface ComponentProps { + label: string; + value: string; + onChangeFunc: (event: React.ChangeEvent<HTMLInputElement>) => void; +} + +export type Props = ComponentProps; + +class UrlTextfield extends React.Component<Props> { + // We need a random id for the text field + id: string; + + constructor(props: Props) { + super(props); + this.id = Math.random().toString(16).slice(-13); + } + + render() { + return ( + <TextField + className="urlElemHeight" + label={this.props.label} + variant="outlined" + fullWidth + value={this.props.value} + onChange={this.props.onChangeFunc} + /> + ); + } +} + +export default connect(null, null)(UrlTextfield); diff --git a/frontend/src/url/style.css b/frontend/src/url/style.css new file mode 100644 index 0000000000000000000000000000000000000000..9c9e6455e522a177d27c2e0d7ed8f1355f646cb8 --- /dev/null +++ b/frontend/src/url/style.css @@ -0,0 +1,3 @@ +.urlElemHeight { + min-height: 56px; +}