Browse Source

Initial commit.

Ian Adam Naval 4 years ago
commit
3ec975d130

+ 4
- 0
.gitignore View File

@@ -0,0 +1,4 @@
1
+out/
2
+project/
3
+target/
4
+.idea/

+ 4
- 0
README.md View File

@@ -0,0 +1,4 @@
1
+netflix-sync
2
+============
3
+
4
+Hacked together tool to make long distance Netflix watching a tiny bit easier.

+ 9
- 0
build.sbt View File

@@ -0,0 +1,9 @@
1
+name := "netflix-sync"
2
+
3
+version := "1.0"
4
+
5
+libraryDependencies += "org.scalatest" % "scalatest_2.10" % "2.1.0" % "test"
6
+
7
+libraryDependencies <+= scalaVersion("org.scala-lang" % "scala-swing" % _)
8
+
9
+libraryDependencies += "com.typesafe.akka" % "akka-actor_2.10" % "2.3.4"

+ 197
- 0
src/main/scala/com/ianonavy/netflixsync/Client.scala View File

@@ -0,0 +1,197 @@
1
+package com.ianonavy.netflixsync
2
+
3
+import java.awt.Robot
4
+import java.awt.event.{KeyEvent, InputEvent}
5
+import java.net._
6
+import java.io._
7
+import scala.io._
8
+import scala.util.parsing.json.{JSON, JSONObject}
9
+
10
+
11
+/**
12
+ * Client for interfacing with the Netflix sync server
13
+ * @param host Hostname of the server
14
+ * @param nTimes Number of messages to send to determine delay
15
+ */
16
+class Client(host: String, nTimes: Int) {
17
+
18
+  /**
19
+   * Average delay to the server. Includes clock differences and network lag.
20
+   */
21
+  var delay = 0.0
22
+
23
+  /**
24
+   * Runs a block of code and times it.
25
+   * @param block The block of code to run
26
+   * @tparam R The type that block returns
27
+   * @return A tuple of the number of nanoseconds passed and the results
28
+   */
29
+  def time[R](block: => R): (Long, R) = {
30
+    val t0 = System.nanoTime()
31
+    val result = block
32
+    val t1 = System.nanoTime()
33
+
34
+    (t1 - t0, result)
35
+  }
36
+
37
+  /**
38
+   * Clicks in a particular set of coordinates after waiting a certain number
39
+   * of milliseconds.
40
+   * @param delay The delay in milliseconds to wait before clicking
41
+   * @param x The x coordinate to click
42
+   * @param y The y coordinate to click
43
+   */
44
+  def synchronizedClick(delay: Long, x: Int, y: Int) {
45
+    val robot = new Robot
46
+    Thread.sleep(delay)
47
+    robot.mouseMove(x, y)
48
+    robot.mousePress(InputEvent.BUTTON1_MASK)
49
+    robot.mouseRelease(InputEvent.BUTTON1_MASK)
50
+  }
51
+
52
+  /**
53
+   * Simulates a spacebar press after waiting a certain number of milliseconds.
54
+   * @param delay The delay in milliseconds to wait before pressing space
55
+   */
56
+  def synchronizedSpacebar(delay: Long) {
57
+    val robot = new Robot
58
+    Thread.sleep(delay)
59
+    robot.keyPress(KeyEvent.VK_SPACE)
60
+    robot.keyRelease(KeyEvent.VK_SPACE)
61
+  }
62
+
63
+  /**
64
+   * Sends an object to the server and returns the results as a string.
65
+   * @param obj Map corresponding to the JSON object to send. Should contain 
66
+   *            the key "command"
67
+   * @param close Whether to close the socket after sending the command
68
+   * @return The reply from the server
69
+   */
70
+  def sendCommand(obj: Map[String, Any], close: Boolean): String = {
71
+    val s = new Socket(InetAddress.getByName(host), 9999)
72
+    lazy val in = new BufferedSource(s.getInputStream).getLines()
73
+    val out = new PrintStream(s.getOutputStream)
74
+    out.println(new JSONObject(obj))
75
+    out.flush()
76
+
77
+    val results = in.next().toString
78
+    s.close()
79
+    results
80
+  }
81
+
82
+  /**
83
+   * Sends an object to the server and returns the results as a string. By 
84
+   * default closes the socket after sending the command.
85
+   * @param obj Map corresponding to the JSON object to send. Should contain 
86
+   *            the key "command"
87
+   * @return The reply from the server
88
+   */
89
+  def sendCommand(obj: Map[String, Any]): String = {
90
+    sendCommand(obj, close = true)
91
+  }
92
+
93
+  /**
94
+   * Reports the current system time in milliseconds to the server and 
95
+   * retrieves the difference from the server's time. Returns a positive integer
96
+   * if the server's clock is ahead.
97
+   * @return The number of milliseconds that the server's clock is ahead
98
+   */
99
+  def getDelayFromServer: String = {
100
+    val cmd = Map(
101
+      "command" -> "GET DELAY",
102
+      "time" -> System.currentTimeMillis.toString)
103
+    sendCommand(cmd)
104
+  }
105
+
106
+  /**
107
+   * Gets the number of users registered to a particular room.
108
+   * @param roomName The name of the room.
109
+   * @return The number of registered users in the given room
110
+   */
111
+  def getNumberRegistered(roomName: String): String = {
112
+    val cmd = Map(
113
+      "command" -> "GET NUM REGISTERED",
114
+      "room" -> roomName)
115
+    sendCommand(cmd)
116
+  }
117
+
118
+  /**
119
+   * Registers a name to the given room.
120
+   * @param roomName The name of the room in which to register
121
+   * @param watcherName The name of the watcher to register
122
+   * @return Whether the watcher was the first one in the room (aka the master)
123
+   */
124
+  def registerName(roomName: String, watcherName: String): Boolean = {
125
+    val cmd = Map(
126
+      "command" -> "REGISTER",
127
+      "room" -> roomName,
128
+      "name" -> watcherName)
129
+    val res = JSON.parseFull(sendCommand(cmd))
130
+    val isMaster = res match {
131
+      case Some(m: Map[String, Any]) => m("isMaster") match {
132
+        case isMaster: Boolean => isMaster
133
+      }
134
+    }
135
+    isMaster
136
+  }
137
+
138
+  /**
139
+   * Instructs the server to store the socket for a given room name and 
140
+   * watcher name.
141
+   * @param roomName The name of the relevant room
142
+   * @param watcherName The name of the watcher whose socket we're setting
143
+   * @return
144
+   */
145
+  def setSocket(roomName: String, watcherName: String) = {
146
+    val cmd = Map(
147
+      "command" -> "SET SOCKET",
148
+      "room" -> roomName,
149
+      "name" -> watcherName)
150
+    sendCommand(cmd, close = false)
151
+  }
152
+
153
+  /**
154
+   * Instructs the server to push a "CLICK" message to all clients with set 
155
+   * sockets.
156
+   * @param roomName The name of the relevant room
157
+   */
158
+  def sendClick(roomName: String) {
159
+    val cmd = Map(
160
+      "command" -> "CLICK",
161
+      "room" -> roomName)
162
+    sendCommand(cmd)
163
+  }
164
+
165
+  /**
166
+   * Instructs the server to push a "SPACEBAR" message to all clients with set 
167
+   * sockets.
168
+   * @param roomName The name of the relevant room
169
+   */
170
+  def sendSpacebar(roomName: String) {
171
+    val cmd = Map(
172
+      "command" -> "SPACEBAR",
173
+      "room" -> roomName)
174
+    sendCommand(cmd)
175
+  }
176
+
177
+  /**
178
+   * Sets the 'delay' attribute by polling the server for the offset between
179
+   * its clock and the client's and timing how long that roundtrip request
180
+   * takes in milliseconds.
181
+   */
182
+  def calculateAverageTime() {
183
+    val times = new Array[Long](nTimes)
184
+    val delays = new Array[Long](nTimes)
185
+
186
+    for (i <- 0 to nTimes - 1) {
187
+      val (elapsed, delay) = time {
188
+        getDelayFromServer
189
+      }
190
+      times(i) = elapsed
191
+      delays(i) = delay.toLong
192
+    }
193
+    val avgOffset = times.sum / times.length / 1000000.0
194
+    val avgDelay = delays.sum / delays.length / 1000000.0
195
+    delay = avgOffset + avgDelay
196
+  }
197
+}

+ 18
- 0
src/main/scala/com/ianonavy/netflixsync/Main.scala View File

@@ -0,0 +1,18 @@
1
+package com.ianonavy.netflixsync
2
+
3
+import scala.swing._
4
+
5
+
6
+/**
7
+ * Main Swing instance
8
+ */
9
+object Main extends SimpleSwingApplication {
10
+
11
+  def top = new MainFrame {
12
+    preferredSize = new Dimension(196, 196)
13
+    title = "Netflix Sync"
14
+    resizable = false
15
+    contents = new MainPanel
16
+  }
17
+
18
+}

+ 175
- 0
src/main/scala/com/ianonavy/netflixsync/MainPanel.scala View File

@@ -0,0 +1,175 @@
1
+package com.ianonavy.netflixsync
2
+
3
+import scala.swing.GridBagPanel.Fill
4
+import scala.swing._
5
+import scala.swing.event.ButtonClicked
6
+
7
+
8
+/**
9
+ * Main swing panel for the main frame.
10
+ */
11
+class MainPanel extends FlowPanel {
12
+  var socketEventPublisher: SocketEventPublisher = null
13
+  var client: Client = null
14
+
15
+  val SYNC_LABEL_DEFAULT_TEXT = "Please place me in the middle of your " +
16
+    "Netflix window"
17
+
18
+  val serverField = new TextField {
19
+    text = "localhost"
20
+  }
21
+  val roomField = new TextField
22
+  val nameField = new TextField
23
+
24
+  val registerButton = new Button {
25
+    text = "Register"
26
+  }
27
+  val setLocationButton = new Button {
28
+    text = "Set Location"
29
+  }
30
+  val setLocationLabel = new TextArea {
31
+    text = SYNC_LABEL_DEFAULT_TEXT
32
+    editable = false
33
+    opaque = false
34
+    focusable = false
35
+    cursor = null
36
+    lineWrap = true
37
+    wordWrap = true
38
+  }
39
+
40
+  /**
41
+   * @return A panel containing the elements relevant to registration
42
+   */
43
+  def registerPanel: Panel = new BoxPanel(Orientation.Vertical) {
44
+    contents += new Label("Server:")
45
+    contents += serverField
46
+    contents += new Label("Room:")
47
+    contents += roomField
48
+    contents += new Label("Name:")
49
+    contents += nameField
50
+    contents += registerButton
51
+    border = Swing.EmptyBorder(30, 30, 30, 30)
52
+  }
53
+
54
+  /**
55
+   * @return A panel used for setting the location of the Netflix window
56
+   */
57
+  def setLocationPanel: Panel = new GridBagPanel() {
58
+    val c = new Constraints
59
+    c.fill = Fill.Vertical
60
+    c.gridx = 0
61
+    c.gridy = 0
62
+    layout(setLocationLabel) = c
63
+
64
+    c.gridx = 0
65
+    c.gridy = 1
66
+    c.insets = new Insets(32, 0, 0, 0)
67
+    layout(setLocationButton) = c
68
+    border = Swing.EmptyBorder(30, 30, 30, 30)
69
+  }
70
+
71
+  /**
72
+   * The master control panel for a particular room. Shown only to the first
73
+   * user who registeres into a room.
74
+   * @param room The room to control
75
+   * @return The master control panel
76
+   */
77
+  def masterPanel(room: String): Panel = new BoxPanel(Orientation.Vertical) {
78
+    val label = new Label {
79
+      text = "0 people have joined the room."
80
+    }
81
+    val clickButton = new Button {
82
+      text = "Send Click"
83
+    }
84
+    val spacebarButton = new Button {
85
+      text = "Send Spacebar"
86
+    }
87
+    val refreshButton = new Button {
88
+      text = "Refresh Count"
89
+    }
90
+
91
+    contents += label
92
+    contents += clickButton
93
+    contents += spacebarButton
94
+    contents += refreshButton
95
+    border = Swing.EmptyBorder(30, 30, 30, 30)
96
+
97
+    listenTo(clickButton)
98
+    listenTo(spacebarButton)
99
+    listenTo(refreshButton)
100
+    reactions += {
101
+      case ButtonClicked(`clickButton`) =>
102
+        label.text = "Sending..."
103
+        client.sendClick(room)
104
+      case ButtonClicked(`spacebarButton`) =>
105
+        label.text = "Sending..."
106
+        client.sendSpacebar(room)
107
+      case ButtonClicked(`refreshButton`) =>
108
+        val numPeople = client.getNumberRegistered(room)
109
+        label.text = s"$numPeople people have joined the room."
110
+    }
111
+  }
112
+
113
+  /**
114
+   * Frame for the master panel
115
+   * @param room The room to control
116
+   * @return The master frame
117
+   */
118
+  def masterFrame(room: String) = new MainFrame {
119
+    title = "Netflix Sync"
120
+    contents = masterPanel(room)
121
+  }
122
+
123
+  /**
124
+   * Registers a user to a client at a particular server based on the results
125
+   * of the form elements usually contained in the registerPanel. Starts the
126
+   * socket event publisher, configures the Netflix Sync client and
127
+   * optionally spawns the master frame.
128
+   */
129
+  def register() {
130
+    client = new Client(serverField.text, 10)
131
+    client.calculateAverageTime()
132
+
133
+    val room = roomField.text
134
+    val watcherName = nameField.text
135
+    contents.clear()
136
+    contents += setLocationPanel
137
+
138
+    revalidate()
139
+    repaint()
140
+
141
+    socketEventPublisher = new SocketEventPublisher(client, room, watcherName)
142
+    listenTo(socketEventPublisher)
143
+
144
+    val isMaster = client.registerName(roomField.text, nameField.text)
145
+    if (isMaster) {
146
+      masterFrame(room).open()
147
+    }
148
+  }
149
+
150
+  contents += registerPanel
151
+
152
+  listenTo(registerButton)
153
+  listenTo(setLocationButton)
154
+
155
+  // Netflix location
156
+  var (x, y) = (0, 0)
157
+
158
+  reactions += {
159
+    case ButtonClicked(`registerButton`) =>
160
+      register()
161
+    case ButtonClicked(`setLocationButton`) =>
162
+      socketEventPublisher.startPublishing()
163
+      val myLocation = self.getLocationOnScreen
164
+      x = myLocation.getX.toInt - 10
165
+      y = myLocation.getY.toInt - 10
166
+      setLocationLabel.text = "Netflix location set. You may now minimize this window."
167
+    case ServerSaidClick(delay) =>
168
+      client.synchronizedClick(delay.toLong, x, y)
169
+      setLocationLabel.text = SYNC_LABEL_DEFAULT_TEXT
170
+    case ServerSaidSpacebar(delay) =>
171
+      client.synchronizedClick(delay.toLong, x, y)
172
+      client.synchronizedSpacebar(delay.toLong)
173
+      setLocationLabel.text = SYNC_LABEL_DEFAULT_TEXT
174
+  }
175
+}

+ 82
- 0
src/main/scala/com/ianonavy/netflixsync/Room.scala View File

@@ -0,0 +1,82 @@
1
+package com.ianonavy.netflixsync
2
+
3
+import java.io._
4
+import java.net.Socket
5
+
6
+import akka.actor.{ActorSystem, ActorRef, Props, Actor}
7
+
8
+
9
+case class AddWatcher(name: String)
10
+
11
+case class WatcherReady(name: String, socket: Socket)
12
+
13
+case class GetSize()
14
+
15
+case class SendMessage(message: String)
16
+
17
+case class CloseAll()
18
+
19
+
20
+/**
21
+ * Simple actor representing a Netflix Synx room.
22
+ * @param name
23
+ */
24
+class Room(name: String) extends Actor {
25
+
26
+  var watchers: Map[String, Socket] = Map()
27
+
28
+  implicit def inputStreamWrapper(in: InputStream) =
29
+    new BufferedReader(new InputStreamReader(in))
30
+
31
+  implicit def outputStreamWrapper(out: OutputStream) =
32
+    new PrintWriter(new OutputStreamWriter(out))
33
+
34
+  override def receive: Receive = {
35
+    case AddWatcher(name) =>
36
+      watchers += (name -> null)
37
+    case WatcherReady(name, socket) =>
38
+      watchers += (name -> socket)
39
+    case GetSize() =>
40
+      sender() ! watchers.size
41
+    case SendMessage(message) =>
42
+      for ((watcher, socket) <- watchers) {
43
+        if (socket != null) {
44
+          val out = new PrintStream(socket.getOutputStream())
45
+          out.println(message)
46
+          out.flush()
47
+          println(s"Server (to $watcher): $message")
48
+        }
49
+      }
50
+    case CloseAll() =>
51
+      for ((watcher, socket) <- watchers) {
52
+        if (socket != null) {
53
+          socket.close()
54
+        }
55
+      }
56
+  }
57
+}
58
+
59
+/**
60
+ * Singleton object for static methods. Akka actors are meant to be
61
+ * instantiated using props on an actor system, so we do that.
62
+ */
63
+object Room {
64
+  def props(name: String): Props = Props(new Room(name))
65
+}
66
+
67
+
68
+/**
69
+ * Simple class for storing room actor references by name.
70
+ */
71
+class RoomRegistry(system: ActorSystem) {
72
+  var allRooms = Map[String, ActorRef]()
73
+
74
+  def getRoom(name: String): ActorRef = {
75
+    if (allRooms.contains(name)) {
76
+      return allRooms(name)
77
+    }
78
+    val newRoom = system.actorOf(Room.props(name))
79
+    allRooms += (name -> newRoom)
80
+    newRoom
81
+  }
82
+}

+ 93
- 0
src/main/scala/com/ianonavy/netflixsync/Server.scala View File

@@ -0,0 +1,93 @@
1
+package com.ianonavy.netflixsync
2
+
3
+import java.net._
4
+import java.io._
5
+import akka.actor.{ActorRef, ActorSystem}
6
+import akka.pattern.ask
7
+import akka.util.Timeout
8
+
9
+import scala.concurrent.Await
10
+import scala.io._
11
+import scala.util.parsing.json.{JSONObject, JSON}
12
+import scala.concurrent.duration._
13
+
14
+
15
+/**
16
+ * The Netflix Sync server is responsible for allowing clients to register names
17
+ * for a particular room. When a client registers and sets their socket,
18
+ * the socket is stored for a particular room and client name. Clients are
19
+ * also known as "watchers." The first client to register for a room is
20
+ * designated the master. When the master sends a message to the server,
21
+ * the message is echoed and propagated to all other clients. The server,
22
+ * however, does not check that the master client was indeed the first one to
23
+ * register. Rather, it returns whether or not the registering client is the
24
+ * first client upon registration.
25
+ */
26
+object Server {
27
+
28
+  val system = ActorSystem("system")
29
+  val roomRegistry = new RoomRegistry(system)
30
+
31
+  def delayFromClient(clientTime: Long): Long = {
32
+    System.currentTimeMillis - clientTime
33
+  }
34
+
35
+  /**
36
+   * Main loop for the Netflix sync server. Delegates matched commands to
37
+   * other functions.
38
+   */
39
+  def main(a: Array[String]) {
40
+    val server = new ServerSocket(9999)
41
+    println("Listening on TCP :::9999")
42
+    implicit val timeout = Timeout(5 seconds)
43
+
44
+    while (true) {
45
+      val s = server.accept()
46
+      val in = new BufferedSource(s.getInputStream).getLines()
47
+      val out = new PrintStream(s.getOutputStream)
48
+
49
+      var shouldClose = true
50
+      try {
51
+        val next = in.next().toString
52
+
53
+        println("Client: " + next)
54
+
55
+        val command = JSON.parseFull(next)
56
+        println(roomRegistry.allRooms)
57
+
58
+        val res = command match {
59
+          case Some(m: Map[String, Any]) => m("command") match {
60
+            case "GET DELAY" =>
61
+              delayFromClient(m("time").toString.toLong)
62
+            case "GET NUM REGISTERED" =>
63
+              val room = roomRegistry.getRoom(m("room").toString)
64
+              Await.result(room ? GetSize(), timeout.duration).asInstanceOf[Int]
65
+            case "REGISTER" =>
66
+              val room = roomRegistry.getRoom(m("room").toString)
67
+              room ! AddWatcher(m("name").toString)
68
+              val isMaster = Await.result(room ? GetSize(), timeout.duration).asInstanceOf[Int] == 1
69
+              JSONObject(Map("isMaster" -> isMaster))
70
+            case "SET SOCKET" =>
71
+              val room = roomRegistry.getRoom(m("room").toString)
72
+              room ! WatcherReady(m("name").toString, s)
73
+              shouldClose = false
74
+            case s: String =>
75
+              val room = roomRegistry.getRoom(m("room").toString)
76
+              room ! SendMessage(s)
77
+              "OK"
78
+          }
79
+        }
80
+        if (shouldClose) {
81
+          println("Server: " + res)
82
+          out.println(res.toString)
83
+          out.flush()
84
+          s.close()
85
+        }
86
+      } catch {
87
+        case e: Exception =>
88
+          e.printStackTrace()
89
+          s.close()
90
+      }
91
+    }
92
+  }
93
+}

+ 45
- 0
src/main/scala/com/ianonavy/netflixsync/SocketEventPublisher.scala View File

@@ -0,0 +1,45 @@
1
+package com.ianonavy.netflixsync
2
+
3
+import scala.swing.Publisher
4
+import scala.swing.event.Event
5
+
6
+
7
+case class ServerSaidClick(delay: Double) extends Event
8
+
9
+case class ServerSaidSpacebar(delay: Double) extends Event
10
+
11
+
12
+/**
13
+ * Simple Swing event publisher that connects to the client and waits on a 
14
+ * socket. Once the server responds, it publishes the appropriate Swing event.
15
+ * @param client The Netflix sync client object
16
+ * @param roomName The name of the room we registered for
17
+ * @param watcherName The user's registered name
18
+ */
19
+class SocketEventPublisher(client: Client, roomName: String,
20
+                           watcherName: String) extends Publisher {
21
+  /**
22
+   * Starts the background thread to publish events. Publishes both a
23
+   * ServerSaidClick and ServerSaidSpacebar message when the server pushes a
24
+   * SPACEBAR message. Publishes only a ServerSaidClick message when the
25
+   * server pushes a CLICK message. In either case, the messages are passed
26
+   * the delay in milliseconds to wait. This delay is calculated by the
27
+   * client upon registration.
28
+   */
29
+  def startPublishing() {
30
+    val thread = new Thread(new Runnable {
31
+      def run() {
32
+        while (true) {
33
+          client.setSocket(roomName, watcherName) match {
34
+            case "SPACEBAR" =>
35
+              publish(ServerSaidClick(client.delay))
36
+              publish(ServerSaidSpacebar(client.delay))
37
+            case "CLICK" =>
38
+              publish(ServerSaidClick(client.delay))
39
+          }
40
+        }
41
+      }
42
+    })
43
+    thread.start()
44
+  }
45
+}

Loading…
Cancel
Save