improved synchronization

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

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

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

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

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

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

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

@ -18,11 +18,17 @@ const Home: React.FC = () => {
ownerId: userId, 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, createdAt: timestamp,
url: 'https://www.youtube.com/watch?v=ksHOjnopT_U', 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('/rooms/' + roomId.id).set({ userCount: 0 });
await rtdb.ref('/available/' + roomId.id).set({ name: 'Room Name', createdAt: new Date().toISOString() }); await rtdb.ref('/available/' + roomId.id).set({ name: 'Room Name', createdAt: new Date().toISOString() });
const path = '/room/' + roomId.id; const path = '/room/' + roomId.id;

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

@ -44,6 +44,20 @@ const animals = [
'Zebra', '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 => { export const generateAnonName = (): string => {
const adj: string = adjectives[Math.floor(Math.random() * 20)]; const adj: string = adjectives[Math.floor(Math.random() * 20)];
const animal: string = animals[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]; return +arr[0] * 60 + +arr[1];
}; };
export const SYNC_MARGIN = 3000; // in milliseconds

Loading…
Cancel
Save