anndl: Replaced with new Python version.
[utils.git] / mpsync
1 #!/usr/bin/python3
2
3 import os, sys, getopt, io, termios
4 import socket, json, pprint
5
6 class key(object):
7     def __init__(self, nm):
8         self.nm = nm
9     def __repr__(self):
10         return "<key %s>" % (self.nm,)
11 class akey(key):
12     def __init__(self, cc):
13         super().__init__(repr(cc))
14         self.cc = cc
15     def __eq__(self, o):
16         return self.cc == o
17
18 class rawtty(object):
19     K_LEFT = key("K_LEFT")
20     K_RIGHT = key("K_RIGHT")
21     K_UP = key("K_UP")
22     K_DOWN = key("K_DOWN")
23     MOD_SHIFT = key("MOD_SHIFT")
24     MOD_META = key("MOD_META")
25     MOD_CTRL = key("MOD_CTRL")
26
27     def __init__(self, *, path="/dev/tty"):
28         self.io = io.FileIO(os.open(path, os.O_RDWR | os.O_NOCTTY), "r+")
29         attr = termios.tcgetattr(self.io.fileno())
30         self.bka = list(attr)
31         attr[3] &= ~termios.ECHO & ~termios.ICANON
32         termios.tcsetattr(self.io.fileno(), termios.TCSANOW, attr)
33
34     def getc(self):
35         b = self.io.read(1)
36         return None if b == b"" else b[0]
37
38     _csikeys = {'A': K_UP, 'B': K_DOWN, 'C': K_RIGHT, 'D': K_LEFT}
39     def readkey(self):
40         c = self.getc()
41         if c == 27:
42             c = self.getc()
43             if c == 27:
44                 return akey("\x1b"), set()
45             elif c == ord('O'):
46                 return None, set()
47             elif c == ord('['):
48                 pars = []
49                 par = None
50                 while True:
51                     c = self.getc()
52                     if 48 <= c <= 57:
53                         if par is None:
54                             par = 0
55                         par = (par * 10) + (c - 48)
56                     elif c == ord(';'):
57                         pars.append(par)
58                         par = None
59                     else:
60                         if par is not None:
61                             pars.append(par)
62                         break
63                 if c == ord('~'):
64                     key = None
65                 elif chr(c) in self._csikeys:
66                     key = self._csikeys[chr(c)]
67                 else:
68                     key = None
69                 mods = set()
70                 if len(pars) > 1:
71                     if (pars[1] - 1) & 1:
72                         mods.add(self.MOD_SHIFT)
73                     if (pars[1] - 1) & 2:
74                         mods.add(self.MOD_META)
75                     if (pars[1] - 1) & 4:
76                         mods.add(self.MOD_CTRL)
77                 return key, mods
78             else:
79                 return akey(chr(c)), [self.MOD_META]
80         else:
81             return akey(chr(c)), set()
82
83     def close(self):
84         termios.tcsetattr(self.io.fileno(), termios.TCSANOW, self.bka)
85         self.io.close()
86
87     def __enter__(self):
88         return self
89
90     def __exit__(self, *exc):
91         self.close()
92         return False
93
94 class target(object):
95     def __init__(self, path):
96         self.path = path
97         self.sk = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
98         self.sk.connect(path)
99         self.obuf = bytearray()
100         self.ibuf = bytearray()
101         self.fps = self.getprop("container-fps")
102
103     def write(self, data):
104         self.obuf.extend(data)
105
106     def flush(self):
107         while len(self.obuf) > 0:
108             ret = self.sk.send(self.obuf)
109             self.obuf[:ret] = b""
110
111     def recv(self, hint=1024):
112         data = self.sk.recv(hint)
113         if data == b"":
114             raise EOFError()
115         self.ibuf.extend(data)
116
117     def readline(self):
118         p = 0
119         while True:
120             p2 = self.ibuf.find(b'\n', p)
121             if p2 != -1:
122                 ret = bytes(self.ibuf[:p2])
123                 self.ibuf[:p2 + 1] = b""
124                 return ret
125             p = len(self.ibuf)
126             self.recv()
127
128     def send(self, data):
129         self.write(data)
130         self.flush()
131
132     def getresp(self):
133         while True:
134             resp = json.loads(self.readline().decode("utf-8"))
135             if "event" in resp:
136                 continue
137             return resp
138
139     def runcmd(self, *cmd):
140         self.send(json.dumps({"command": cmd}).encode("utf-8") + b"\n")
141         resp = self.getresp()
142         if "error" not in resp:
143             sys.stderr.write("mpsync: strange response from %s: %r\n" % (self.path, resp))
144         if resp["error"] != "success":
145             sys.stderr.write("mpsync: error response from %s: %r\n" % (self.path, resp))
146         return resp
147
148     def getprop(self, pname):
149         return self.runcmd("get_property", pname)["data"]
150
151     def setprop(self, pname, val):
152         self.runcmd("set_property", pname, val)
153
154 def usage(out):
155     out.write("usage: mpsync [-h] SOCKET...\n")
156     out.write("players: mpv -input-unix-socket SOCKET -pause [ARGS...] FILE\n")
157
158 opts, args = getopt.getopt(sys.argv[1:], "h")
159 for o, a in opts:
160     if o == "-h":
161         usage(sys.stdout)
162         sys.exit(0)
163 if len(args) < 1:
164     usage(sys.stderr)
165     sys.exit(1)
166 for path in args:
167     if not os.path.exists(path):
168         sys.stderr.write("mpsync: %s: no such file or directory\n" % path)
169         sys.exit(1)
170
171 targets = []
172 for path in args:
173     targets.append(target(path))
174
175 def runcmd(*cmd):
176     for tgt in targets:
177         tgt.runcmd(*cmd)
178
179 def simulcmd(*cmd):
180     cmd = json.dumps({"command": cmd}).encode("utf-8") + b"\n"
181     for tgt in targets:
182         tgt.send(cmd)
183     for tgt in targets:
184         resp = tgt.getresp()
185         if "error" not in resp:
186             sys.stderr.write("mpsync: strange response from %s: %r\n" % (tgt.path, resp))
187         if resp["error"] != "success":
188             sys.stderr.write("mpsync: error response from %s: %r\n" % (tgt.path, resp))
189
190 def relseek(ss, offsets):
191     cur = targets[0].getprop("time-pos")
192     for tgt, off in zip(targets, offsets):
193         tgt.setprop("time-pos", cur + ss + off)
194
195 def getoffsets():
196     opos = targets[0].getprop("time-pos")
197     ret = []
198     for tgt in targets:
199         ret.append(tgt.getprop("time-pos") - opos)
200     return ret
201
202 def main(tty):
203     runcmd("set_property", "hr-seek", "yes")
204     paused = targets[0].getprop("pause")
205     mutemode = 0
206     offsets = [0.0] * len(targets)
207     while True:
208         c, mods = tty.readkey()
209         if c == 'q':
210             return
211         elif c == 'Q':
212             runcmd("quit")
213             return
214         elif c == ' ':
215             paused = not paused
216             simulcmd("set_property", "pause", paused)
217         elif c == rawtty.K_LEFT and not mods:
218             relseek(-10, offsets)
219         elif c == rawtty.K_RIGHT and not mods:
220             relseek(10, offsets)
221         elif c == rawtty.K_UP and not mods:
222             relseek(60, offsets)
223         elif c == rawtty.K_DOWN and not mods:
224             relseek(-60, offsets)
225         elif c == rawtty.K_LEFT and mods == {rawtty.MOD_SHIFT}:
226             relseek(-2, offsets)
227         elif c == rawtty.K_RIGHT and mods == {rawtty.MOD_SHIFT}:
228             relseek(2, offsets)
229         elif c == rawtty.K_UP and mods == {rawtty.MOD_SHIFT}:
230             relseek(5, offsets)
231         elif c == rawtty.K_DOWN and mods == {rawtty.MOD_SHIFT}:
232             relseek(-5, offsets)
233         elif c == 's':
234             relseek(0, offsets)
235         elif c == 'm':
236             mutemode = (mutemode + 1) % 3
237             if mutemode == 0:
238                 for tgt in targets:
239                     tgt.setprop("mute", False)
240             elif mutemode == 1:
241                 for i, tgt in enumerate(targets):
242                     tgt.setprop("mute", i != 0)
243             elif mutemode == 2:
244                 for tgt in targets:
245                     tgt.setprop("mute", True)
246             targets[0].runcmd("show_text", "Audio mode: %s" % ["All", "One", "None"][mutemode])
247         elif c == 'S':
248             offsets = getoffsets()
249             for tgt, off in zip(targets, offsets):
250                 tgt.runcmd("show_text", "Offset: %i" % round(off * tgt.fps))
251         elif c == '.':
252             runcmd("frame_step")
253             paused = True
254         elif c == ',':
255             runcmd("frame_back_step")
256             paused = True
257
258 with rawtty() as tty:
259     try:
260         main(tty)
261     except KeyboardInterrupt:
262         pass