Compare commits

..

8 Commits

Author SHA1 Message Date
vel 370e056a2b
(doc): readme changes 2022-02-02 16:13:08 -08:00
vel 55e5136e88
Merge pull request #1 from QPixel/refactor/main
Refactor main so it works now
2022-02-02 16:04:12 -08:00
vel 2586e60f0c
finally fixed player 2022-02-01 17:07:56 -08:00
vel 7a7e99337f
websocket is more stable 2022-01-31 12:29:06 -08:00
vel f52b9a9c53
player is super broken 2022-01-31 11:21:04 -08:00
vel 9350346b4c
state stuff in backend 2022-01-31 00:30:29 -08:00
vel 728f2d67d0
feat(ws): barebones SetPlayhead event 2022-01-27 22:48:21 -08:00
vel 5f81ed1756
more stuff 2022-01-27 14:29:54 -08:00
25 changed files with 338 additions and 99 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
*/.env */.env
test test
*/.DS_STORE */.DS_STORE
.DS_STORE

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# watchtogether
This was a project that I created over 7 days of working while having covid :)
I probably won't do much with the codebase, but enjoy how I basically figured out how to sync video streams with multiple clients
## The Stack
- frontend
- next js
- websockets
- chakra-ui
- react-player
- backend
- go
- gorilla/websockets
## how 2 deploy?
you don't

1
backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
cmd/watchtogether/watchtogether

View File

@ -5,14 +5,8 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8a64704d-5500-41a6-aa4c-e275933fc58c" name="Changes" comment=""> <list default="true" id="8a64704d-5500-41a6-aa4c-e275933fc58c" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/../.vscode/settings.json" beforeDir="false" afterPath="$PROJECT_DIR$/../.vscode/settings.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/ws/handlers.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/ws/handlers.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../frontend/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/../frontend/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../frontend/src/hooks/useWS.ts" beforeDir="false" afterPath="$PROJECT_DIR$/../frontend/src/hooks/useWS.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../frontend/src/pages/player.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/../frontend/src/pages/player.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/../frontend/src/pages/player.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/../frontend/src/pages/player.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../frontend/src/ws/websocket.ts" beforeDir="false" afterPath="$PROJECT_DIR$/../frontend/src/ws/websocket.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../frontend/yarn.lock" beforeDir="false" afterPath="$PROJECT_DIR$/../frontend/yarn.lock" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />

1
backend/README.md Normal file
View File

@ -0,0 +1 @@
# backend

View File

@ -0,0 +1,12 @@
#!/bin/bash
VERSION=v1.0.0
COMMIT_HASH=$(git rev-parse --short HEAD)
ENVIRONMENT=$ENV
BUILDSTRING="$VERSION-$COMMIT_HASH-$ENVIRONMENT"
echo The environment is "$ENVIRONMENT"
echo Building watch together backend "$VERSION-$COMMIT_HASH"
if [[ "$ENVIRONMENT" == "dev" ]]; then
go build -gcflags="all=-N -l" -ldflags "-X github.com/qpixel/watchtogether/cmd/watchtogether/main.VERSION=${BUILDSTRING} -X github.com/qpixel/watchtogether/cmd/watchtogether/main.ENVIRONMENT=${ENVIRONMENT}";
else
go build -ldflags "-X github.com/qpixel/watchtogether/cmd/watchtogether/main.VERSION=${BUILDSTRING} -X github.com/ubergeek77/uberbot/v2/core.ENVIRONMENT=${ENVIRONMENT}";
fi

View File

@ -10,6 +10,11 @@ import (
var log = tlog.NewTaggedLogger("Logger", tlog.NewColor("38;5;111")) var log = tlog.NewTaggedLogger("Logger", tlog.NewColor("38;5;111"))
var (
VERSION string
ENVIRONMENT string
)
func main() { func main() {
r := mux.NewRouter() r := mux.NewRouter()
hub := ws.NewHub() hub := ws.NewHub()
@ -47,7 +52,8 @@ func main() {
} }
return return
}) })
log.Infof("starting backend")
log.Infof("running version %s. environment: %s", VERSION, ENVIRONMENT)
err := http.ListenAndServe(":8080", r) err := http.ListenAndServe(":8080", r)
if err != nil { if err != nil {
log.Errorf("%s", err.Error()) log.Errorf("%s", err.Error())

View File

@ -12,14 +12,20 @@ func handleIdentifyEvent(message *Message) {
if id, ok := d["clientID"]; ok { if id, ok := d["clientID"]; ok {
log.Infof("Client %s has sent identify event", id.(string)) log.Infof("Client %s has sent identify event", id.(string))
} }
user := d["user"].(map[string]interface{})
userId := user["id"].(string)
playhead := message.hub.State.playhead
paused := message.hub.State.paused
m := Message{ m := Message{
MessageData: MessageData{ MessageData: MessageData{
Type: Identify, Type: Identify,
Data: map[string]interface{}{ Data: map[string]interface{}{
"admin": true, "admin": message.hub.State.IsAdmin(userId),
"playlist": "http://cdnapi.kaltura.com/p/1878761/sp/187876100/playManifest/entryId/1_usagz19w/flavorIds/1_5spqkazq,1_nslowvhp,1_boih5aji,1_qahc37ag/format/applehttp/protocol/http/a.m3u8", "playlist": "http://localhost:8081/BelleOpening.m3u8",
"playhead": 0, "hasController": message.hub.State.IsController(userId),
"user": d["user"], "playhead": playhead,
"paused": paused,
"user": d["user"],
}, },
}, },
} }
@ -42,5 +48,29 @@ func handleGetPlayhead(message *Message) {
} }
func handleSetPlayhead(message *Message) { func handleSetPlayhead(message *Message) {
d := message.Data.(map[string]interface{})
m := Message{
MessageData: MessageData{
Type: SetPlayhead,
Data: map[string]interface{}{
"playhead": d["playhead"],
"paused": d["paused"],
},
},
}
log.Infof("Received SetPlayhead event. playhead is at %s", d["playhead"])
err := message.hub.State.setPlayhead(d["playhead"].(float64))
if err != nil {
log.Errorf("unable to set playhead. %s", err)
}
err = message.hub.State.setPaused(d["paused"].(bool))
if err != nil {
log.Errorf("unable to set paused. %s", err)
}
for client := range message.Client.hub.Clients {
if client == message.Client {
continue
}
client.send <- m.SerializeMessage().Data
}
} }

View File

@ -4,6 +4,9 @@ type Hub struct {
// Registered Clients // Registered Clients
Clients map[*Client]bool Clients map[*Client]bool
// State
State *State
// Inbound messages from the Clients // Inbound messages from the Clients
broadcast chan RawMessage broadcast chan RawMessage
@ -20,6 +23,7 @@ func NewHub() *Hub {
register: make(chan *Client), register: make(chan *Client),
unregister: make(chan *Client), unregister: make(chan *Client),
Clients: make(map[*Client]bool), Clients: make(map[*Client]bool),
State: NewState(),
} }
} }
@ -30,9 +34,9 @@ func (h *Hub) handleMessage(rm RawMessage) {
handleIdentifyEvent(&m) handleIdentifyEvent(&m)
case Ping: case Ping:
handlePingEvent(&m) handlePingEvent(&m)
case Position: case GetPlayhead:
handleGetPlayhead(&m) handleGetPlayhead(&m)
case SetPosition: case SetPlayhead:
handleSetPlayhead(&m) handleSetPlayhead(&m)
default: default:
return return

View File

@ -14,8 +14,8 @@ const (
Ping MessageTypes = iota Ping MessageTypes = iota
Pong Pong
Identify Identify
Position GetPlayhead
SetPosition SetPlayhead
) )
type MessageData struct { type MessageData struct {

View File

@ -0,0 +1,54 @@
package ws
import (
"errors"
"sync"
)
type State struct {
sync.RWMutex
playhead float64
controllerUserId string
adminUserId string
paused bool
}
func NewState() *State {
return &State{
playhead: 0,
controllerUserId: "218072060923084802",
adminUserId: "218072060923084802",
}
}
func (s *State) setPlayhead(playhead float64) error {
if s == nil {
return errors.New("unable to find state")
}
s.Lock()
defer s.Unlock()
s.playhead = playhead
return nil
}
func (s *State) setPaused(paused bool) error {
if s == nil {
return errors.New("unable to find state")
}
s.Lock()
defer s.Unlock()
s.paused = paused
return nil
}
func (s *State) IsController(userID string) bool {
if userID == s.controllerUserId {
return true
}
return false
}
func (s *State) IsAdmin(userID string) bool {
if userID == s.adminUserId {
return true
}
return false
}

View File

@ -1,39 +1 @@
# Example app with [chakra-ui](https://github.com/chakra-ui/chakra-ui) and TypeScript # frontend
This example features how to use [chakra-ui](https://github.com/chakra-ui/chakra-ui) as the component library within a Next.js app with TypeScript.
Next.js and chakra-ui have built-in TypeScript declarations, so we'll get autocompletion for their modules straight away.
We are connecting the Next.js `_app.js` with `chakra-ui`'s Provider and theme so the pages can have app-wide dark/light mode. We are also creating some components which shows the usage of `chakra-ui`'s style props.
## Preview
Preview the example live on [StackBlitz](http://stackblitz.com/):
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/with-chakra-ui-typescript)
## Deploy your own
Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example):
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-chakra-ui-typescript&project-name=with-chakra-ui-typescript&repository-name=with-chakra-ui-typescript)
## How to use
### Using `create-next-app`
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example:
```bash
npx create-next-app --example with-chakra-ui-typescript with-chakra-ui-typescript-app
# or
yarn create next-app --example with-chakra-ui-typescript with-chakra-ui-typescript-app
```
Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).
## Notes
Chakra has supported Gradients and RTL in `v1.1`. To utilize RTL, [add RTL direction and swap](https://chakra-ui.com/docs/features/rtl-support).
If you don't have multi-direction app, you should make `<Html lang="ar" dir="rtl">` inside `_document.ts`.

View File

@ -1,19 +1,23 @@
import { Flex, useColorMode, FlexProps } from '@chakra-ui/react' import React, { FC } from "react";
import { Flex, useColorMode, FlexProps } from "@chakra-ui/react";
export const Container = (props: FlexProps) => { export const Container: FC<FlexProps> = (props: FlexProps) => {
const { colorMode } = useColorMode() const { colorMode } = useColorMode();
const bgColor = { light: 'gray.50', dark: 'gray.900' } const bgColor = { light: "gray.50", dark: "gray.900" };
const color = { light: 'black', dark: 'white' } const color = { light: "black", dark: "white" };
return ( return (
<Flex <Flex
direction="column" direction="column"
alignItems="center"
justifyContent="flex-start" justifyContent="flex-start"
bg={bgColor[colorMode]} bg={bgColor[colorMode]}
color={color[colorMode]} color={color[colorMode]}
{...props} {...props}
/> />
) );
} };
Container.defaultProps = {
alignItems: "center",
};

View File

@ -1,15 +1,83 @@
import React, { FC } from "react"; import { Box } from "@chakra-ui/react";
import React, { forwardRef } from "react";
import ReactPlayer, { Config, ReactPlayerProps } from "react-player"; import ReactPlayer, { Config, ReactPlayerProps } from "react-player";
type PlayerProps = { id: string } & ReactPlayerProps; const Player = forwardRef<ReactPlayer, ReactPlayerProps>((props, ref) => {
const Player: FC<PlayerProps> = (props) => {
const config: Config = { const config: Config = {
file: { file: {
forceHLS: true, forceHLS: true,
}, },
}; };
return <ReactPlayer url={props.id} config={config} {...props} />; // useEffect(() => {
}; // if (playerRef.current && typeof props.identity !== "undefined") {
// console.log(props.identity.playhead);
// playerRef.current.seekTo(props.identity.playhead);
// setPaused(props.identity.paused);
// }
// }, []);
// socket.emitter.once(SocketEvents.SetPlayhead, (e) => {
// console.log(e);
// playerRef.current.seekTo(e.playhead);
// setPaused(e.paused);
// });
// const onSeek = (playedSeconds: number) => {
// if (!props.identity.admin) return;
// if (paused) {
// socket?.send(
// MessageUtil.encode(
// new Message(MessageTypes.SetPlayhead, {
// playhead: playedSeconds,
// paused: true,
// })
// )
// );
// return;
// }
// socket?.send(
// MessageUtil.encode(
// new Message(MessageTypes.SetPlayhead, {
// playhead: playedSeconds,
// paused: false,
// })
// )
// );
// };
// const onPause = () => {
// if (!props.identity.admin) return;
// setPaused(true);
// socket?.send(
// MessageUtil.encode(
// new Message(MessageTypes.SetPlayhead, {
// playhead: playerRef.current.getCurrentTime(),
// paused: true,
// })
// )
// );
// };
// const onPlay = () => {
// if (!props.identity.admin) return;
// setPaused(false);
// socket?.send(
// MessageUtil.encode(
// new Message(MessageTypes.SetPlayhead, {
// playhead: playerRef.current.getCurrentTime(),
// paused: false,
// })
// )
// );
// };
return (
<Box height="100vh" width="100vw">
<ReactPlayer
url={props.url}
width="100%"
height="100%"
config={config}
ref={ref}
{...props}
/>
</Box>
);
});
export default Player; export default Player;

View File

@ -7,17 +7,22 @@ interface useWSProps {
} }
// todo write websocket reconnector // todo write websocket reconnector
const useWS = ({ user }: useWSProps) => { const useWS = ({ user }: useWSProps): PlayerSocket | null => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return;
} }
// todo checkout usecallback // todo checkout usecallback
const [socket, setSocket] = useState<PlayerSocket>(); const [socket, setSocket] = useState<PlayerSocket>(null);
useEffect(() => { useEffect(() => {
if (socket !== null) {
return;
}
let internalSocket = new PlayerSocket(user); let internalSocket = new PlayerSocket(user);
setSocket(internalSocket); setSocket(internalSocket);
return () => { return () => {
return internalSocket.close(); if (internalSocket.readyState !== WebSocket.OPEN) {
return internalSocket.close();
}
}; };
}, []); }, []);

View File

@ -1,9 +1,12 @@
import IUser from "./IUser"; import IUser from "./IUser";
interface IdentityData { interface IdentityData {
admin?: boolean;
hasController?: boolean;
clientID?: string; clientID?: string;
playlist?: string; playlist?: string;
playHead?: number; playhead?: number;
paused?: boolean;
user: IUser; user: IUser;
} }

View File

@ -0,0 +1,4 @@
export default interface SetPlayheadEvent {
playhead: number;
paused: boolean;
}

View File

@ -0,0 +1,7 @@
enum SocketEvents {
Identify = "Identify",
SetPlayhead = "SetPlayhead",
GetPlayhead = "GetPlayhead",
}
export default SocketEvents;

View File

@ -8,6 +8,7 @@ import { Container } from "../components/Container";
import { Footer } from "../components/Footer"; import { Footer } from "../components/Footer";
import { Hero } from "../components/Hero"; import { Hero } from "../components/Hero";
import { Main } from "../components/Main"; import { Main } from "../components/Main";
import { isDev } from "../util";
const Index: NextPage = () => { const Index: NextPage = () => {
return ( return (
@ -22,6 +23,7 @@ const Index: NextPage = () => {
maxWidth="200" maxWidth="200"
alignSelf="center" alignSelf="center"
onClick={() => signIn("discord")} onClick={() => signIn("discord")}
disabled={!isDev()}
> >
Login With Discord Login With Discord
</Button> </Button>

View File

@ -1,41 +1,98 @@
import { GetServerSideProps, NextPage } from "next"; import { GetServerSideProps, NextPage } from "next";
import { User } from "next-auth"; import { User } from "next-auth";
import { getSession } from "next-auth/react"; import { getSession } from "next-auth/react";
import dynamic from "next/dynamic";
import Head from "next/head"; import Head from "next/head";
import React, { useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import ReactPlayer from "react-player"; import ReactPlayer from "react-player";
import { Container } from "../components/Container"; import { Container } from "../components/Container";
import Player from "../components/Player";
import useWS from "../hooks/useWS"; import useWS from "../hooks/useWS";
import IdentityData from "../interfaces/Identity"; import IdentityData from "../interfaces/Identity";
import { MessageTypes } from "../interfaces/IMessage"; import { MessageTypes } from "../interfaces/IMessage";
import isBrowser from "../util/isBrowser"; import SetPlayheadEvent from "../interfaces/Playhead";
import SocketEvents from "../interfaces/SocketEvents";
const Player = dynamic(() => import("../components/Player"), { ssr: false }); import { isBrowser } from "../util";
import Message from "../util/Message";
import MessageUtil from "../util/MessageUtil";
interface PlayerPageProps { interface PlayerPageProps {
URI: string;
user: User; user: User;
} }
// types for the function
const PlayerPage: NextPage<PlayerPageProps> = ({ URI, user }) => { const PlayerPage: NextPage<PlayerPageProps> = ({ user }) => {
const playerRef = useRef<ReactPlayer>();
const socket = useWS({ user }); const socket = useWS({ user });
const playerRef = useRef<ReactPlayer>();
const [id, setID] = useState<string>(""); const [id, setID] = useState<string>("");
if (isBrowser() && typeof socket !== "undefined") { const [identity, setIdentity] = useState<IdentityData>();
socket.emitter.on("Identify", (e: IdentityData) => { const [paused, setPaused] = useState<boolean>(true);
console.log(e);
setID(e.playlist);
});
}
useEffect(() => {
if (isBrowser() && typeof socket !== "undefined") {
socket?.emitter.once(SocketEvents.Identify, (e: IdentityData) => {
setID(e.playlist);
setIdentity(e);
playerRef?.current.seekTo(e.playhead);
setPaused(e.paused);
});
socket?.emitter.on(SocketEvents.SetPlayhead, (e: SetPlayheadEvent) => {
console.log(e.paused);
setPaused(e.paused);
playerRef.current.seekTo(e.playhead);
});
}
}, [socket]);
const onPlay = () => {
if (!identity.admin) return;
setPaused(false);
socket?.send(
MessageUtil.encode(
new Message(MessageTypes.SetPlayhead, {
playhead: playerRef.current.getCurrentTime(),
paused: false,
})
)
);
};
const onSeek = (playedSeconds: number) => {
if (!identity.admin) return;
socket.send(
MessageUtil.encode(
new Message(MessageTypes.SetPlayhead, {
playhead: playedSeconds,
paused: paused,
})
)
);
};
const onPause = () => {
console.log("running now");
if (!identity.admin) return;
setPaused(true);
socket?.send(
MessageUtil.encode(
new Message(MessageTypes.SetPlayhead, {
playhead: playerRef.current.getCurrentTime(),
paused: true,
})
)
);
};
return ( return (
<> <>
<Head> <Head>
<title>Watch Together</title> <title>Watch Together</title>
</Head> </Head>
<Container height="100vh"> <Container height="100vh" background={"#000"}>
<Player id={id} ref={playerRef} /> <Player
url={id}
onPlay={onPlay}
onPause={onPause}
onSeek={onSeek}
controls={identity?.hasController}
playing={!paused}
ref={playerRef}
/>
</Container> </Container>
</> </>
); );

View File

@ -1 +0,0 @@
export default class Handler {}

View File

@ -1,3 +0,0 @@
const useAPI = () => {};
export default useAPI;

View File

@ -0,0 +1,6 @@
export function isDev() {
return process.env.NODE_ENV === "development";
}
export function isBrowser() {
return typeof window !== "undefined";
}

View File

@ -1,3 +0,0 @@
export default function isBrowser() {
return typeof window !== "undefined";
}

View File

@ -1,5 +1,3 @@
// nice and easy way to get types for the
import { User } from "next-auth"; import { User } from "next-auth";
import EventEmitter from "events"; import EventEmitter from "events";
import IdentityData from "../interfaces/Identity"; import IdentityData from "../interfaces/Identity";
@ -8,6 +6,7 @@ import Message from "../util/Message";
import MessageUtil from "../util/MessageUtil"; import MessageUtil from "../util/MessageUtil";
// browser socket // browser socket
// todo: write a shim for this
let Websocket: typeof WebSocket; let Websocket: typeof WebSocket;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
Websocket = window.WebSocket; Websocket = window.WebSocket;
@ -26,6 +25,10 @@ export default class PlayerSocket extends Websocket {
this.onmessage = this.onMessage; this.onmessage = this.onMessage;
this.onclose = this.onClose; this.onclose = this.onClose;
} }
close(code?: number, reason?: string): void {
this.emitter.removeAllListeners();
super.close(code, reason);
}
onMessage(evt: MessageEvent<any>) { onMessage(evt: MessageEvent<any>) {
let message = MessageUtil.decode(evt.data); let message = MessageUtil.decode(evt.data);
if (message.type === MessageTypes["Ping"]) { if (message.type === MessageTypes["Ping"]) {
@ -66,7 +69,6 @@ export default class PlayerSocket extends Websocket {
} }
onClose(event: CloseEvent) { onClose(event: CloseEvent) {
console.log("[WS] socket connection closed"); console.log("[WS] socket connection closed");
console.log(event);
this.emitter.emit("closed"); this.emitter.emit("closed");
} }
get open() { get open() {