From 9e61fe15ef3187e9939eff37207413e5670bcb9d Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Thu, 21 May 2020 10:48:37 -0400 Subject: [PATCH] v1.2.0-beta/fix file structure --- app/config/env.js | 93 + app/config/jest/cssTransform.js | 14 + app/config/jest/fileTransform.js | 12 + app/config/paths.js | 55 + app/config/polyfills.js | 22 + app/config/webpack.config.dev.js | 264 + app/config/webpack.config.prod.js | 346 + app/config/webpackDevServer.config.js | 95 + app/package-lock.json | 16448 ++++++++++++++++++++++++ app/package.json | 114 + app/public/android-icon-144x144.png | Bin 0 -> 2097 bytes app/public/android-icon-192x192.png | Bin 0 -> 1379 bytes app/public/background.js | 131 + app/public/favicon-128x128.png | Bin 0 -> 5088 bytes app/public/favicon-16x16.png | Bin 0 -> 915 bytes app/public/favicon-32x32.png | Bin 0 -> 1204 bytes app/public/favicon-96x96.png | Bin 0 -> 1751 bytes app/public/favicon.ico | Bin 0 -> 1150 bytes app/public/index.html | 7 + app/public/logo192.png | Bin 0 -> 8271 bytes app/public/logo512.png | Bin 0 -> 11066 bytes app/public/manifest.json | 66 + app/public/popup.html | 54 + app/public/robots.txt | 3 + app/scripts/build.js | 150 + app/scripts/start.js | 102 + app/scripts/test.js | 27 + app/src/Api.js | 111 + app/src/App.js | 93 + app/src/Chat.js | 280 + app/src/Create.js | 180 + app/src/Inactive.js | 13 + app/src/Loading.js | 10 + app/src/Thread.css | 95 + app/src/Thread.js | 70 + app/src/VideoListener.js | 304 + app/src/index.css | 302 + app/src/index.js | 70 + 38 files changed, 19531 insertions(+) create mode 100644 app/config/env.js create mode 100644 app/config/jest/cssTransform.js create mode 100644 app/config/jest/fileTransform.js create mode 100644 app/config/paths.js create mode 100644 app/config/polyfills.js create mode 100644 app/config/webpack.config.dev.js create mode 100644 app/config/webpack.config.prod.js create mode 100644 app/config/webpackDevServer.config.js create mode 100644 app/package-lock.json create mode 100644 app/package.json create mode 100644 app/public/android-icon-144x144.png create mode 100644 app/public/android-icon-192x192.png create mode 100644 app/public/background.js create mode 100644 app/public/favicon-128x128.png create mode 100644 app/public/favicon-16x16.png create mode 100644 app/public/favicon-32x32.png create mode 100644 app/public/favicon-96x96.png create mode 100644 app/public/favicon.ico create mode 100644 app/public/index.html create mode 100644 app/public/logo192.png create mode 100644 app/public/logo512.png create mode 100644 app/public/manifest.json create mode 100644 app/public/popup.html create mode 100644 app/public/robots.txt create mode 100644 app/scripts/build.js create mode 100644 app/scripts/start.js create mode 100644 app/scripts/test.js create mode 100644 app/src/Api.js create mode 100644 app/src/App.js create mode 100644 app/src/Chat.js create mode 100644 app/src/Create.js create mode 100644 app/src/Inactive.js create mode 100644 app/src/Loading.js create mode 100644 app/src/Thread.css create mode 100644 app/src/Thread.js create mode 100644 app/src/VideoListener.js create mode 100644 app/src/index.css create mode 100644 app/src/index.js diff --git a/app/config/env.js b/app/config/env.js new file mode 100644 index 0000000..30a6c7f --- /dev/null +++ b/app/config/env.js @@ -0,0 +1,93 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const paths = require('./paths'); + +// Make sure that including paths.js after env.js will read .env variables. +delete require.cache[require.resolve('./paths')]; + +const NODE_ENV = process.env.NODE_ENV; +if (!NODE_ENV) { + throw new Error( + 'The NODE_ENV environment variable is required but was not specified.' + ); +} + +// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use +var dotenvFiles = [ + `${paths.dotenv}.${NODE_ENV}.local`, + `${paths.dotenv}.${NODE_ENV}`, + // Don't include `.env.local` for `test` environment + // since normally you expect tests to produce the same + // results for everyone + NODE_ENV !== 'test' && `${paths.dotenv}.local`, + paths.dotenv, +].filter(Boolean); + +// Load environment variables from .env* files. Suppress warnings using silent +// if this file is missing. dotenv will never modify any environment variables +// that have already been set. Variable expansion is supported in .env files. +// https://github.com/motdotla/dotenv +// https://github.com/motdotla/dotenv-expand +dotenvFiles.forEach(dotenvFile => { + if (fs.existsSync(dotenvFile)) { + require('dotenv-expand')( + require('dotenv').config({ + path: dotenvFile, + }) + ); + } +}); + +// We support resolving modules according to `NODE_PATH`. +// This lets you use absolute paths in imports inside large monorepos: +// https://github.com/facebookincubator/create-react-app/issues/253. +// It works similar to `NODE_PATH` in Node itself: +// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders +// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. +// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. +// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 +// We also resolve them to make sure all tools using them work consistently. +const appDirectory = fs.realpathSync(process.cwd()); +process.env.NODE_PATH = (process.env.NODE_PATH || '') + .split(path.delimiter) + .filter(folder => folder && !path.isAbsolute(folder)) + .map(folder => path.resolve(appDirectory, folder)) + .join(path.delimiter); + +// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be +// injected into the application via DefinePlugin in Webpack configuration. +const REACT_APP = /^REACT_APP_/i; + +function getClientEnvironment(publicUrl) { + const raw = Object.keys(process.env) + .filter(key => REACT_APP.test(key)) + .reduce( + (env, key) => { + env[key] = process.env[key]; + return env; + }, + { + // Useful for determining whether we’re running in production mode. + // Most importantly, it switches React into the correct mode. + NODE_ENV: process.env.NODE_ENV || 'development', + // Useful for resolving the correct path to static assets in `public`. + // For example, . + // This should only be used as an escape hatch. Normally you would put + // images into the `src` and `import` them in code to get their paths. + PUBLIC_URL: publicUrl, + } + ); + // Stringify all values so we can feed into Webpack DefinePlugin + const stringified = { + 'process.env': Object.keys(raw).reduce((env, key) => { + env[key] = JSON.stringify(raw[key]); + return env; + }, {}), + }; + + return { raw, stringified }; +} + +module.exports = getClientEnvironment; diff --git a/app/config/jest/cssTransform.js b/app/config/jest/cssTransform.js new file mode 100644 index 0000000..8f65114 --- /dev/null +++ b/app/config/jest/cssTransform.js @@ -0,0 +1,14 @@ +'use strict'; + +// This is a custom Jest transformer turning style imports into empty objects. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process() { + return 'module.exports = {};'; + }, + getCacheKey() { + // The output is always the same. + return 'cssTransform'; + }, +}; diff --git a/app/config/jest/fileTransform.js b/app/config/jest/fileTransform.js new file mode 100644 index 0000000..9e4047d --- /dev/null +++ b/app/config/jest/fileTransform.js @@ -0,0 +1,12 @@ +'use strict'; + +const path = require('path'); + +// This is a custom Jest transformer turning file imports into filenames. +// http://facebook.github.io/jest/docs/en/webpack.html + +module.exports = { + process(src, filename) { + return `module.exports = ${JSON.stringify(path.basename(filename))};`; + }, +}; diff --git a/app/config/paths.js b/app/config/paths.js new file mode 100644 index 0000000..6d16efc --- /dev/null +++ b/app/config/paths.js @@ -0,0 +1,55 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const url = require('url'); + +// Make sure any symlinks in the project folder are resolved: +// https://github.com/facebookincubator/create-react-app/issues/637 +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = relativePath => path.resolve(appDirectory, relativePath); + +const envPublicUrl = process.env.PUBLIC_URL; + +function ensureSlash(path, needsSlash) { + const hasSlash = path.endsWith('/'); + if (hasSlash && !needsSlash) { + return path.substr(path, path.length - 1); + } else if (!hasSlash && needsSlash) { + return `${path}/`; + } else { + return path; + } +} + +const getPublicUrl = appPackageJson => + envPublicUrl || require(appPackageJson).homepage; + +// We use `PUBLIC_URL` environment variable or "homepage" field to infer +// "public path" at which the app is served. +// Webpack needs to know it to put the right + + + + Hulu + Netflix + Prime Video + + diff --git a/app/public/robots.txt b/app/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/app/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/app/scripts/build.js b/app/scripts/build.js new file mode 100644 index 0000000..5709f48 --- /dev/null +++ b/app/scripts/build.js @@ -0,0 +1,150 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'production'; +process.env.NODE_ENV = 'production'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + +const path = require('path'); +const chalk = require('chalk'); +const fs = require('fs-extra'); +const webpack = require('webpack'); +const config = require('../config/webpack.config.prod'); +const paths = require('../config/paths'); +const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); +const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); +const printHostingInstructions = require('react-dev-utils/printHostingInstructions'); +const FileSizeReporter = require('react-dev-utils/FileSizeReporter'); +const printBuildError = require('react-dev-utils/printBuildError'); + +const measureFileSizesBeforeBuild = + FileSizeReporter.measureFileSizesBeforeBuild; +const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild; +const useYarn = fs.existsSync(paths.yarnLockFile); + +// These sizes are pretty large. We'll warn for bundles exceeding them. +const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; +const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +// First, read the current file sizes in build directory. +// This lets us display how much they changed later. +measureFileSizesBeforeBuild(paths.appBuild) + .then(previousFileSizes => { + // Remove all content but keep the directory so that + // if you're in it, you don't end up in Trash + fs.emptyDirSync(paths.appBuild); + // Merge with the public folder + copyPublicFolder(); + // Start the webpack build + return build(previousFileSizes); + }) + .then( + ({ stats, previousFileSizes, warnings }) => { + if (warnings.length) { + console.log(chalk.yellow('Compiled with warnings.\n')); + console.log(warnings.join('\n\n')); + console.log( + '\nSearch for the ' + + chalk.underline(chalk.yellow('keywords')) + + ' to learn more about each warning.' + ); + console.log( + 'To ignore, add ' + + chalk.cyan('// eslint-disable-next-line') + + ' to the line before.\n' + ); + } else { + console.log(chalk.green('Compiled successfully.\n')); + } + + console.log('File sizes after gzip:\n'); + printFileSizesAfterBuild( + stats, + previousFileSizes, + paths.appBuild, + WARN_AFTER_BUNDLE_GZIP_SIZE, + WARN_AFTER_CHUNK_GZIP_SIZE + ); + console.log(); + + const appPackage = require(paths.appPackageJson); + const publicUrl = paths.publicUrl; + const publicPath = config.output.publicPath; + const buildFolder = path.relative(process.cwd(), paths.appBuild); + printHostingInstructions( + appPackage, + publicUrl, + publicPath, + buildFolder, + useYarn + ); + }, + err => { + console.log(chalk.red('Failed to compile.\n')); + printBuildError(err); + process.exit(1); + } + ); + +// Create the production build and print the deployment instructions. +function build(previousFileSizes) { + console.log('Creating an optimized production build...'); + + let compiler = webpack(config); + return new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err) { + return reject(err); + } + const messages = formatWebpackMessages(stats.toJson({}, true)); + if (messages.errors.length) { + // Only keep the first error. Others are often indicative + // of the same problem, but confuse the reader with noise. + if (messages.errors.length > 1) { + messages.errors.length = 1; + } + return reject(new Error(messages.errors.join('\n\n'))); + } + if ( + process.env.CI && + (typeof process.env.CI !== 'string' || + process.env.CI.toLowerCase() !== 'false') && + messages.warnings.length + ) { + console.log( + chalk.yellow( + '\nTreating warnings as errors because process.env.CI = true.\n' + + 'Most CI servers set it automatically.\n' + ) + ); + return reject(new Error(messages.warnings.join('\n\n'))); + } + return resolve({ + stats, + previousFileSizes, + warnings: messages.warnings, + }); + }); + }); +} + +function copyPublicFolder() { + fs.copySync(paths.appPublic, paths.appBuild, { + dereference: true, + filter: file => file !== paths.appHtml, + }); +} diff --git a/app/scripts/start.js b/app/scripts/start.js new file mode 100644 index 0000000..4a6ddff --- /dev/null +++ b/app/scripts/start.js @@ -0,0 +1,102 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'development'; +process.env.NODE_ENV = 'development'; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', (err) => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + +const fs = require('fs'); +const chalk = require('chalk'); +const webpack = require('webpack'); +const WebpackDevServer = require('webpack-dev-server'); +const clearConsole = require('react-dev-utils/clearConsole'); +const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles'); +const { + choosePort, + createCompiler, + prepareProxy, + prepareUrls, +} = require('react-dev-utils/WebpackDevServerUtils'); +const openBrowser = require('react-dev-utils/openBrowser'); +const paths = require('../config/paths'); +const config = require('../config/webpack.config.dev'); +const createDevServerConfig = require('../config/webpackDevServer.config'); + +const useYarn = fs.existsSync(paths.yarnLockFile); +const isInteractive = process.stdout.isTTY; + +// Warn and crash if required files are missing +if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) { + process.exit(1); +} + +// Tools like Cloud9 rely on this. +const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; +const HOST = process.env.HOST || '0.0.0.0'; + +if (process.env.HOST) { + console.log( + chalk.cyan( + `Attempting to bind to HOST environment variable: ${chalk.yellow( + chalk.bold(process.env.HOST) + )}` + ) + ); + console.log(`If this was unintentional, check that you haven't mistakenly set it in your shell.`); + console.log(`Learn more here: ${chalk.yellow('http://bit.ly/2mwWSwH')}`); + console.log(); +} + +// We attempt to use the default port but if it is busy, we offer the user to +// run on a different port. `choosePort()` Promise resolves to the next free port. +choosePort(HOST, DEFAULT_PORT) + .then((port) => { + if (port == null) { + // We have not found a port. + return; + } + const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; + const appName = require(paths.appPackageJson).name; + const urls = prepareUrls(protocol, HOST, port); + // Create a webpack compiler that is configured with custom messages. + const compiler = createCompiler({ webpack, config, appName, urls, useYarn }); + // Load proxy config + const proxySetting = require(paths.appPackageJson).proxy; + const proxyConfig = prepareProxy(proxySetting, paths.appPublic); + // Serve webpack assets generated by the compiler over a web sever. + const serverConfig = createDevServerConfig(proxyConfig, urls.lanUrlForConfig); + const devServer = new WebpackDevServer(compiler, serverConfig); + // Launch WebpackDevServer. + devServer.listen(port, HOST, (err) => { + if (err) { + return console.log(err); + } + if (isInteractive) { + clearConsole(); + } + console.log(chalk.cyan('Starting the development server...\n')); + openBrowser(urls.localUrlForBrowser); + }); + + ['SIGINT', 'SIGTERM'].forEach(function (sig) { + process.on(sig, function () { + devServer.close(); + process.exit(); + }); + }); + }) + .catch((err) => { + if (err && err.message) { + console.log(err.message); + } + process.exit(1); + }); diff --git a/app/scripts/test.js b/app/scripts/test.js new file mode 100644 index 0000000..45a643a --- /dev/null +++ b/app/scripts/test.js @@ -0,0 +1,27 @@ +'use strict'; + +// Do this as the first thing so that any code reading it knows the right env. +process.env.BABEL_ENV = 'test'; +process.env.NODE_ENV = 'test'; +process.env.PUBLIC_URL = ''; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', err => { + throw err; +}); + +// Ensure environment variables are read. +require('../config/env'); + +const jest = require('jest'); +let argv = process.argv.slice(2); + +// Watch unless on CI or in coverage mode +if (!process.env.CI && argv.indexOf('--coverage') < 0) { + argv.push('--watch'); +} + + +jest.run(argv); diff --git a/app/src/Api.js b/app/src/Api.js new file mode 100644 index 0000000..92bc5bb --- /dev/null +++ b/app/src/Api.js @@ -0,0 +1,111 @@ +/*global chrome*/ + +const API_HOST = window.location.href.startsWith('http://localhost') + ? 'http://localhost:5001/watch-party-crx/us-central1/widgets' + : // : 'http://localhost:5001/watch-party-crx/us-central1/widgets'; + 'https://us-central1-watch-party-crx.cloudfunctions.net/widgets'; + +export const PLAYER_STATUS = { + PLAYING: 'PLAYING', + PAUSED: 'PAUSED', + NOT_PLAYING: 'NOT_PLAYING', +}; + +export const PLAYER_SOURCE = { + NETFLIX: 'https://www.netflix.', + AMAZON: 'https://www.amazon.', + HULU: 'https://www.hulu.', + DISNEY: 'https://www.disneyplus.', + YOUTUBE: 'https://www.youtube.', + HBONOW: 'https://play.hbonow.', + LOCAL: 'http://localhost', +}; + +export const getPlayerSource = () => { + const HREF = window.location.href; + if (HREF.startsWith(PLAYER_SOURCE.NETFLIX)) return PLAYER_SOURCE.NETFLIX; + if (HREF.startsWith(PLAYER_SOURCE.HULU)) return PLAYER_SOURCE.HULU; + if (HREF.startsWith(PLAYER_SOURCE.AMAZON)) return PLAYER_SOURCE.AMAZON; + if (HREF.startsWith(PLAYER_SOURCE.DISNEY)) return PLAYER_SOURCE.DISNEY; + if (HREF.startsWith(PLAYER_SOURCE.YOUTUBE)) return PLAYER_SOURCE.YOUTUBE; + if (HREF.startsWith(PLAYER_SOURCE.HBONOW)) return PLAYER_SOURCE.HBONOW; + if (HREF.startsWith(PLAYER_SOURCE.LOCAL)) return PLAYER_SOURCE.LOCAL; +}; + +export const getUserUid = async () => + new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { + type: 'GET_AUTH', + }, + (response) => { + const { uid } = response; + window.localStorage.setItem('watchPartyUserUid', uid); + resolve(uid); + } + ); + }); + +export const fetcher = async ({ endpoint, method, ...args }) => { + const url = `${API_HOST}/${endpoint}`; + return new Promise((resolve, reject) => + chrome.runtime.sendMessage( + { + type: 'API_CALL', + url, + method, + ...args, + }, + (response) => { + if (response.status > 299) reject(response); + else resolve({ json: () => response }); + } + ) + ); +}; + +export const CreateRoomApi = async (data) => { + return fetcher({ + endpoint: 'create', + method: 'POST', + body: JSON.stringify(data), + }); +}; + +export const JoinRoomApi = async (data) => { + return fetcher({ + endpoint: 'join', + method: 'POST', + body: JSON.stringify(data), + }); +}; + +export const SendMessageApi = async (data) => { + return fetcher({ + endpoint: 'message', + method: 'POST', + body: JSON.stringify(data), + }); +}; + +export const LeaveRoomApi = async (data) => { + return fetcher({ + endpoint: `leave/${data.roomId}/users/${data.uid}`, + method: 'DELETE', + }); +}; + +export const DeleteRoomApi = async (data) => { + return fetcher({ + endpoint: `delete/${data.roomId}`, + method: 'DELETE', + }); +}; + +export const UpdatePlayerApi = async (data) => { + return fetcher({ + endpoint: 'updatePlayer', + method: 'POST', + body: JSON.stringify(data), + }); +}; diff --git a/app/src/App.js b/app/src/App.js new file mode 100644 index 0000000..793af17 --- /dev/null +++ b/app/src/App.js @@ -0,0 +1,93 @@ +import React, { useState, useEffect } from 'react'; +import Router from 'route-lite'; +import { Fab, createMuiTheme, ThemeProvider, Snackbar } from '@material-ui/core'; +import Create from './Create'; +import Loading from './Loading'; +import firebase from 'firebase/app'; +import 'firebase/firestore'; +import Inactive from './Inactive'; +import { getUserUid } from './Api'; + +const darkTheme = createMuiTheme({ + palette: { + type: 'dark', + primary: { + main: '#a6d4fa', + }, + }, +}); + +const Routes = (props) => ( + + + +); + +const App = () => { + const closedNotification = { open: false, message: '' }; + + const [status, setStatus] = useState({ active: false, message: '' }); + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + const [notification, setNotification] = useState(closedNotification); + + useEffect(() => { + setLoading(true); + getUserUid(); + firebase + .firestore() + .collection('server') + .doc('status') + .onSnapshot((statusSnap) => { + setStatus(statusSnap.data()); + setLoading(false); + }); + }, []); + + // HANDLE VISIBILITY STYLING + const PartyRoutesWrapper = document.getElementById('party-routes-wrapper'); + if (visible) { + document.body.classList.add('visible'); + PartyRoutesWrapper && PartyRoutesWrapper.classList.add('visible'); + } else { + document.body.classList.remove('visible'); + PartyRoutesWrapper && PartyRoutesWrapper.classList.remove('visible'); + } + + // HANDLE FULLSCREEN EVENT + document.onfullscreenchange = () => { + if (document.fullscreenElement) setVisible(false); + }; + + return ( + + setNotification(closedNotification)} + message={notification.message} + resumeHideDuration={500} + autoHideDuration={2000} + /> +
+
+ {loading && } + {status.active ? ( + setNotification({ open: true, message })} + /> + ) : ( + + )} +
+ setVisible(!visible)} className="watch-party-fab"> + + +
+
+ ); +}; + +export default App; diff --git a/app/src/Chat.js b/app/src/Chat.js new file mode 100644 index 0000000..c34842b --- /dev/null +++ b/app/src/Chat.js @@ -0,0 +1,280 @@ +/*global chrome */ +import React, { Component } from 'react'; +import { + Typography, + TextField, + InputAdornment, + IconButton, + ButtonGroup, + Button, +} from '@material-ui/core'; +import Thread from './Thread'; +import Send from '@material-ui/icons/Send'; +import Exit from '@material-ui/icons/ExitToApp'; +import Share from '@material-ui/icons/Share'; +import Delete from '@material-ui/icons/Delete'; +import Gif from '@material-ui/icons/Gif'; +import { Carousel } from '@giphy/react-components'; +import { GiphyFetch } from '@giphy/js-fetch-api'; +import { + SendMessageApi, + LeaveRoomApi, + DeleteRoomApi, + UpdatePlayerApi, + getPlayerSource, + PLAYER_SOURCE, +} from './Api'; +import { goTo } from 'route-lite'; +import Create from './Create'; +import VideoListener from './VideoListener'; + +const giphyFetch = new GiphyFetch('ojJbIMUN6XRMn4mHiFFav3rMCpXUg8sq'); +const alertMessage = { + name: '', + text: 'Functionality may be buggy, support on this site is in beta.', + time: '', + uid: '', +}; +export default class Chat extends Component { + constructor(props) { + super(props); + this.state = { + name: '', + messages: [], + newMessage: '', + admin: null, + gif: false, + }; + this.sendMessage = this.sendMessage.bind(this); + this.sendMessageInvoker = this.sendMessageInvoker.bind(this); + this.endParty = this.endParty.bind(this); + this.leaveParty = this.leaveParty.bind(this); + this.shareParty = this.shareParty.bind(this); + this.syncPlayer = this.syncPlayer.bind(this); + this.videoEventListener = this.videoEventListener.bind(this); + this.mVideoListener = new VideoListener(this.videoEventListener); + this.port = null; + this.getCarousel = this.getCarousel.bind(this); + this.gifClick = this.gifClick.bind(this); + } + componentDidMount() { + // EVENT CAPTURE FOR ENTER TO SEND + window.onkeypress = (ev) => { + if (ev.code === 'Enter') { + if (ev.target === document.getElementById('chat-message-box-input')) { + if (ev.shiftKey === false && this.state.gif === true) ev.preventDefault(); + if (ev.shiftKey === false && this.state.gif === false) { + ev.preventDefault(); + this.sendMessageInvoker(); + } + } + } + }; + + // VIDEO LISTENER START + this.mVideoListener.start(); + + // CHROME FIREBASE FIRESTORE LISTENER + this.port = chrome.runtime.connect({ name: window.localStorage.getItem('watchPartyRoomId') }); + // Initialize listener + this.port.postMessage({ + type: 'INIT', + roomId: window.localStorage.getItem('watchPartyRoomId'), + }); + // Listener + this.port.onMessage.addListener((response) => { + if (response.isError === true) { + this.props.notify('Uh oh. Got lost from the party.'); + goTo(Create, this.props); + return; + } + this.setState(response); + if (response.player) this.mVideoListener.doUpdate(response.player); + const pcc = document.getElementsByClassName('thread-container')[0]; + if (pcc) pcc.scrollTop = pcc.scrollHeight; + }); + + // DONE WITH MOUNTING TASKS + this.props.updateLoading(false); + } + componentWillUnmount() { + this.mVideoListener.end(); + } + videoEventListener(video) { + this.syncPlayer(video); + } + syncPlayer(video) { + const { status, showName, time, source, element, showChanged } = video; + if (element) { + UpdatePlayerApi({ + roomId: window.localStorage.getItem('watchPartyRoomId'), + showChanged, + userName: window.localStorage.getItem('watchPartyUserName'), + player: { + status, + showName, + time, + source, + updatedBy: window.localStorage.getItem('watchPartyUserUid'), + }, + }).catch((err) => { + this.props.updateLoading(false); + this.props.notify(err.message || err.statusText); + console.error('Sync Party API error', err); + }); + } else { + this.mVideoListener.start(); + } + } + endParty() { + this.props.notify('Ending party...'); + this.props.updateLoading(true); + const data = { + roomId: window.localStorage.getItem('watchPartyRoomId'), + }; + DeleteRoomApi(data) + .then((res) => { + this.props.notify('Ended party'); + window.localStorage.removeItem('watchPartyRoomId'); + goTo(Create, this.props); + this.props.updateLoading(false); + }) + .catch((err) => { + this.props.updateLoading(false); + this.props.notify(err.message || err.statusText); + console.error('End Party API error', err); + }); + } + leaveParty() { + this.props.notify('Leaving party...'); + this.props.updateLoading(true); + const data = { + roomId: window.localStorage.getItem('watchPartyRoomId'), + uid: window.localStorage.getItem('watchPartyUserUid'), + }; + LeaveRoomApi(data) + .then((res) => { + this.props.notify('Left party'); + window.localStorage.removeItem('watchPartyRoomId'); + goTo(Create, this.props); + this.props.updateLoading(false); + }) + .catch((err) => { + this.props.updateLoading(false); + this.props.notify(err.message || err.statusText); + console.error('End Party API error', err); + }); + } + shareParty() { + const el = document.createElement('textarea'); + el.value = window.localStorage.getItem('watchPartyRoomId'); + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + this.props.notify('Copied room code'); + } + sendMessage(message) { + const data = { + roomId: window.localStorage.getItem('watchPartyRoomId'), + name: window.localStorage.getItem('watchPartyUserName'), + uid: window.localStorage.getItem('watchPartyUserUid'), + text: message, + time: new Date(), + type: '', + }; + if (this.state.gif === true) data.type = 'giphy'; + SendMessageApi(data).catch((err) => { + this.props.notify(err.message || err.statusText); + console.error('Send Message API error', err); + }); + } + sendMessageInvoker(overrideMessage) { + if (overrideMessage) this.sendMessage(overrideMessage); + else { + const trim = this.state.newMessage.replace(/^\s+|\s+$/g, ''); + if (trim !== '') this.sendMessage(trim); + } + this.setState({ newMessage: '' }); + } + gifClick(iGif, ev) { + ev.preventDefault(); + this.sendMessageInvoker(iGif.images.original.url); + } + getCarousel() { + const trendingFetch = (offset) => giphyFetch.trending({ offset, limit: 5 }); + const searchFetch = (offset) => giphyFetch.search(this.state.newMessage, { offset, limit: 5 }); + return ( + + ); + } + render() { + const { admin, name, messages, newMessage, gif } = this.state; + if (getPlayerSource() === PLAYER_SOURCE.HBONOW && messages[0] !== alertMessage) + messages.unshift(alertMessage); + return ( +
+
+
+ + WATCH PARTY + + + {name} + +
+ + + + {admin && ( + + )} + +
+
+ +
+ this.setState({ newMessage: e.target.value, gif: false })} + InputProps={{ + id: 'chat-message-box-input', + endAdornment: ( + + this.sendMessageInvoker(newMessage)} edge="end"> + + + + ), + startAdornment: ( + + this.setState({ gif: !gif })} edge="start"> + + + + ), + }} + /> + {gif ? this.getCarousel() : null} +
+ ); + } +} diff --git a/app/src/Create.js b/app/src/Create.js new file mode 100644 index 0000000..bbeb0dd --- /dev/null +++ b/app/src/Create.js @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { + Button, + InputAdornment, + TextField, + Typography, + IconButton, + Paper, +} from '@material-ui/core'; +import Save from '@material-ui/icons/Save'; +import { JoinRoomApi, CreateRoomApi, PLAYER_STATUS, getPlayerSource } from './Api'; +import { goTo } from 'route-lite'; +import Chat from './Chat'; + +const saveName = (data) => { + const { name, notify } = data; + window.localStorage.setItem('watchPartyUserName', name); + notify('Saved name'); +}; +const getName = () => window.localStorage.getItem('watchPartyUserName'); + +const saveRoomId = (data) => { + const { rid, notify } = data; + window.localStorage.setItem('watchPartyRoomId', rid); + notify('Saved room'); +}; +const getRoomId = () => window.localStorage.getItem('watchPartyRoomId'); + +const joinRoom = async (data) => { + data.props.updateLoading(true); + data.props.notify('Joining party...'); + const apidata = { + roomId: data.joinName.trim(), + userName: data.name.trim(), + source: getPlayerSource(), + }; + const confirmedRoomId = await JoinRoomApi(apidata) + .then((res) => res.json()) + .then((res) => res.roomId) + .catch((err) => { + data.props.updateLoading(false); + data.props.notify(err.message || err.statusText); + console.error('Join Room API error', err); + }); + if (confirmedRoomId && confirmedRoomId === apidata.roomId) { + saveName({ + name: apidata.userName, + notify: data.props.notify, + }); + saveRoomId({ + rid: confirmedRoomId, + notify: data.props.notify, + }); + data.props.notify('Joined party'); + goTo(Chat, data.props); + } +}; + +const createRoom = async (data) => { + data.props.updateLoading(true); + data.props.notify('Starting party...'); + + const apidata = { + roomName: data.createName, + userName: data.name, + player: { + source: getPlayerSource(), + status: PLAYER_STATUS.PAUSED, + time: 0, + url: '', + }, + }; + + CreateRoomApi(apidata) + .then((res) => res.json()) + .then((res) => { + if (res.status <= 299) { + saveName({ + name: data.name, + notify: data.props.notify, + }); + saveRoomId({ + rid: res.roomId, + notify: data.props.notify, + }); + data.props.notify('Started party'); + goTo(Chat, data.props); + } else { + data.props.updateLoading(false); + data.props.notify(res.message || res.statusText); + } + }) + .catch((err) => { + data.props.updateLoading(false); + data.props.notify(err.message || err.statusText); + console.error('Create Room API error', err); + }); +}; + +const Create = (props) => { + const [name, setName] = useState(getName() || ''); + const [createName, setCreateName] = useState(''); + const [joinName, setJoinName] = useState(getRoomId() || ''); + const { notify } = props; + + return ( +
+ + WATCH PARTY + +
+ (e.target.value.length <= 24 ? setName(e.target.value) : null)} + className="name-field" + label="Name" + variant="filled" + helperText={name.length > 0 ? `${name.length}/24` : null} + InputProps={{ + endAdornment: ( + + saveName({ name, notify })}> + + + + ), + }} + /> + + + CREATE PARTY + + (e.target.value.length <= 24 ? setCreateName(e.target.value) : null)} + helperText={createName.length > 0 ? `${createName.length}/24` : null} + label="Party Name" + /> + + + + + JOIN PARTY + + setJoinName(e.target.value)} + label="Party Code" + helperText="Ask your party leader for this code" + /> + + +
+
+ ); +}; + +export default Create; diff --git a/app/src/Inactive.js b/app/src/Inactive.js new file mode 100644 index 0000000..ab81e9a --- /dev/null +++ b/app/src/Inactive.js @@ -0,0 +1,13 @@ +import React from 'react'; +import { Typography } from '@material-ui/core'; +import { Warning } from '@material-ui/icons'; + +const Inactive = (props) => ( +
+ + PARTY IS PAUSED FOR A LITTLE CLEANUP + {props.status.message} +
+); + +export default Inactive; diff --git a/app/src/Loading.js b/app/src/Loading.js new file mode 100644 index 0000000..e377817 --- /dev/null +++ b/app/src/Loading.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { CircularProgress } from '@material-ui/core'; + +const Loading = () => ( +
+ +
+); + +export default Loading; diff --git a/app/src/Thread.css b/app/src/Thread.css new file mode 100644 index 0000000..102db89 --- /dev/null +++ b/app/src/Thread.css @@ -0,0 +1,95 @@ +.thread-item { + width: 100%; + white-space: pre-wrap; + margin-top: 25px !important; + font-size: 12px !important; + position: relative; +} +.thread-item.no-space { + margin-top: 0 !important; +} +.system-message { + display: block; + text-align: center; + width: 100%; + text-align: center; + font-size: 12px; + margin-top: 7.5px !important; + color: rgba(147, 147, 147, 0.8) !important; +} +.system-message.alert { + background-color: black; + border-radius: 1rem; + color: white !important; + padding: 5px 10px; + position: sticky; + top: 2px; + z-index: 1; +} +.my-message { + display: block; + width: fit-content; + text-align: right; + margin-left: auto !important; + margin-right: 5px !important; + margin-top: 5px !important; + padding: 2.5px 10px !important; + border-radius: 13px; + max-width: 60%; + word-wrap: break-word; + background-color: rgb(49, 49, 49); +} +img.my-message { + margin-right: 0 !important; + padding-right: 0 !important; + border-radius: 1rem; + max-width: 60%; + background: none; + border-radius: 2rem; +} +.party-message { + font-size: 1rem !important; + display: block; + width: fit-content; + max-width: 60%; + word-wrap: break-word; + margin-top: 5px !important; + padding: 2.5px 10px !important; + border-radius: 13px; + background-color: rgb(88, 88, 88); +} +img.party-message { + border-radius: 1rem; + max-width: 60%; + background: none; + border-radius: 2rem; + margin-left: 0 !important; + padding-left: 0 !important; +} +.thread-item .name { + color: rgb(149, 149, 149); + font-size: 10px !important; +} +.time { + top: 0; + color: #3a3a3a; + position: absolute; + opacity: 0; + transition: all ease-in-out 0.3s; +} +.party-message ~ .time { + right: 0; +} +.party-message:hover ~ .time { + opacity: 1; +} +.my-message ~ .time { + left: 0; +} +.my-message:hover ~ .time { + opacity: 1; +} +body.hulu .my-message, +body.hulu .party-message { + font-size: 1.4rem !important; +} diff --git a/app/src/Thread.js b/app/src/Thread.js new file mode 100644 index 0000000..7a5f77b --- /dev/null +++ b/app/src/Thread.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { Typography } from '@material-ui/core'; +import './Thread.css'; +import firebase from 'firebase/app'; +import 'firebase/firestore'; + +const Thread = (props) => + props.messages.map((m, midx) => { + const userUid = window.localStorage.getItem('watchPartyUserUid'); + if (m.uid === '') { + if (m.name === '') { + return ( + + {m.text} + + ); + } + return ( + + {m.text} + + ); + } else if (m.uid === userUid) { + return ( +
0 ? (props.messages[midx - 1].uid !== m.uid ? '' : 'no-space') : '' + }`} + key={m.id} + > + {m.type === 'giphy' ? ( + giphy gif + ) : ( + + {m.text} + + )} + {new Date(m.time.seconds * 1000).toLocaleTimeString()} +
+ ); + } else { + return ( +
0 ? (props.messages[midx - 1].uid !== m.uid ? '' : 'no-space') : '' + }`} + key={m.id} + > + + {midx > 0 ? (props.messages[midx - 1].uid !== m.uid ? m.name : '') : m.name} + + {m.type === 'giphy' ? ( + giphy gif + ) : ( + + {m.text} + + )} + {new Date(m.time.seconds * 1000).toLocaleTimeString()} +
+ ); + } + }); + +export default Thread; diff --git a/app/src/VideoListener.js b/app/src/VideoListener.js new file mode 100644 index 0000000..ff250cd --- /dev/null +++ b/app/src/VideoListener.js @@ -0,0 +1,304 @@ +import { PLAYER_STATUS, getPlayerSource, PLAYER_SOURCE } from './Api'; + +const templateListener = (video) => console.log('MISSING VIDEO LISTENER'); + +export default class VideoListener { + constructor(listener = templateListener) { + this.video = { + status: PLAYER_STATUS.NOT_PLAYING, + time: 0, + showName: '', + source: '', + element: null, + }; + this.isUpdating = false; + this.listener = listener; + this.updateStatus = this.updateStatus.bind(this); + this.firstTimeSync = this.firstTimeSync.bind(this); + this.doUpdate = this.doUpdate.bind(this); + this.start = this.start.bind(this); + this.onpause = this.onpause.bind(this); + this.onplay = this.onplay.bind(this); + this.shouldPushSync = this.shouldPushSync.bind(this); + } + shouldPushSync() { + return ( + this.isUpdating === false && + this.server !== null && + this.server !== undefined && + this.video.element !== null && + this.video.element !== undefined && + this.video.status !== PLAYER_STATUS.NOT_PLAYING && + this.video.showName !== '' && + this.video.showName !== null && + (this.video.status !== this.server.status || + Math.abs(this.video.element.currentTime - this.server.time) > 1.5) + ); + } + updateStatus(newStatus) { + this.video = Object.assign({}, this.video, newStatus); + if (this.shouldPushSync()) { + this.listener({ + ...this.video, + showChanged: this.video.showName !== this.server.showName, + }); + } + } + async doUpdate(serverPlayer) { + this.server = serverPlayer; + if ( + this.isUpdating === false && + serverPlayer.showName === this.video.showName && + serverPlayer.updatedBy !== window.localStorage.getItem('watchPartyUserUid') + ) { + this.isUpdating = true; + if (this.video.element) { + // REMOVE LISTENER BEFORE SYNCING + this.video.element.removeEventListener('playing', this.onplay); + this.video.element.removeEventListener('pause', this.onpause); + await this.timeout(250); + // SEEK + if (Math.abs(serverPlayer.time - this.video.element.currentTime) > 1.5) { + switch (serverPlayer.source) { + case PLAYER_SOURCE.NETFLIX: + window.postMessage({ type: 'SEEK', time: serverPlayer.time * 1000 }, '*'); + break; + case PLAYER_SOURCE.AMAZON: + if (this.video.status === PLAYER_STATUS.PAUSED) + await this.video.element + .play() + .catch((err) => console.error('WatchParty erred when syncing time.')); + this.video.element.currentTime = serverPlayer.time; + await this.timeout(500); + break; + case PLAYER_SOURCE.DISNEY: + this.video.element.currentTime = serverPlayer.time; + await this.timeout(500); + break; + case PLAYER_SOURCE.YOUTUBE: + this.video.element.currentTime = serverPlayer.time; + break; + case PLAYER_SOURCE.HULU: + window.postMessage({ type: 'SEEK', time: serverPlayer.time }, '*'); + break; + default: + break; + } + } + // TOGGLE PLAYER STATUS + switch (serverPlayer.status) { + case PLAYER_STATUS.PAUSED: + if ( + PLAYER_SOURCE.HULU === this.video.source || + PLAYER_SOURCE.NETFLIX === this.video.source + ) + window.postMessage({ type: 'PAUSE', time: serverPlayer.time }, '*'); + else await this.video.element.pause(); + break; + case PLAYER_STATUS.PLAYING: + if ( + PLAYER_SOURCE.HULU === this.video.source || + PLAYER_SOURCE.NETFLIX === this.video.source + ) + window.postMessage({ type: 'PLAY', time: serverPlayer.time }, '*'); + else + await this.video.element + .play() + .catch((err) => console.error('WatchParty erred when syncing play.')); + break; + default: + break; + } + await this.timeout(500); + // RE-ADD LISTENERS FOR FUTURE SYNC + this.video.element.addEventListener('playing', this.onplay); + this.video.element.addEventListener('pause', this.onpause); + } + this.isUpdating = false; + } + } + timeout(n) { + return new Promise((resolve, reject) => { + setTimeout(resolve, n); + }); + } + async getShowName() { + switch (this.video.source) { + case PLAYER_SOURCE.NETFLIX: + const titleContainerParent = document.querySelector( + '.PlayerControls--control-element.text-control.video-title' + ); + if (titleContainerParent) { + const titleContainer = titleContainerParent.firstChild; + const nshow = titleContainer.querySelector('h4') + ? titleContainer.querySelector('h4').innerHTML + : titleContainer.innerHTML; + let nepisode = ''; + titleContainer.querySelectorAll('span').forEach((e) => { + if (e.innerHTML) nepisode += e.innerHTML + ' '; + }); + return nshow === '' && nepisode === '' ? null : nshow + '\n' + nepisode; + } + break; + case PLAYER_SOURCE.AMAZON: + const ashow = document.querySelector('.f3w9jrr.ff48h02').firstChild.innerHTML; + const aepisode = document.querySelector('.f3w9jrr.ff48h02').lastChild.innerHTML; + return ashow === '' && aepisode === '' ? null : ashow + '\n' + aepisode; + case PLAYER_SOURCE.HULU: + const hshow = document.querySelector('.metadata-area__second-line').innerHTML; + const hepisode = document.querySelector('.metadata-area__third-line').innerHTML; + return hshow === '' && hepisode === '' ? null : hshow + '\n' + hepisode; + case PLAYER_SOURCE.DISNEY: + this.video.element.parentElement.click(); + await this.timeout(200); + const dshow = document.querySelector('.title-field') + ? document.querySelector('.title-field').innerHTML + : ''; + const depisode = document.querySelector('.subtitle-field') + ? document.querySelector('.subtitle-field').innerHTML + : ''; + return dshow === '' && depisode === '' ? null : dshow + '\n' + depisode; + case PLAYER_SOURCE.YOUTUBE: + const yshow = document.querySelector('h1.title').innerText; + const yepisode = document.querySelector('yt-formatted-string.super-title').innerText; + return yshow === '' && yepisode === '' ? null : yshow + '\n' + yepisode; + default: + break; + } + } + async onplay(ev) { + this.updateStatus({ + status: PLAYER_STATUS.PLAYING, + time: this.video.element.currentTime, + showName: await this.getShowName(), + }); + } + async onpause(ev) { + this.updateStatus({ + status: PLAYER_STATUS.PAUSED, + time: this.video.element.currentTime, + showName: await this.getShowName(), + }); + } + async firstTimeSync(videoElement) { + this.updateStatus({ + element: videoElement, + }); + this.updateStatus({ + showName: await this.getShowName(), + }); + // if (videoElement.currentTime > 0 && !videoElement.paused) { + // this.updateStatus({ + // time: videoElement.currentTime, + // status: PLAYER_STATUS.PLAYING, + // }); + // } + videoElement.addEventListener('playing', this.onplay); + videoElement.addEventListener('pause', this.onpause); + } + async start() { + this.updateStatus({ source: getPlayerSource(), showName: await this.getShowName() }); + switch (this.video.source) { + case PLAYER_SOURCE.NETFLIX: + const seekInject = `window.seekScriptLoaded=!0;var getVideoPlayer=function(){var e=window.netflix.appContext.state.playerApp.getAPI().videoPlayer,t=e.getAllPlayerSessionIds()[0];return e.getVideoPlayerBySessionId(t)},seekInteraction=function(e){if(e.source==window){if(e.data.type&&"SEEK"==e.data.type){getVideoPlayer().pause();getVideoPlayer().seek(e.data.time);}if(e.data.type&&e.data.type==="PAUSE"){getVideoPlayer().pause();}if(e.data.type&&e.data.type==="PLAY"){getVideoPlayer().play();}e.data.type&&"teardown"==e.data.type&&(window.removeEventListener("message",seekInteraction,!1),window.seekScriptLoaded=!1)}};window.addEventListener("message",seekInteraction,!1);`; + const seekInjectScript = document.createElement('script'); + seekInjectScript.text = seekInject; + document.head.appendChild(seekInjectScript); + if (document.getElementsByClassName('AkiraPlayer')[0]) { + const videoElement = document + .getElementsByClassName('AkiraPlayer')[0] + .getElementsByTagName('video')[0]; + if (videoElement) { + this.firstTimeSync(videoElement); + } + } else { + document.addEventListener('DOMNodeInserted', (e) => { + if (e.target) { + if (e.target.classList) { + if ( + e.target.classList.contains('nfp') && + e.target.classList.contains('AkiraPlayer') + ) { + e.target.addEventListener('DOMNodeInserted', (ev) => { + if (ev.target.children[0].id) { + this.firstTimeSync(ev.target.children[0].firstChild); + } + }); + } + } + } + }); + } + break; + case PLAYER_SOURCE.HULU: + const huluSeekInject = `window.seekScriptLoaded=!0;var getVideoPlayer=function(){var e=document.getElementsByClassName('content-video-player')[0].__HuluDashPlayer__;return e;},seekInteraction=function(e){if(e.source==window){if(e.data.type&&"SEEK"==e.data.type){getVideoPlayer().seek(e.data.time,e.data.time);}if(e.data.type&&e.data.type==="PAUSE"){getVideoPlayer().pause();}if(e.data.type&&e.data.type==="PLAY"){getVideoPlayer().play();}e.data.type&&"teardown"==e.data.type&&(window.removeEventListener("message",seekInteraction,!1),window.seekScriptLoaded=!1)}};window.addEventListener("message",seekInteraction,!1);`; + const huluSeekInjectScript = document.createElement('script'); + huluSeekInjectScript.text = huluSeekInject; + document.head.appendChild(huluSeekInjectScript); + const videoElement = document.getElementsByClassName('content-video-player')[0]; + if (videoElement) { + this.firstTimeSync(videoElement); + } + break; + case PLAYER_SOURCE.AMAZON: + if (document.getElementsByClassName('webPlayerSDKContainer')[0]) { + const videoElement = document + .getElementsByClassName('webPlayerSDKContainer')[0] + .getElementsByTagName('video')[0]; + if (videoElement) { + this.firstTimeSync(videoElement); + } + } + break; + case PLAYER_SOURCE.DISNEY: + const disneyVideoElement = document.querySelector('video.btm-media-client-element'); + if (disneyVideoElement) { + this.firstTimeSync(disneyVideoElement); + } else { + setInterval( + this.video.element + ? null + : () => { + var dve = document.querySelector('video.btm-media-client-element'); + if (dve !== null && (this.video.element !== dve || this.video.element === null)) { + this.firstTimeSync(dve); + } + }, + 1000 + ); + } + break; + case PLAYER_SOURCE.YOUTUBE: + const youtubeVideoElement = document.querySelector('video.video-stream.html5-main-video'); + if (youtubeVideoElement) this.firstTimeSync(youtubeVideoElement); + else { + setInterval( + this.video.element + ? null + : () => { + var yve = document.querySelector('video.video-stream.html5-main-video'); + if (yve !== null && (this.video.element !== yve || this.video.element === null)) { + this.firstTimeSync(yve); + } + }, + 1000 + ); + } + break; + case PLAYER_SOURCE.LOCAL: + const localVideoElement = document.getElementsByTagName('video')[0]; + if (localVideoElement) { + this.firstTimeSync(localVideoElement); + } + break; + default: + break; + } + } + end() { + if (this.video.element) { + this.video.element.removeEventListener('playing', this.onplay); + this.video.element.removeEventListener('pause', this.onpause); + } + } +} diff --git a/app/src/index.css b/app/src/index.css new file mode 100644 index 0000000..ea36fe5 --- /dev/null +++ b/app/src/index.css @@ -0,0 +1,302 @@ +:root { + --party-width: 350px; + --neg-party-width: -350px; + --body-width: calc(100% - var(--party-width)); +} +body { + margin: 0 !important; + background-color: black; + transition: max-width ease-in-out 0.35s; +} +body.visible { + width: var(--body-width); +} +body.visible .watch-party-fab { + right: var(--party-width) !important; + transform: translateX(50%); + top: 1.5rem; +} +body.visible .party-notification-container { + display: block !important; +} + +body.netflix.visible .AkiraPlayer, /* NETFLIX */ +body.hulu.visible .Player__container,/* HULU */ +body.amazon-prime.visible #dv-web-player.dv-player-fullscreen, /* AMAZON PRIME */ +body.disney.visible #hudson-wrapper, /* DISNEY */ +body.youtube.visible #content.ytd-app, /* YOUTUBE */ +body.youtube.visible #masthead-container, /* YOUTUBE HEADER */ +body.hbo.visible video.default { + max-width: var(--body-width) !important; +} +/* HULU MINIMIZED VIDEO */ +body.hulu.visible #__player__ .Player__container.Player__container--modal { + right: calc(var(--party-width) + 2rem) !important; + z-index: 99999 !important; +} +/* HULU, DISNEY & AMAZON PRIME FIX */ +body.disney #party-routes-wrapper, +body.hulu #party-routes-wrapper, +body.amazon-prime #party-routes-wrapper { + width: var(--party-width) !important; + height: 100% !important; +} +/* DISNEY FULL SCREEN VIDEO FIX */ +body.disney #hudson-wrapper { + left: 0; +} +/* YOUTUBE VIDEO SIZING FIX */ +body.youtube.visible video { + max-width: 100%; + height: auto !important; +} +body.youtube.visible .ytp-chrome-bottom { + max-width: 100% !important; + width: calc(100% - 24px) !important; +} +body.youtube.visible .ytp-chapter-hover-container { + max-width: 100% !important; +} +body.youtube.visible .ytp-tooltip.ytp-bottom.ytp-preview { + bottom: 100px !important; + top: unset !important; +} +body.youtube.visible td-two-column-browse-results-renderer.grid:not(.grid-disabled) { + max-width: calc(100% - 48px); +} +/* AMAZON STYLING FIX */ +body.amazon-prime input, +body.amazon-prime textarea { + background-color: inherit !important; + border: initial !important; + box-shadow: none !important; +} +body.amazon-prime input.MuiInputBase-input { + height: 1.1876em !important; + padding: 6px 0 7px !important; +} +body.amazon-prime input.MuiFilledInput-inputAdornedEnd { + padding-right: 0 !important; +} +body.amazon-prime input.MuiFilledInput-input { + padding: 27px 12px 10px !important; +} +body.amazon-prime input:focus, +body.amazon-prime textarea:focus { + border: none !important; + box-shadow: none !important; +} + +@keyframes backgroundpulse { + 0% { + background-color: black; + color: red; + } + 50% { + background-color: red; + color: black; + } + 100% { + background-color: black; + color: red; + } +} +#party-loading { + position: absolute; + width: 100%; + height: 100%; + z-index: 9999; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + top: 0; + left: 0; + align-items: center; + justify-content: center; +} +#party-loading.inactive { + flex-direction: column; + text-align: center; + animation-duration: 4s; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-name: backgroundpulse; +} +#party-routes-wrapper { + margin: 0; + position: fixed; + background-color: rgb(27, 27, 27); + bottom: 0; + width: calc(var(--party-width) - 2rem); + right: var(--neg-party-width); + transition: all ease-in-out 0.3s; + height: calc(100% - 2rem); + padding: 1rem; + color: white; + display: flex; + flex-direction: column; + z-index: 9999; +} +#party-routes-wrapper.visible { + right: 0; +} +.party-notification-container { + z-index: 99999 !important; + display: none !important; + bottom: 0.8rem !important; + right: 1rem !important; + width: calc(var(--party-width) - 2rem) !important; +} +.party-notification-container .MuiPaper-root { + border-radius: 26px !important; + color: white; + background-color: black; +} +@keyframes showbutton { + 0% { + right: 1rem; + } + 50% { + transform: scale(5) translateX(-100%) translateY(-100%); + } + 100% { + right: -1rem; + transform: scale(1); + } +} +.watch-party-fab { + z-index: 9999; + position: fixed !important; + bottom: 1rem !important; + right: -2rem !important; + border: 1px solid white; + background-color: black !important; + /* background-image: url('/android-icon-192x192.png'); */ + background-image: url('chrome-extension://__MSG_@@extension_id__/android-icon-192x192.png'); + background-position: center; + background-repeat: no-repeat; + background-size: 2rem; + animation-name: showbutton; + animation-iteration-count: 1; + animation-duration: 2s; + transition: all ease-in-out 0.3s !important; +} +.watch-party-fab:hover { + right: 1rem !important; +} +.watch-party-fab .fab-logo { + max-width: 75%; + max-height: 75%; +} + +.party-create-container { + display: flex; + flex-direction: column; + height: 100%; +} +.party-create-container .brand-title { + padding-top: 1.4rem !important; + padding-left: 3.5rem !important; +} +.party-create-container .name-field { + margin-bottom: 2rem !important; +} +.party-create-container .join-card { + padding: 1rem !important; + margin-bottom: 2rem; +} +.party-create-container .join-card .card-field { + margin-bottom: 1rem !important; +} +.party-create-container .party-create-inputs { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding: 2rem; +} + +.party-chat-container { + display: flex; + max-height: 100%; + flex-direction: column; + flex: 1; +} + +.thread-container { + max-height: 100%; + flex: 1; + overflow-y: auto; + margin-bottom: 1rem; + margin-top: 1rem; +} +.room-title { + text-overflow: ellipsis !important; + word-break: break-word !important; + overflow: hidden !important; + padding-right: 1rem !important; +} +.thread-container::-webkit-scrollbar { + width: 5px; +} +.thread-container::-webkit-scrollbar-track { + background-color: rgb(90, 90, 90); + border-radius: 1rem; +} +.thread-container::-webkit-scrollbar-thumb { + background-color: rgb(118, 182, 255); + border-radius: 13px; +} +textarea::-webkit-scrollbar { + display: none; +} +.chat-message-box > div { + border-radius: 26px; +} +.party-chat-header { + display: flex; + align-items: flex-start; + padding-left: 2.25rem; +} +.party-chat-header-text { + flex: 1; +} +.party-controls { + margin-top: 2rem; +} +.party-controls .MuiButton-startIcon { + margin-right: 0px !important; +} +.party-control-button-text { + display: none; +} +.party-controls button:hover .party-control-button-text { + display: initial; +} +.party-controls button:hover .MuiButton-startIcon { + margin-right: 8px !important; +} +.giphy-carousel { + margin-top: 1rem; + min-height: fit-content; +} +.giphy-carousel img { + border-radius: 1rem; + transform: scale(0.85); + transition: all ease-in-out 0.2s; +} +.giphy-carousel img:hover { + transform: scale(0.95); +} + +.giphy-carousel::-webkit-scrollbar { + width: 5px; + height: 5px; +} +.giphy-carousel::-webkit-scrollbar-track { + background-color: rgb(90, 90, 90); + border-radius: 1rem; +} +.giphy-carousel::-webkit-scrollbar-thumb { + background-color: rgb(118, 182, 255); + border-radius: 13px; +} diff --git a/app/src/index.js b/app/src/index.js new file mode 100644 index 0000000..70335f7 --- /dev/null +++ b/app/src/index.js @@ -0,0 +1,70 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import firebase from 'firebase/app'; +import App from './App'; +import './index.css'; +import { PLAYER_SOURCE, getPlayerSource } from './Api'; + +const firebaseConfig = { + apiKey: 'AIzaSyCJLuYpzXLDRmu2ttzFuSGmYcpFxqToWFU', + authDomain: 'watch-party-crx.firebaseapp.com', + databaseURL: 'https://watch-party-crx.firebaseio.com', + projectId: 'watch-party-crx', + storageBucket: 'watch-party-crx.appspot.com', + messagingSenderId: '712242707190', + appId: '1:712242707190:web:65fa7f772e30da364b0ad7', + measurementId: 'G-EZS4PEVG4T', +}; + +firebase.initializeApp(firebaseConfig); + +const PartyWrapper = document.createElement('div'); +PartyWrapper.id = 'party_wrapper'; +document.body.appendChild(PartyWrapper); + +switch (getPlayerSource()) { + case PLAYER_SOURCE.NETFLIX: + document.body.classList.add('netflix'); + break; + case PLAYER_SOURCE.HULU: + document.addEventListener( + 'keydown', + (ev) => { + if (ev.target.tagName !== 'INPUT' || ev.target.tagName !== 'TEXTAREA') { + ev.stopImmediatePropagation(); + ev.stopPropagation(); + } else { + ev.target.focus(); + } + }, + { capture: true } + ); + document.body.classList.add('hulu'); + const ds = document.createElement('script'); + ds.text = `window.doquery=function(){window.huluPlayer=window.queryObjects(window.HuluMediaPlayer);}`; + document.head.appendChild(ds); + break; + case PLAYER_SOURCE.AMAZON: + document.body.classList.add('amazon-prime'); + break; + case PLAYER_SOURCE.DISNEY: + document.body.classList.add('disney'); + break; + case PLAYER_SOURCE.YOUTUBE: + document.body.classList.add('youtube'); + break; + case PLAYER_SOURCE.HBONOW: + document.body.classList.add('hbo'); + break; + case PLAYER_SOURCE.LOCAL: + break; + default: + break; +} + +ReactDOM.render( + + + , + PartyWrapper +);