Initial commit.
This commit is contained in:
commit
3ec975d130
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
out/
|
||||
project/
|
||||
target/
|
||||
.idea/
|
||||
4
README.md
Normal file
4
README.md
Normal file
@ -0,0 +1,4 @@
|
||||
netflix-sync
|
||||
============
|
||||
|
||||
Hacked together tool to make long distance Netflix watching a tiny bit easier.
|
||||
9
build.sbt
Normal file
9
build.sbt
Normal file
@ -0,0 +1,9 @@
|
||||
name := "netflix-sync"
|
||||
|
||||
version := "1.0"
|
||||
|
||||
libraryDependencies += "org.scalatest" % "scalatest_2.10" % "2.1.0" % "test"
|
||||
|
||||
libraryDependencies <+= scalaVersion("org.scala-lang" % "scala-swing" % _)
|
||||
|
||||
libraryDependencies += "com.typesafe.akka" % "akka-actor_2.10" % "2.3.4"
|
||||
197
src/main/scala/com/ianonavy/netflixsync/Client.scala
Normal file
197
src/main/scala/com/ianonavy/netflixsync/Client.scala
Normal file
@ -0,0 +1,197 @@
|
||||
package com.ianonavy.netflixsync
|
||||
|
||||
import java.awt.Robot
|
||||
import java.awt.event.{KeyEvent, InputEvent}
|
||||
import java.net._
|
||||
import java.io._
|
||||
import scala.io._
|
||||
import scala.util.parsing.json.{JSON, JSONObject}
|
||||
|
||||
|
||||
/**
|
||||
* Client for interfacing with the Netflix sync server
|
||||
* @param host Hostname of the server
|
||||
* @param nTimes Number of messages to send to determine delay
|
||||
*/
|
||||
class Client(host: String, nTimes: Int) {
|
||||
|
||||
/**
|
||||
* Average delay to the server. Includes clock differences and network lag.
|
||||
*/
|
||||
var delay = 0.0
|
||||
|
||||
/**
|
||||
* Runs a block of code and times it.
|
||||
* @param block The block of code to run
|
||||
* @tparam R The type that block returns
|
||||
* @return A tuple of the number of nanoseconds passed and the results
|
||||
*/
|
||||
def time[R](block: => R): (Long, R) = {
|
||||
val t0 = System.nanoTime()
|
||||
val result = block
|
||||
val t1 = System.nanoTime()
|
||||
|
||||
(t1 - t0, result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks in a particular set of coordinates after waiting a certain number
|
||||
* of milliseconds.
|
||||
* @param delay The delay in milliseconds to wait before clicking
|
||||
* @param x The x coordinate to click
|
||||
* @param y The y coordinate to click
|
||||
*/
|
||||
def synchronizedClick(delay: Long, x: Int, y: Int) {
|
||||
val robot = new Robot
|
||||
Thread.sleep(delay)
|
||||
robot.mouseMove(x, y)
|
||||
robot.mousePress(InputEvent.BUTTON1_MASK)
|
||||
robot.mouseRelease(InputEvent.BUTTON1_MASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a spacebar press after waiting a certain number of milliseconds.
|
||||
* @param delay The delay in milliseconds to wait before pressing space
|
||||
*/
|
||||
def synchronizedSpacebar(delay: Long) {
|
||||
val robot = new Robot
|
||||
Thread.sleep(delay)
|
||||
robot.keyPress(KeyEvent.VK_SPACE)
|
||||
robot.keyRelease(KeyEvent.VK_SPACE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an object to the server and returns the results as a string.
|
||||
* @param obj Map corresponding to the JSON object to send. Should contain
|
||||
* the key "command"
|
||||
* @param close Whether to close the socket after sending the command
|
||||
* @return The reply from the server
|
||||
*/
|
||||
def sendCommand(obj: Map[String, Any], close: Boolean): String = {
|
||||
val s = new Socket(InetAddress.getByName(host), 9999)
|
||||
lazy val in = new BufferedSource(s.getInputStream).getLines()
|
||||
val out = new PrintStream(s.getOutputStream)
|
||||
out.println(new JSONObject(obj))
|
||||
out.flush()
|
||||
|
||||
val results = in.next().toString
|
||||
s.close()
|
||||
results
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an object to the server and returns the results as a string. By
|
||||
* default closes the socket after sending the command.
|
||||
* @param obj Map corresponding to the JSON object to send. Should contain
|
||||
* the key "command"
|
||||
* @return The reply from the server
|
||||
*/
|
||||
def sendCommand(obj: Map[String, Any]): String = {
|
||||
sendCommand(obj, close = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the current system time in milliseconds to the server and
|
||||
* retrieves the difference from the server's time. Returns a positive integer
|
||||
* if the server's clock is ahead.
|
||||
* @return The number of milliseconds that the server's clock is ahead
|
||||
*/
|
||||
def getDelayFromServer: String = {
|
||||
val cmd = Map(
|
||||
"command" -> "GET DELAY",
|
||||
"time" -> System.currentTimeMillis.toString)
|
||||
sendCommand(cmd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of users registered to a particular room.
|
||||
* @param roomName The name of the room.
|
||||
* @return The number of registered users in the given room
|
||||
*/
|
||||
def getNumberRegistered(roomName: String): String = {
|
||||
val cmd = Map(
|
||||
"command" -> "GET NUM REGISTERED",
|
||||
"room" -> roomName)
|
||||
sendCommand(cmd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a name to the given room.
|
||||
* @param roomName The name of the room in which to register
|
||||
* @param watcherName The name of the watcher to register
|
||||
* @return Whether the watcher was the first one in the room (aka the master)
|
||||
*/
|
||||
def registerName(roomName: String, watcherName: String): Boolean = {
|
||||
val cmd = Map(
|
||||
"command" -> "REGISTER",
|
||||
"room" -> roomName,
|
||||
"name" -> watcherName)
|
||||
val res = JSON.parseFull(sendCommand(cmd))
|
||||
val isMaster = res match {
|
||||
case Some(m: Map[String, Any]) => m("isMaster") match {
|
||||
case isMaster: Boolean => isMaster
|
||||
}
|
||||
}
|
||||
isMaster
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructs the server to store the socket for a given room name and
|
||||
* watcher name.
|
||||
* @param roomName The name of the relevant room
|
||||
* @param watcherName The name of the watcher whose socket we're setting
|
||||
* @return
|
||||
*/
|
||||
def setSocket(roomName: String, watcherName: String) = {
|
||||
val cmd = Map(
|
||||
"command" -> "SET SOCKET",
|
||||
"room" -> roomName,
|
||||
"name" -> watcherName)
|
||||
sendCommand(cmd, close = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructs the server to push a "CLICK" message to all clients with set
|
||||
* sockets.
|
||||
* @param roomName The name of the relevant room
|
||||
*/
|
||||
def sendClick(roomName: String) {
|
||||
val cmd = Map(
|
||||
"command" -> "CLICK",
|
||||
"room" -> roomName)
|
||||
sendCommand(cmd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructs the server to push a "SPACEBAR" message to all clients with set
|
||||
* sockets.
|
||||
* @param roomName The name of the relevant room
|
||||
*/
|
||||
def sendSpacebar(roomName: String) {
|
||||
val cmd = Map(
|
||||
"command" -> "SPACEBAR",
|
||||
"room" -> roomName)
|
||||
sendCommand(cmd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the 'delay' attribute by polling the server for the offset between
|
||||
* its clock and the client's and timing how long that roundtrip request
|
||||
* takes in milliseconds.
|
||||
*/
|
||||
def calculateAverageTime() {
|
||||
val times = new Array[Long](nTimes)
|
||||
val delays = new Array[Long](nTimes)
|
||||
|
||||
for (i <- 0 to nTimes - 1) {
|
||||
val (elapsed, delay) = time {
|
||||
getDelayFromServer
|
||||
}
|
||||
times(i) = elapsed
|
||||
delays(i) = delay.toLong
|
||||
}
|
||||
val avgOffset = times.sum / times.length / 1000000.0
|
||||
val avgDelay = delays.sum / delays.length / 1000000.0
|
||||
delay = avgOffset + avgDelay
|
||||
}
|
||||
}
|
||||
18
src/main/scala/com/ianonavy/netflixsync/Main.scala
Normal file
18
src/main/scala/com/ianonavy/netflixsync/Main.scala
Normal file
@ -0,0 +1,18 @@
|
||||
package com.ianonavy.netflixsync
|
||||
|
||||
import scala.swing._
|
||||
|
||||
|
||||
/**
|
||||
* Main Swing instance
|
||||
*/
|
||||
object Main extends SimpleSwingApplication {
|
||||
|
||||
def top = new MainFrame {
|
||||
preferredSize = new Dimension(196, 196)
|
||||
title = "Netflix Sync"
|
||||
resizable = false
|
||||
contents = new MainPanel
|
||||
}
|
||||
|
||||
}
|
||||
175
src/main/scala/com/ianonavy/netflixsync/MainPanel.scala
Normal file
175
src/main/scala/com/ianonavy/netflixsync/MainPanel.scala
Normal file
@ -0,0 +1,175 @@
|
||||
package com.ianonavy.netflixsync
|
||||
|
||||
import scala.swing.GridBagPanel.Fill
|
||||
import scala.swing._
|
||||
import scala.swing.event.ButtonClicked
|
||||
|
||||
|
||||
/**
|
||||
* Main swing panel for the main frame.
|
||||
*/
|
||||
class MainPanel extends FlowPanel {
|
||||
var socketEventPublisher: SocketEventPublisher = null
|
||||
var client: Client = null
|
||||
|
||||
val SYNC_LABEL_DEFAULT_TEXT = "Please place me in the middle of your " +
|
||||
"Netflix window"
|
||||
|
||||
val serverField = new TextField {
|
||||
text = "localhost"
|
||||
}
|
||||
val roomField = new TextField
|
||||
val nameField = new TextField
|
||||
|
||||
val registerButton = new Button {
|
||||
text = "Register"
|
||||
}
|
||||
val setLocationButton = new Button {
|
||||
text = "Set Location"
|
||||
}
|
||||
val setLocationLabel = new TextArea {
|
||||
text = SYNC_LABEL_DEFAULT_TEXT
|
||||
editable = false
|
||||
opaque = false
|
||||
focusable = false
|
||||
cursor = null
|
||||
lineWrap = true
|
||||
wordWrap = true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return A panel containing the elements relevant to registration
|
||||
*/
|
||||
def registerPanel: Panel = new BoxPanel(Orientation.Vertical) {
|
||||
contents += new Label("Server:")
|
||||
contents += serverField
|
||||
contents += new Label("Room:")
|
||||
contents += roomField
|
||||
contents += new Label("Name:")
|
||||
contents += nameField
|
||||
contents += registerButton
|
||||
border = Swing.EmptyBorder(30, 30, 30, 30)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return A panel used for setting the location of the Netflix window
|
||||
*/
|
||||
def setLocationPanel: Panel = new GridBagPanel() {
|
||||
val c = new Constraints
|
||||
c.fill = Fill.Vertical
|
||||
c.gridx = 0
|
||||
c.gridy = 0
|
||||
layout(setLocationLabel) = c
|
||||
|
||||
c.gridx = 0
|
||||
c.gridy = 1
|
||||
c.insets = new Insets(32, 0, 0, 0)
|
||||
layout(setLocationButton) = c
|
||||
border = Swing.EmptyBorder(30, 30, 30, 30)
|
||||
}
|
||||
|
||||
/**
|
||||
* The master control panel for a particular room. Shown only to the first
|
||||
* user who registeres into a room.
|
||||
* @param room The room to control
|
||||
* @return The master control panel
|
||||
*/
|
||||
def masterPanel(room: String): Panel = new BoxPanel(Orientation.Vertical) {
|
||||
val label = new Label {
|
||||
text = "0 people have joined the room."
|
||||
}
|
||||
val clickButton = new Button {
|
||||
text = "Send Click"
|
||||
}
|
||||
val spacebarButton = new Button {
|
||||
text = "Send Spacebar"
|
||||
}
|
||||
val refreshButton = new Button {
|
||||
text = "Refresh Count"
|
||||
}
|
||||
|
||||
contents += label
|
||||
contents += clickButton
|
||||
contents += spacebarButton
|
||||
contents += refreshButton
|
||||
border = Swing.EmptyBorder(30, 30, 30, 30)
|
||||
|
||||
listenTo(clickButton)
|
||||
listenTo(spacebarButton)
|
||||
listenTo(refreshButton)
|
||||
reactions += {
|
||||
case ButtonClicked(`clickButton`) =>
|
||||
label.text = "Sending..."
|
||||
client.sendClick(room)
|
||||
case ButtonClicked(`spacebarButton`) =>
|
||||
label.text = "Sending..."
|
||||
client.sendSpacebar(room)
|
||||
case ButtonClicked(`refreshButton`) =>
|
||||
val numPeople = client.getNumberRegistered(room)
|
||||
label.text = s"$numPeople people have joined the room."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame for the master panel
|
||||
* @param room The room to control
|
||||
* @return The master frame
|
||||
*/
|
||||
def masterFrame(room: String) = new MainFrame {
|
||||
title = "Netflix Sync"
|
||||
contents = masterPanel(room)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a user to a client at a particular server based on the results
|
||||
* of the form elements usually contained in the registerPanel. Starts the
|
||||
* socket event publisher, configures the Netflix Sync client and
|
||||
* optionally spawns the master frame.
|
||||
*/
|
||||
def register() {
|
||||
client = new Client(serverField.text, 10)
|
||||
client.calculateAverageTime()
|
||||
|
||||
val room = roomField.text
|
||||
val watcherName = nameField.text
|
||||
contents.clear()
|
||||
contents += setLocationPanel
|
||||
|
||||
revalidate()
|
||||
repaint()
|
||||
|
||||
socketEventPublisher = new SocketEventPublisher(client, room, watcherName)
|
||||
listenTo(socketEventPublisher)
|
||||
|
||||
val isMaster = client.registerName(roomField.text, nameField.text)
|
||||
if (isMaster) {
|
||||
masterFrame(room).open()
|
||||
}
|
||||
}
|
||||
|
||||
contents += registerPanel
|
||||
|
||||
listenTo(registerButton)
|
||||
listenTo(setLocationButton)
|
||||
|
||||
// Netflix location
|
||||
var (x, y) = (0, 0)
|
||||
|
||||
reactions += {
|
||||
case ButtonClicked(`registerButton`) =>
|
||||
register()
|
||||
case ButtonClicked(`setLocationButton`) =>
|
||||
socketEventPublisher.startPublishing()
|
||||
val myLocation = self.getLocationOnScreen
|
||||
x = myLocation.getX.toInt - 10
|
||||
y = myLocation.getY.toInt - 10
|
||||
setLocationLabel.text = "Netflix location set. You may now minimize this window."
|
||||
case ServerSaidClick(delay) =>
|
||||
client.synchronizedClick(delay.toLong, x, y)
|
||||
setLocationLabel.text = SYNC_LABEL_DEFAULT_TEXT
|
||||
case ServerSaidSpacebar(delay) =>
|
||||
client.synchronizedClick(delay.toLong, x, y)
|
||||
client.synchronizedSpacebar(delay.toLong)
|
||||
setLocationLabel.text = SYNC_LABEL_DEFAULT_TEXT
|
||||
}
|
||||
}
|
||||
82
src/main/scala/com/ianonavy/netflixsync/Room.scala
Normal file
82
src/main/scala/com/ianonavy/netflixsync/Room.scala
Normal file
@ -0,0 +1,82 @@
|
||||
package com.ianonavy.netflixsync
|
||||
|
||||
import java.io._
|
||||
import java.net.Socket
|
||||
|
||||
import akka.actor.{ActorSystem, ActorRef, Props, Actor}
|
||||
|
||||
|
||||
case class AddWatcher(name: String)
|
||||
|
||||
case class WatcherReady(name: String, socket: Socket)
|
||||
|
||||
case class GetSize()
|
||||
|
||||
case class SendMessage(message: String)
|
||||
|
||||
case class CloseAll()
|
||||
|
||||
|
||||
/**
|
||||
* Simple actor representing a Netflix Synx room.
|
||||
* @param name
|
||||
*/
|
||||
class Room(name: String) extends Actor {
|
||||
|
||||
var watchers: Map[String, Socket] = Map()
|
||||
|
||||
implicit def inputStreamWrapper(in: InputStream) =
|
||||
new BufferedReader(new InputStreamReader(in))
|
||||
|
||||
implicit def outputStreamWrapper(out: OutputStream) =
|
||||
new PrintWriter(new OutputStreamWriter(out))
|
||||
|
||||
override def receive: Receive = {
|
||||
case AddWatcher(name) =>
|
||||
watchers += (name -> null)
|
||||
case WatcherReady(name, socket) =>
|
||||
watchers += (name -> socket)
|
||||
case GetSize() =>
|
||||
sender() ! watchers.size
|
||||
case SendMessage(message) =>
|
||||
for ((watcher, socket) <- watchers) {
|
||||
if (socket != null) {
|
||||
val out = new PrintStream(socket.getOutputStream())
|
||||
out.println(message)
|
||||
out.flush()
|
||||
println(s"Server (to $watcher): $message")
|
||||
}
|
||||
}
|
||||
case CloseAll() =>
|
||||
for ((watcher, socket) <- watchers) {
|
||||
if (socket != null) {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton object for static methods. Akka actors are meant to be
|
||||
* instantiated using props on an actor system, so we do that.
|
||||
*/
|
||||
object Room {
|
||||
def props(name: String): Props = Props(new Room(name))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Simple class for storing room actor references by name.
|
||||
*/
|
||||
class RoomRegistry(system: ActorSystem) {
|
||||
var allRooms = Map[String, ActorRef]()
|
||||
|
||||
def getRoom(name: String): ActorRef = {
|
||||
if (allRooms.contains(name)) {
|
||||
return allRooms(name)
|
||||
}
|
||||
val newRoom = system.actorOf(Room.props(name))
|
||||
allRooms += (name -> newRoom)
|
||||
newRoom
|
||||
}
|
||||
}
|
||||
93
src/main/scala/com/ianonavy/netflixsync/Server.scala
Normal file
93
src/main/scala/com/ianonavy/netflixsync/Server.scala
Normal file
@ -0,0 +1,93 @@
|
||||
package com.ianonavy.netflixsync
|
||||
|
||||
import java.net._
|
||||
import java.io._
|
||||
import akka.actor.{ActorRef, ActorSystem}
|
||||
import akka.pattern.ask
|
||||
import akka.util.Timeout
|
||||
|
||||
import scala.concurrent.Await
|
||||
import scala.io._
|
||||
import scala.util.parsing.json.{JSONObject, JSON}
|
||||
import scala.concurrent.duration._
|
||||
|
||||
|
||||
/**
|
||||
* The Netflix Sync server is responsible for allowing clients to register names
|
||||
* for a particular room. When a client registers and sets their socket,
|
||||
* the socket is stored for a particular room and client name. Clients are
|
||||
* also known as "watchers." The first client to register for a room is
|
||||
* designated the master. When the master sends a message to the server,
|
||||
* the message is echoed and propagated to all other clients. The server,
|
||||
* however, does not check that the master client was indeed the first one to
|
||||
* register. Rather, it returns whether or not the registering client is the
|
||||
* first client upon registration.
|
||||
*/
|
||||
object Server {
|
||||
|
||||
val system = ActorSystem("system")
|
||||
val roomRegistry = new RoomRegistry(system)
|
||||
|
||||
def delayFromClient(clientTime: Long): Long = {
|
||||
System.currentTimeMillis - clientTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Main loop for the Netflix sync server. Delegates matched commands to
|
||||
* other functions.
|
||||
*/
|
||||
def main(a: Array[String]) {
|
||||
val server = new ServerSocket(9999)
|
||||
println("Listening on TCP :::9999")
|
||||
implicit val timeout = Timeout(5 seconds)
|
||||
|
||||
while (true) {
|
||||
val s = server.accept()
|
||||
val in = new BufferedSource(s.getInputStream).getLines()
|
||||
val out = new PrintStream(s.getOutputStream)
|
||||
|
||||
var shouldClose = true
|
||||
try {
|
||||
val next = in.next().toString
|
||||
|
||||
println("Client: " + next)
|
||||
|
||||
val command = JSON.parseFull(next)
|
||||
println(roomRegistry.allRooms)
|
||||
|
||||
val res = command match {
|
||||
case Some(m: Map[String, Any]) => m("command") match {
|
||||
case "GET DELAY" =>
|
||||
delayFromClient(m("time").toString.toLong)
|
||||
case "GET NUM REGISTERED" =>
|
||||
val room = roomRegistry.getRoom(m("room").toString)
|
||||
Await.result(room ? GetSize(), timeout.duration).asInstanceOf[Int]
|
||||
case "REGISTER" =>
|
||||
val room = roomRegistry.getRoom(m("room").toString)
|
||||
room ! AddWatcher(m("name").toString)
|
||||
val isMaster = Await.result(room ? GetSize(), timeout.duration).asInstanceOf[Int] == 1
|
||||
JSONObject(Map("isMaster" -> isMaster))
|
||||
case "SET SOCKET" =>
|
||||
val room = roomRegistry.getRoom(m("room").toString)
|
||||
room ! WatcherReady(m("name").toString, s)
|
||||
shouldClose = false
|
||||
case s: String =>
|
||||
val room = roomRegistry.getRoom(m("room").toString)
|
||||
room ! SendMessage(s)
|
||||
"OK"
|
||||
}
|
||||
}
|
||||
if (shouldClose) {
|
||||
println("Server: " + res)
|
||||
out.println(res.toString)
|
||||
out.flush()
|
||||
s.close()
|
||||
}
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
e.printStackTrace()
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.ianonavy.netflixsync
|
||||
|
||||
import scala.swing.Publisher
|
||||
import scala.swing.event.Event
|
||||
|
||||
|
||||
case class ServerSaidClick(delay: Double) extends Event
|
||||
|
||||
case class ServerSaidSpacebar(delay: Double) extends Event
|
||||
|
||||
|
||||
/**
|
||||
* Simple Swing event publisher that connects to the client and waits on a
|
||||
* socket. Once the server responds, it publishes the appropriate Swing event.
|
||||
* @param client The Netflix sync client object
|
||||
* @param roomName The name of the room we registered for
|
||||
* @param watcherName The user's registered name
|
||||
*/
|
||||
class SocketEventPublisher(client: Client, roomName: String,
|
||||
watcherName: String) extends Publisher {
|
||||
/**
|
||||
* Starts the background thread to publish events. Publishes both a
|
||||
* ServerSaidClick and ServerSaidSpacebar message when the server pushes a
|
||||
* SPACEBAR message. Publishes only a ServerSaidClick message when the
|
||||
* server pushes a CLICK message. In either case, the messages are passed
|
||||
* the delay in milliseconds to wait. This delay is calculated by the
|
||||
* client upon registration.
|
||||
*/
|
||||
def startPublishing() {
|
||||
val thread = new Thread(new Runnable {
|
||||
def run() {
|
||||
while (true) {
|
||||
client.setSocket(roomName, watcherName) match {
|
||||
case "SPACEBAR" =>
|
||||
publish(ServerSaidClick(client.delay))
|
||||
publish(ServerSaidSpacebar(client.delay))
|
||||
case "CLICK" =>
|
||||
publish(ServerSaidClick(client.delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
thread.start()
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user