improved synchronization

pull/3/head
Simon Huang 6 years ago
parent eb281313ac
commit 0e6cefbda1

@ -2,30 +2,46 @@
// Firestore
const collection = {
rooms: {
rooms: [
{
roomId: {
createdAt: 'timestamp',
ownerId: 'userId',
messages: {
messages: [
{
messageId: {
createdAt: 'timestamp',
content: 'Message content',
senderId: 'userId',
},
},
playlist: {
],
playlist: [
{
videoId: {
createdAt: 'timestamp',
url: 'https://youtube.com',
},
},
],
states: [
{
stateId: {
time: 'timestamp',
isPlaying: true,
},
},
],
},
},
users: {
],
users: [
{
userId: {
name: 'Anonymous',
},
},
],
};
// Realtime Database - needed for tracking user presence

@ -13,12 +13,18 @@ ion-textarea {
align-items: center;
justify-content: center;
display: flex;
padding-left: 0;
}
.send-button {
width: 100%;
}
.message-col {
white-space: normal;
padding: 10px;
}
.message-input {
height: 100%;
border: 1px solid #999;

@ -19,7 +19,7 @@ const Chat: React.FC<ChatboxProps> = ({ roomId, userId }) => {
createdAt: timestamp,
senderId: userId,
content: message,
type: 'user',
type: 'chat',
});
}

@ -2,7 +2,6 @@ ion-col {
padding: 6px;
border-radius: 5px;
margin-bottom: 4px;
white-space: pre-wrap;
max-width: 70%;
}

@ -1,5 +1,5 @@
import { IonCol, IonGrid, IonRow, IonContent } from '@ionic/react';
import React, { useEffect, useState } from 'react';
import { IonCol, IonContent, IonGrid, IonRow } from '@ionic/react';
import React, { useEffect, useRef, useState } from 'react';
import { currTime, db } from '../services/firebase';
import './Messages.css';
@ -22,6 +22,7 @@ const Messages: React.FC<MessagesProps> = ({ roomId, userId }) => {
]); // All received messages
const [prevMessages, setPrevMessages] = useState<Message[]>([]); // Track previous messages
const [newMessages, setNewMessages] = useState<Message[]>([]); // Newly retrieved messages
const contentRef = useRef<HTMLIonContentElement>(null);
// Only update array containing all messages ('chats') when there are new messages
useEffect(() => {
@ -45,15 +46,17 @@ const Messages: React.FC<MessagesProps> = ({ roomId, userId }) => {
for (const change of changes) {
if (change.type === 'added') {
const data = change.doc.data();
const user = await db.collection('users').doc(data?.senderId).get();
const user = await db.collection('users').doc(data.senderId).get();
if (data.type !== 'updateState') {
newMsgs.push({
id: change.doc.id,
senderId: data?.senderId,
senderId: data.senderId,
sender: user.data()?.name,
content: data?.content,
content: data.content,
});
}
}
}
if (newMsgs.length !== 0) {
setNewMessages(newMsgs);
@ -67,7 +70,7 @@ const Messages: React.FC<MessagesProps> = ({ roomId, userId }) => {
// Always scroll to most recent chat message (bottom)
useEffect(() => {
let content = document.querySelector('ion-content');
let content = contentRef.current;
// Set timeout because DOM doesn't update immediately after 'chats' state is updated
setTimeout(() => {
@ -76,7 +79,7 @@ const Messages: React.FC<MessagesProps> = ({ roomId, userId }) => {
}, [chats]);
return (
<IonContent class="message-card">
<IonContent class="message-card" ref={contentRef}>
<IonGrid class="message-grid">
{chats.map((chat) => {
return (

@ -4,14 +4,14 @@ import React, { useState } from 'react';
import { db, timestamp } from '../services/firebase';
import './RoomHeader.css';
type VideoInputProps = {
type RoomHeaderProps = {
roomId: string;
userId: string;
ownerId: string;
videoId: string;
};
const VideoInput: React.FC<VideoInputProps> = ({ roomId, userId, ownerId, videoId }) => {
const RoomHeader: React.FC<RoomHeaderProps> = ({ roomId, userId, ownerId, videoId }) => {
const [videoUrl, setVideoUrl] = useState('');
const onSubmit = async () => {
@ -25,7 +25,7 @@ const VideoInput: React.FC<VideoInputProps> = ({ roomId, userId, ownerId, videoI
createdAt: timestamp,
senderId: userId,
content: 'changed the video',
type: 'system',
type: 'change',
});
}
@ -61,4 +61,4 @@ const VideoInput: React.FC<VideoInputProps> = ({ roomId, userId, ownerId, videoI
);
};
export default VideoInput;
export default RoomHeader;

@ -1,92 +1,141 @@
import React, { useEffect, useRef, useState } from 'react';
import ReactPlayer from 'react-player';
import { db, timestamp } from '../services/firebase';
import { secondsToTimestamp, timestampToSeconds } from '../services/utilities';
import { secondsToTimestamp, SYNC_MARGIN } from '../services/utilities';
type VideoPlayerProps = {
ownerId: string;
userId: string;
roomId: string;
stateId: string;
};
const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId }) => {
const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId, stateId }) => {
const player = useRef<ReactPlayer>(null);
const [playing, setPlaying] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
// Send video playing message to database when owner plays video
// Update database on play (owner only)
const onPlay = async () => {
setPlaying(true);
if (ownerId === userId) {
const currTime = player?.current?.getCurrentTime();
const currTime = player.current?.getCurrentTime();
if (currTime !== undefined) {
await db
.collection('rooms')
.doc(roomId)
.collection('messages')
.add({
const roomRef = db.collection('rooms').doc(roomId);
await roomRef.collection('messages').add({
createdAt: timestamp,
senderId: userId,
content: 'started playing the video from ' + secondsToTimestamp(currTime),
type: 'play',
});
await roomRef.collection('states').doc(stateId).update({
time: currTime,
isPlaying: true,
});
}
}
};
// Update database on pause (owner only)
const onPause = async () => {
setPlaying(false);
if (ownerId === userId) {
const currTime = player?.current?.getCurrentTime();
const currTime = player.current?.getCurrentTime();
if (currTime !== undefined) {
await db
.collection('rooms')
.doc(roomId)
.collection('messages')
.add({
const roomRef = db.collection('rooms').doc(roomId);
await roomRef.collection('messages').add({
createdAt: timestamp,
senderId: userId,
content: 'paused the video at ' + secondsToTimestamp(currTime),
type: 'pause',
});
await roomRef.collection('states').doc(stateId).update({
time: currTime,
isPlaying: false,
});
}
}
};
// Request an update after buffering is finished (member only)
const onBufferEnd = () => {
if (ownerId !== userId) {
db.collection('rooms').doc(roomId).collection('messages').add({
createdAt: timestamp,
senderId: userId,
type: 'updateState',
});
}
};
// Listen for video interactions
// Listen for video state updates (member only)
useEffect(() => {
const videoUnsubscribe = db
.collection('rooms')
.doc(roomId)
.collection('messages')
.where('type', 'in', ['play', 'pause'])
.onSnapshot((querySnapshot) => {
if (ownerId !== userId) {
const stateRef = db.collection('rooms').doc(roomId).collection('states');
const stateUnsubscribe = stateRef.onSnapshot((querySnapshot) => {
const changes = querySnapshot.docChanges();
const change = changes[changes.length - 1];
if (change?.type === 'added') {
if (change.type === 'modified') {
const data = change.doc.data();
if (userId !== data.senderId) {
// Match video timestamp of received message
const arr = data.content.split(' ');
const timestamp = arr[arr.length - 1];
player.current?.seekTo(timestampToSeconds(timestamp));
const currTime = player.current?.getCurrentTime();
if (currTime !== undefined) {
setPlaying(data.isPlaying);
if (!data.isPlaying) {
player.current?.seekTo(data.time);
}
if (data.type === 'play') {
setPlaying(true);
} else {
setPlaying(false);
// Continue requesting an update on the video state, until synced
if (Math.abs(currTime - data.time) > SYNC_MARGIN / 1000 && data.isPlaying) {
player.current?.seekTo(data.time);
console.log('diff: ' + Math.abs(currTime - data.time));
db.collection('rooms').doc(roomId).collection('messages').add({
createdAt: timestamp,
senderId: userId,
type: 'updateState',
});
}
}
}
});
return () => {
stateUnsubscribe();
};
}
}, [ownerId, userId, roomId]);
// Listen for video updateState requests (owner only)
useEffect(() => {
if (ownerId === userId) {
const roomRef = db.collection('rooms').doc(roomId);
const videoUnsubscribe = roomRef
.collection('messages')
.where('type', '==', 'updateState')
.onSnapshot((querySnapshot) => {
const changes = querySnapshot.docChanges();
const change = changes[changes.length - 1];
if (change?.type === 'added') {
const currTime = player.current?.getCurrentTime();
if (currTime !== undefined) {
roomRef.collection('states').doc(stateId).update({
time: currTime,
isPlaying: true,
});
}
}
});
return () => {
videoUnsubscribe();
};
}, [roomId, userId]);
}
}, [ownerId, roomId, userId, stateId]);
// Listen for video URL changes
useEffect(() => {
const urlRef = db.collection('rooms').doc(roomId).collection('playlist');
const urlUnsubscribe = urlRef.onSnapshot((querySnapshot) => {
const changes = querySnapshot.docChanges();
for (const change of changes) {
@ -108,6 +157,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId }) =>
height="100%"
controls={true}
onPlay={onPlay}
onBufferEnd={onBufferEnd}
onPause={onPause}
playing={playing}
muted={true}

@ -18,11 +18,17 @@ const Home: React.FC = () => {
ownerId: userId,
});
await db.collection('rooms').doc(roomId.id).collection('playlist').add({
const roomRef = db.collection('rooms').doc(roomId.id);
await roomRef.collection('playlist').add({
createdAt: timestamp,
url: 'https://www.youtube.com/watch?v=ksHOjnopT_U',
});
await roomRef.collection('states').add({
time: 0,
isPlaying: false,
});
await rtdb.ref('/rooms/' + roomId.id).set({ userCount: 0 });
await rtdb.ref('/available/' + roomId.id).set({ name: 'Room Name', createdAt: new Date().toISOString() });
const path = '/room/' + roomId.id;

@ -18,6 +18,7 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
const [loading, setLoading] = useState(true);
const [userCount, setUserCount] = useState(0);
const [videoId, setVideoId] = useState('');
const [stateId, setStateId] = useState('');
// Verify that the roomId exists in db
useEffect(() => {
@ -27,13 +28,18 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
if (!room.exists) {
history.push('/');
} else {
setValidRoom(true);
setOwnerId(room.data()?.ownerId);
// Set videoId for RoomHeader component
const playlistSnapshot = await roomRef.collection('playlist').get();
const vidId = playlistSnapshot.docs[0].id;
setVideoId(vidId);
// Set stateId for VideoPlayer component
const stateSnapshot = await roomRef.collection('states').get();
const vidStateId = stateSnapshot.docs[0].id;
setStateId(vidStateId);
setOwnerId(room.data()?.ownerId);
setValidRoom(true);
}
};
@ -145,7 +151,7 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
<IonGrid class="room-grid">
<IonRow class="room-row">
<IonCol size="12" sizeLg="9" class="player-col">
<VideoPlayer ownerId={ownerId} userId={userId} roomId={roomId}></VideoPlayer>
<VideoPlayer ownerId={ownerId} userId={userId} roomId={roomId} stateId={stateId}></VideoPlayer>
</IonCol>
<IonCol size="12" sizeLg="3" class="chat-col">
<Chat roomId={roomId} userId={userId}></Chat>

@ -44,6 +44,20 @@ const animals = [
'Zebra',
];
// const links: { [index: string]: RegExp } = {
// MATCH_URL_YOUTUBE: /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})|youtube\.com\/playlist\?list=|youtube\.com\/user\//,
// MATCH_URL_SOUNDCLOUD: /(?:soundcloud\.com|snd\.sc)\/[^.]+$/,
// MATCH_URL_VIMEO: /vimeo\.com\/.+/,
// MATCH_URL_FACEBOOK: /^https?:\/\/(www\.)?facebook\.com.*\/(video(s)?|watch|story)(\.php?|\/).+$/,
// MATCH_URL_STREAMABLE: /streamable\.com\/([a-z0-9]+)$/,
// MATCH_URL_WISTIA: /(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/,
// MATCH_URL_TWITCH_VIDEO: /(?:www\.|go\.)?twitch\.tv\/videos\/(\d+)($|\?)/,
// MATCH_URL_TWITCH_CHANNEL: /(?:www\.|go\.)?twitch\.tv\/([a-zA-Z0-9_]+)($|\?)/,
// MATCH_URL_DAILYMOTION: /^(?:(?:https?):)?(?:\/\/)?(?:www\.)?(?:(?:dailymotion\.com(?:\/embed)?\/video)|dai\.ly)\/([a-zA-Z0-9]+)(?:_[\w_-]+)?$/,
// MATCH_URL_MIXCLOUD: /mixcloud\.com\/([^/]+\/[^/]+)/,
// MATCH_URL_VIDYARD: /vidyard.com\/(?:watch\/)?([a-zA-Z0-9-]+)/,
// };
export const generateAnonName = (): string => {
const adj: string = adjectives[Math.floor(Math.random() * 20)];
const animal: string = animals[Math.floor(Math.random() * 20)];
@ -66,3 +80,5 @@ export const timestampToSeconds = (timestamp: string): number => {
}
return +arr[0] * 60 + +arr[1];
};
export const SYNC_MARGIN = 3000; // in milliseconds

Loading…
Cancel
Save