From 4e57fa30908f0df0ebf7543a72a3111212626dfb Mon Sep 17 00:00:00 2001 From: Simon Huang Date: Thu, 1 Oct 2020 18:56:51 -0400 Subject: [PATCH] integrated netflix syncing into app --- extension/background.js | 1 - extension/player.js | 2 + .../ReactPlayerFrame.tsx} | 97 +++++----- src/components/Player/SubscriptionFrame.tsx | 170 ++++++++++++++++++ src/components/Player/VideoPlayer.tsx | 45 +++++ src/pages/Room.tsx | 14 +- src/services/utilities.ts | 10 ++ 7 files changed, 277 insertions(+), 62 deletions(-) rename src/components/{VideoPlayer.tsx => Player/ReactPlayerFrame.tsx} (78%) create mode 100644 src/components/Player/SubscriptionFrame.tsx create mode 100644 src/components/Player/VideoPlayer.tsx diff --git a/extension/background.js b/extension/background.js index 283e4ac..a348126 100644 --- a/extension/background.js +++ b/extension/background.js @@ -26,7 +26,6 @@ chrome.webRequest.onHeadersReceived.addListener( header.value += '\n'; } } - //console.log(header.value); headers[index].value = header.value; } diff --git a/extension/player.js b/extension/player.js index bce787d..8e2071c 100644 --- a/extension/player.js +++ b/extension/player.js @@ -4,11 +4,13 @@ const NETFLIX_VID_URL = /https?:\/\/www\.netflix\.com\/watch\/([-a-zA-Z0-9()@:%_ document.addEventListener('click', (e) => { if (!playerReady && window.location.toString().match(NETFLIX_VID_URL)) { let s = document.createElement('script'); + s.setAttribute('id', 'netflix'); s.src = chrome.runtime.getURL('netflix.js'); (document.head || document.documentElement).appendChild(s); playerReady = true; } else if (playerReady && !window.location.toString().match(NETFLIX_VID_URL)) { playerReady = false; window.parent.postMessage('video not ready', '*'); + document.getElementById('netflix').remove(); } }); diff --git a/src/components/VideoPlayer.tsx b/src/components/Player/ReactPlayerFrame.tsx similarity index 78% rename from src/components/VideoPlayer.tsx rename to src/components/Player/ReactPlayerFrame.tsx index c13eecb..70a72c9 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/Player/ReactPlayerFrame.tsx @@ -1,19 +1,18 @@ import React, { useEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player'; -import { db, arrayUnion } from '../services/firebase'; -import { SYNC_MARGIN } from '../services/utilities'; +import { arrayUnion, db } from '../../services/firebase'; +import { SYNC_MARGIN } from '../../services/utilities'; -type VideoPlayerProps = { +type ReactPlayerFrameProps = { ownerId: string; userId: string; roomId: string; + videoUrl: string; }; -const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => { +const ReactPlayerFrame: React.FC = ({ ownerId, userId, roomId, videoUrl }) => { const player = useRef(null); const [playing, setPlaying] = useState(false); - const [videoUrl, setVideoUrl] = useState(''); - const [allowUpdate, setAllowUpdate] = useState(true); // Update database on play (owner only) const onPlay = () => { @@ -55,6 +54,34 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => } }; + // Listen for requests from Firebase (owner only) + useEffect(() => { + if (ownerId === userId) { + const roomRef = db.collection('rooms').doc(roomId); + const stateRef = db.collection('states').doc(roomId); + + // Add a listener to 'rooms' collection, listening for updateState requests + const roomUnsubscribe = roomRef.onSnapshot((docSnapshot) => { + const requests = docSnapshot.data()?.requests; + const req = requests[requests.length - 1]; + + if (!!req && req.type === 'updateState' && req.senderId !== userId) { + const currTime = player.current?.getCurrentTime(); + if (currTime !== undefined) { + stateRef.update({ + time: currTime, + isPlaying: player.current?.props.playing, + }); + } + } + }); + + return () => { + roomUnsubscribe(); + }; + } + }, [ownerId, roomId, userId]); + // Request an update after buffering is finished (member only) const onBufferEnd = () => { if (ownerId !== userId) { @@ -66,13 +93,14 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => } }; - // Subscribe member only listener + // Listen for changes in video state from Firebase (member only) useEffect(() => { if (ownerId !== userId) { const stateRef = db.collection('states').doc(roomId); const roomRef = db.collection('rooms').doc(roomId); + let allowUpdate = true; - // Add a listener to 'states' collection, listening for video state changes + // listen to 'states' collection for video state changes from owner const stateUnsubscribe = stateRef.onSnapshot((docSnapshot) => { const docData = docSnapshot.data(); @@ -83,11 +111,11 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => setPlaying(realPlayState); if (allowUpdate && Math.abs(currTime - realTimeState) > SYNC_MARGIN / 1000 && realPlayState) { - setAllowUpdate(false); + allowUpdate = false; setTimeout(() => { - // throttle update requests - setAllowUpdate(true); - }, 3000); + // Throttle update requests + allowUpdate = true; + }, 5000); player.current?.seekTo(realTimeState); roomRef.update({ @@ -101,51 +129,8 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => stateUnsubscribe(); }; } - }, [ownerId, roomId, userId, allowUpdate]); - - // Subscribe owner only listener - useEffect(() => { - if (ownerId === userId) { - const roomRef = db.collection('rooms').doc(roomId); - const stateRef = db.collection('states').doc(roomId); - - // Add a listener to 'rooms' collection, listening for updateState requests - const roomUnsubscribe = roomRef.onSnapshot((docSnapshot) => { - const requests = docSnapshot.data()?.requests; - const req = requests[requests.length - 1]; - - if (!!req && req.type === 'updateState' && req.senderId !== userId) { - const currTime = player.current?.getCurrentTime(); - if (currTime !== undefined) { - stateRef.update({ - time: currTime, - isPlaying: true, - }); - } - } - }); - - return () => { - roomUnsubscribe(); - }; - } }, [ownerId, roomId, userId]); - // Listen for video URL changes - useEffect(() => { - const playlistRef = db.collection('playlists').doc(roomId); - const playlistUnsubscribe = playlistRef.onSnapshot((docSnapshot) => { - const data = docSnapshot.data(); - if (data !== undefined) { - setVideoUrl(data.url); - } - }); - - return () => { - playlistUnsubscribe(); - }; - }, [roomId]); - return ( = ({ ownerId, userId, roomId }) => ); }; -export default VideoPlayer; +export default ReactPlayerFrame; diff --git a/src/components/Player/SubscriptionFrame.tsx b/src/components/Player/SubscriptionFrame.tsx new file mode 100644 index 0000000..b886b2b --- /dev/null +++ b/src/components/Player/SubscriptionFrame.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { arrayUnion, db } from '../../services/firebase'; +import { SYNC_MARGIN } from '../../services/utilities'; + +type SubscriptionFrameProps = { + roomId: string; + ownerId: string; + userId: string; + videoUrl: string; +}; + +const SubscriptionFrame: React.FC = ({ ownerId, userId, roomId, videoUrl }) => { + const frameRef = useRef(null); + const [playerReady, setPlayerReady] = useState(false); + + // Listen for events from browser extension (owner only) + useEffect(() => { + if (ownerId === userId) { + const handleMessage = (e: MessageEvent) => { + const type = e.data.type; + + // Send play/pause events to Firebase + if (type === 'play' || type === 'pause') { + db.collection('states') + .doc(roomId) + .update({ + isPlaying: type === 'play', + time: e.data.time, + }); + + db.collection('rooms') + .doc(roomId) + .update({ + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: e.data.time, type: type }), + }); + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + } + }, [roomId, ownerId, userId]); + + // Request current state of video from browser extension + const getCurrentStatus = () => { + return new Promise<{ isPlaying: boolean; time: number }>((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = ({ data }) => { + channel.port1.close(); + if (data.error) { + reject(data.error); + } else { + resolve({ isPlaying: data.isPlaying, time: data.time }); + } + }; + frameRef.current?.contentWindow?.postMessage('fetch current status', '*', [channel.port2]); + }); + }; + + // Subscribe listener for updateState requests from Firebase (owner only) + useEffect(() => { + if (ownerId === userId) { + const roomRef = db.collection('rooms').doc(roomId); + const stateRef = db.collection('states').doc(roomId); + + // Add a listener to 'rooms' collection, listening for updateState requests + const roomUnsubscribe = roomRef.onSnapshot(async (docSnapshot) => { + const requests = docSnapshot.data()?.requests; + const req = requests[requests.length - 1]; + + if (!!req && req.type === 'updateState' && req.senderId !== userId) { + const status = await getCurrentStatus(); + stateRef.update({ + time: status.time, + isPlaying: status.isPlaying, + }); + } + }); + + return () => { + roomUnsubscribe(); + }; + } + }, [ownerId, roomId, userId]); + + // Listen for browser extension events (member only) + useEffect(() => { + if (ownerId !== userId) { + const handleMessage = (e: MessageEvent) => { + const event = e.data.toString(); + if (event === 'video ready') { + setPlayerReady(true); + } + if (event === 'video not ready') { + setPlayerReady(false); + } + }; + + // Listen for current video state updates from browser extension + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + } + }, [ownerId, userId, videoUrl]); + + // Listen for changes in video state from Firebase (member only) + useEffect(() => { + if (ownerId !== userId && playerReady) { + const stateRef = db.collection('states').doc(roomId); + const roomRef = db.collection('rooms').doc(roomId); + let allowUpdate = true; + + // Send player seeking event to browser extension + const seekTo = (time: number) => { + frameRef.current?.contentWindow?.postMessage({ type: 'seek', currentTime: time }, '*'); + }; + + // Send player set event to browser extension + const setPlaying = (isPlaying: boolean) => { + frameRef.current?.contentWindow?.postMessage({ type: 'playing', playing: isPlaying }, '*'); + }; + + // listen to 'states' collection for video state changes from owner + const stateUnsubscribe = stateRef.onSnapshot(async (docSnapshot) => { + const actual = docSnapshot.data(); + if (allowUpdate) { + const status = await getCurrentStatus(); + console.log('status:', status); + setPlaying(actual?.isPlaying); + + if (Math.abs(status.time - actual?.time) > SYNC_MARGIN / 1000 && actual?.isPlaying) { + allowUpdate = false; + setTimeout(() => { + // Throttle update requests + allowUpdate = true; + }, 5000); + + seekTo(actual?.time); + roomRef.update({ + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: 0, type: 'updateState' }), + }); + } + } + }); + + return () => { + stateUnsubscribe(); + }; + } + }, [playerReady, roomId, ownerId, userId]); + + return ( + + ); +}; + +export default SubscriptionFrame; diff --git a/src/components/Player/VideoPlayer.tsx b/src/components/Player/VideoPlayer.tsx new file mode 100644 index 0000000..74157a0 --- /dev/null +++ b/src/components/Player/VideoPlayer.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from 'react'; +import { db } from '../../services/firebase'; +import { matchUrl } from '../../services/utilities'; +import ReactPlayerFrame from './ReactPlayerFrame'; +import SubscriptionFrame from './SubscriptionFrame'; + +type VideoPlayerProps = { + ownerId: string; + userId: string; + roomId: string; +}; + +const VideoPlayer: React.FC = ({ roomId, ownerId, userId }) => { + const [videoUrl, setVideoUrl] = useState(''); + + // Listen for video URL changes + useEffect(() => { + const playlistRef = db.collection('playlists').doc(roomId); + const playlistUnsubscribe = playlistRef.onSnapshot((docSnapshot) => { + const data = docSnapshot.data(); + if (data !== undefined) { + let url: string = data.url; + if (matchUrl(url) === 'NETFLIX') { + // Remove query from url + if (url.indexOf('?') > 0) { + url = url.substring(0, url.indexOf('?')); + } + } + setVideoUrl(url); + } + }); + + return () => { + playlistUnsubscribe(); + }; + }, [roomId]); + + return matchUrl(videoUrl) === 'NETFLIX' ? ( + + ) : ( + + ); +}; + +export default VideoPlayer; diff --git a/src/pages/Room.tsx b/src/pages/Room.tsx index 078bd0b..6b4dff8 100644 --- a/src/pages/Room.tsx +++ b/src/pages/Room.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps, useHistory } from 'react-router'; import Frame from '../components/Frame'; import RoomHeader from '../components/RoomHeader'; -import VideoPlayer from '../components/VideoPlayer'; +import VideoPlayer from '../components/Player/VideoPlayer'; import { auth, db, decrement, increment, rtdb } from '../services/firebase'; import { generateAnonName } from '../services/utilities'; import './Room.css'; @@ -84,10 +84,14 @@ const Room: React.FC> = ({ match }) => { }); // Manage user count - rtdb.ref('.info/connected').on('value', (snapshot) => { + rtdb.ref('.info/connected').on('value', async (snapshot) => { if (snapshot.val() === true) { - roomRef.update({ userCount: increment }); - roomRef.onDisconnect().update({ userCount: decrement }); + try { + await roomRef.update({ userCount: increment }); + await roomRef.onDisconnect().update({ userCount: decrement }); + } catch (err) { + console.log(err); + } } }); @@ -129,7 +133,7 @@ const Room: React.FC> = ({ match }) => { - + diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 3971430..9f42b86 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -88,6 +88,16 @@ const animals = [ // MATCH_URL_VIDYARD: /vidyard.com\/(?:watch\/)?([a-zA-Z0-9-]+)/, // }; +const MATCH_URL_NETFLIX = /https?:\/\/(www\.)?netflix\.com\/watch\/([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + +export const matchUrl = (url: string) => { + if (!!url.match(MATCH_URL_NETFLIX)) { + return 'NETFLIX'; + } else { + return 'DEFAULT'; + } +}; + export const generateAnonName = (): string => { const adj: string = adjectives[Math.floor(Math.random() * 40)]; const animal: string = animals[Math.floor(Math.random() * 30)];