1--[==========================================================================[ 2 syncplay.lua: Syncplay interface module for VLC 3--[==========================================================================[ 4 5 Principal author: Etoh 6 Other contributors: DerGenaue, jb, Pilotat 7 Project: https://syncplay.pl/ 8 Version: 0.3.5 9 10 Note: 11 * This interface module is intended to be used in conjunction with Syncplay. 12 * Syncplay provides synchronized video playback across multiple media player instances over the net. 13 * Syncplay allows group of people who all have the same videos to watch them together wherever they are. 14 * Syncplay is available to download for free from https://syncplay.pl/ 15 16--[==========================================================================[ 17 18 === Installation instructions === 19 20Syncplay should install this automatically to your user folder. 21 22 === Commands and responses === 23 = Note: ? denotes optional responses; * denotes mandatory response; uses \n terminator. 24 25 . 26 ? >> inputstate-change: [<input/no-input>] 27 ? >> filepath-change-notification 28 29 * >> playstate: [<playing/paused/no-input>] 30 * >> position: [<decimal seconds/no-input>] 31 32 get-interface-version 33 * >> interface-version: [syncplay connector version] 34 35 get-vlc-version 36 * >> vlc-version: [VLC version] 37 38 get-duration 39 * >> duration: [<duration/no-input>] 40 41 get-filepath 42 * >> filepath: [<filepath/no-input>] 43 44 get-filename 45 * >> filepath: [<filename/no-input>] 46 47 get-title 48 * >> title: [<title/no-input>] 49 50 set-position: [decimal seconds] 51 ? >> play-error: no-input 52 53 seek-within-title: [decimal seconds] 54 ? >> seek-within-title-error: no-input 55 56 set-playstate: [<playing/paused>] 57 ? >> set-playstate-error: no-input 58 59 set-rate: [decimal rate] 60 ? >> set-rate-error: no-input 61 62 set-title 63 ? >> set-title-error: no-input 64 65 display-osd: [placement on screen <center/left/right/top/bottom/top-left/top-right/bottom-left/bottom-right>], [duration in seconds], [message] 66 ? >> display-osd-error: no-input 67 68 display-secondary-osd: [placement on screen <center/left/right/top/bottom/top-left/top-right/bottom-left/bottom-right>], [duration in seconds], [message] 69 ? >> display-secondary-osd-error: no-input 70 71 load-file: [filepath] 72 * >> load-file-attempted 73 74 close-vlc 75 76 [Unknown command] 77 * >> [Unknown command]-error: unknown-command 78 79--]==========================================================================] 80 81local connectorversion = "0.3.5" 82local vlcversion = vlc.misc.version() 83local vlcmajorversion = tonumber(vlcversion:sub(1,1)) -- get the major version of VLC 84local durationdelay = 500000 -- Pause for get_duration command etc for increased reliability (uses microseconds) 85local loopsleepduration = 2500 -- Pause for every event loop (uses microseconds) 86local quitcheckfrequency = 20 -- Check whether VLC has closed every X loops 87 88local host = "localhost" 89local port 90 91local titlemultiplier = 604800 -- One week 92 93local msgterminator = "\n" 94local msgseperator = ": " 95local argseperator = ", " 96 97local responsemarker = "-response" 98local errormarker = "-error" 99local notificationmarker = "-notification" 100 101local noinput = "no-input" 102local notimplemented = "not-implemented" 103local unknowncommand = "unknown-command" 104local unknownstream = "(Unknown Stream)" 105 106local oldfilepath 107local oldinputstate 108local newfilepath 109local newinputstate 110local oldtitle = 0 111local newtitle = 0 112 113local channel1 114local channel2 115local l 116 117local running = true 118 119 120function radixsafe_tonumber(str) 121 -- Version of tonumber that works with any radix character (but not thousand seperators) 122 -- Based on the public domain VLC common.lua us_tonumber() function 123 124 str = string.gsub(tostring(str), "[^0-9]", ".") 125 local s, i, d = string.match(str, "^([+-]?)(%d*)%.?(%d*)$") 126 if not s or not i or not d then 127 return nil 128 end 129 130 if s == "-" then 131 s = -1 132 else 133 s = 1 134 end 135 if i == "" then 136 i = "0" 137 end 138 if d == nil or d == "" then 139 d = "0" 140 end 141 return s * (tonumber(i) + tonumber(d)/(10^string.len(d))) 142end 143 144-- Start hosting Syncplay interface. 145 146port = radixsafe_tonumber(config["port"]) 147if (port == nil or port < 1) then port = 4123 end 148 149function quit_vlc() 150 running = false 151 vlc.misc.quit() 152end 153 154function detectchanges() 155 -- Detects changes in VLC to report to Syncplay. 156 -- [Used by the poll / "." command] 157 158 local notificationbuffer = "" 159 160 if vlc.object.input() then 161 newinputstate = "input" 162 newfilepath = get_filepath() 163 164 if newfilepath ~= oldfilepath and get_filepath() ~= unknownstream then 165 oldfilepath = newfilepath 166 notificationbuffer = notificationbuffer .. "filepath-change"..notificationmarker..msgterminator 167 end 168 169 local titleerror 170 newtitle, titleerror = get_var("title", 0) 171 if newtitle ~= oldtitle and get_var("time", 0) > 1 then 172 vlc.misc.mwait(vlc.misc.mdate() + durationdelay) -- Don't give new title with old time 173 end 174 oldtitle = newtitle 175 notificationbuffer = notificationbuffer .. "playstate"..msgseperator..tostring(get_play_state())..msgterminator 176 notificationbuffer = notificationbuffer .. "position"..msgseperator..tostring(get_time())..msgterminator 177 else 178 notificationbuffer = notificationbuffer .. "playstate"..msgseperator..noinput..msgterminator 179 notificationbuffer = notificationbuffer .. "position"..msgseperator..noinput..msgterminator 180 newinputstate = noinput 181 end 182 183 if newinputstate ~= oldinputstate then 184 oldinputstate = newinputstate 185 notificationbuffer = notificationbuffer.."inputstate-change"..msgseperator..tostring(newinputstate)..msgterminator 186 end 187 188 return notificationbuffer 189end 190 191function get_args (argument, argcount) 192 -- Converts comma-space-seperated values into array of a given size, with last item absorbing all remaining data if needed. 193 -- [Used by the display-osd command] 194 195 local argarray = {} 196 local index 197 local i 198 local argbuffer 199 200 argbuffer = argument 201 202 for i = 1, argcount,1 do 203 if i == argcount then 204 if argbuffer == nil then 205 argarray[i] = "" 206 else 207 argarray[i] = argbuffer 208 end 209 else 210 if string.find(argbuffer, argseperator) then 211 index = string.find(argbuffer, argseperator) 212 argarray[i] = string.sub(argbuffer, 0, index - 1) 213 argbuffer = string.sub(argbuffer, index + string.len(argseperator)) 214 else 215 argarray[i] = "" 216 end 217 end 218 219 end 220 221 return argarray 222 223end 224 225 226function get_var( vartoget, fallbackvar ) 227 -- [Used by the poll / '.' command to get time] 228 229 local response 230 local errormsg 231 local input = vlc.object.input() 232 233 if input then 234 response = vlc.var.get(input,tostring(vartoget)) 235 else 236 response = fallbackvar 237 errormsg = noinput 238 end 239 240 if vlcmajorversion > 2 and vartoget == "time" then 241 response = response / 1000000 242 end 243 244 return response, errormsg 245end 246 247 248function set_var(vartoset, varvalue) 249 -- [Used by the set-time and set-rate commands] 250 251 local errormsg 252 local input = vlc.object.input() 253 254 if vlcmajorversion > 2 and vartoset == "time" then 255 varvalue = varvalue * 1000000 256 end 257 258 if input then 259 vlc.var.set(input,tostring(vartoset),varvalue) 260 else 261 errormsg = noinput 262 end 263 264 return errormsg 265end 266 267function get_time() 268 local realtime, errormsg, longtime, title, titletime 269 realtime, errormsg = get_var("time", 0) -- Seconds 270 if errormsg ~= nil and errormsg ~= "" then 271 return errormsg 272 end 273 274 title = get_var("title", 0) 275 276 if errormsg ~= nil and errormsg ~= "" then 277 return realtime 278 end 279 titletime = title * titlemultiplier -- weeks 280 longtime = titletime + realtime 281 return longtime 282end 283 284function set_time ( timetoset) 285 local input = vlc.object.input() 286 if input then 287 local response, errormsg, realtime, titletrack 288 realtime = timetoset % titlemultiplier 289 oldtitle = radixsafe_tonumber(get_var("title", 0)) 290 newtitle = (timetoset - realtime) / titlemultiplier 291 if oldtitle ~= newtitle and newtitle > -1 then 292 set_var("title", radixsafe_tonumber(newtitle)) 293 end 294 errormsg = set_var("time", radixsafe_tonumber(realtime)) 295 return errormsg 296 else 297 return noinput 298 end 299end 300 301function get_play_state() 302 -- [Used by the get-playstate command] 303 304 local response 305 local errormsg 306 local input = vlc.object.input() 307 308 if input then 309 response = vlc.playlist.status() 310 else 311 errormsg = noinput 312 end 313 314 return response, errormsg 315 316end 317 318function get_filepath () 319 -- [Used by get-filepath command] 320 321 local response 322 local errormsg 323 local item 324 local input = vlc.object.input() 325 326 if input then 327 local item = vlc.input.item() 328 if item then 329 if string.find(item:uri(),"file://") then 330 response = vlc.strings.decode_uri(item:uri()) 331 elseif string.find(item:uri(),"dvd://") or string.find(item:uri(),"simpledvd://") then 332 response = ":::DVD:::" 333 else 334 local metas = item:metas() 335 if metas and metas["url"] and string.len(metas["url"]) > 0 then 336 response = metas["url"] 337 elseif item:uri() and string.len(item:uri()) > 0 then 338 response = item:uri() 339 else 340 response = unknownstream 341 end 342 end 343 else 344 errormsg = noinput 345 end 346 else 347 errormsg = noinput 348 end 349 350 return response, errormsg 351end 352 353function get_filename () 354 -- [Used by get-filename command] 355 356 local response 357 local index 358 local filename 359 filename = errormerge(get_filepath()) 360 if filename == unknownstream then 361 return unknownstream 362 end 363 if filename == "" then 364 local input = vlc.object.input() 365 if input then 366 local item = vlc.input.item() 367 if item then 368 if item.name then 369 response = ":::("..item.title..")" 370 return response 371 end 372 end 373 end 374 end 375 376 if(filename ~= nil) and (filename ~= "") and (filename ~= noinput) then 377 index = string.len(tostring(string.match(filename, ".*/"))) 378 if string.sub(filename,1,3) == ":::" then 379 return filename 380 elseif index then 381 response = string.sub(tostring(filename), index+1) 382 end 383 else 384 response = noinput 385 end 386 387 return response 388end 389 390function get_duration () 391 -- [Used by get-duration command] 392 393 local response 394 local errormsg 395 local item 396 local input = vlc.object.input() 397 398 if input then 399 local item = vlc.input.item() 400 -- Try to get duration, which might not be available straight away 401 local i = 0 402 response = 0 403 repeat 404 vlc.misc.mwait(vlc.misc.mdate() + durationdelay) 405 if item and item:duration() then 406 response = item:duration() 407 if response < 1 then 408 response = 0 409 elseif string.sub(vlcversion,1,5) == "3.0.0" and response > 2147 and math.abs(response-(vlc.var.get(input,"length")/1000000)) > 5 then 410 errormsg = "invalid-32-bit-value" 411 end 412 end 413 i = i + 1 414 until response > 1 or i > 5 415 else 416 errormsg = noinput 417 end 418 419 return response, errormsg 420end 421 422 423function display_osd ( argument ) 424 -- [Used by display-osd command] 425 local errormsg 426 local osdarray 427 local input = vlc.object.input() 428 if input and vlc.osd and vlc.object.vout() then 429 if not channel1 then 430 channel1 = vlc.osd.channel_register() 431 end 432 if not channel2 then 433 channel2 = vlc.osd.channel_register() 434 end 435 osdarray = get_args(argument,3) 436 --position, duration, message -> message, , position, duration (converted from seconds to microseconds) 437 local osdduration = radixsafe_tonumber(osdarray[2]) * 1000 * 1000 438 vlc.osd.message(osdarray[3],channel1,osdarray[1],osdduration) 439 else 440 errormsg = noinput 441 end 442 return errormsg 443end 444 445function display_secondary_osd ( argument ) 446 -- [Used by display-secondary-osd command] 447 local errormsg 448 local osdarray 449 local input = vlc.object.input() 450 if input and vlc.osd and vlc.object.vout() then 451 if not channel1 then 452 channel1 = vlc.osd.channel_register() 453 end 454 if not channel2 then 455 channel2 = vlc.osd.channel_register() 456 end 457 osdarray = get_args(argument,3) 458 --position, duration, message -> message, , position, duration (converted from seconds to microseconds) 459 local osdduration = radixsafe_tonumber(osdarray[2]) * 1000 * 1000 460 vlc.osd.message(osdarray[3],channel2,osdarray[1],osdduration) 461 else 462 errormsg = noinput 463 end 464 return errormsg 465end 466 467function load_file (filepath) 468 -- [Used by load-file command] 469 470 local uri = vlc.strings.make_uri(filepath) 471 vlc.playlist.add({{path=uri}}) 472 return "load-file-attempted\n" 473end 474 475function do_command ( command, argument) 476 -- Processes all commands sent by Syncplay (see protocol, above). 477 478 if command == "." then 479 do return detectchanges() end 480 end 481 local command = tostring(command) 482 local argument = tostring(argument) 483 local errormsg = "" 484 local response = "" 485 486 if command == "get-interface-version" then response = "interface-version"..msgseperator..connectorversion..msgterminator 487 elseif command == "get-vlc-version" then response = "vlc-version"..msgseperator..vlcversion..msgterminator 488 elseif command == "get-duration" then response = "duration"..msgseperator..errormerge(get_duration())..msgterminator 489 elseif command == "get-filepath" then response = "filepath"..msgseperator..errormerge(get_filepath())..msgterminator 490 elseif command == "get-filename" then response = "filename"..msgseperator..errormerge(get_filename())..msgterminator 491 elseif command == "get-title" then response = "title"..msgseperator..errormerge(get_var("title", 0))..msgterminator 492 elseif command == "set-position" then errormsg = set_time(radixsafe_tonumber(argument)) 493 elseif command == "seek-within-title" then errormsg = set_var("time", radixsafe_tonumber(argument)) 494 elseif command == "set-playstate" then errormsg = set_playstate(argument) 495 elseif command == "set-rate" then errormsg = set_var("rate", radixsafe_tonumber(argument)) 496 elseif command == "set-title" then errormsg = set_var("title", radixsafe_tonumber(argument)) 497 elseif command == "display-osd" then errormsg = display_osd(argument) 498 elseif command == "display-secondary-osd" then errormsg = display_secondary_osd(argument) 499 elseif command == "load-file" then response = load_file(argument) 500 elseif command == "close-vlc" then quit_vlc() 501 else errormsg = unknowncommand 502 end 503 504 if (errormsg ~= nil) and (errormsg ~= "") then 505 response = command..errormarker..msgseperator..tostring(errormsg)..msgterminator 506 end 507 508 return response 509 510end 511 512function errormerge(argument, errormsg) 513 -- Used to integrate 'no-input' error messages into command responses. 514 515 if (errormsg ~= nil) and (errormsg ~= "") then 516 do return errormsg end 517 end 518 519 return argument 520end 521 522function set_playstate(argument) 523 -- [Used by the set-playstate command] 524 525 local errormsg 526 local input = vlc.object.input() 527 local playstate 528 playstate, errormsg = get_play_state() 529 530 if playstate ~= "playing" then playstate = "paused" end 531 if ((errormsg ~= noinput) and (playstate ~= argument)) then 532 vlc.playlist.pause() 533 end 534 535 return errormsg 536end 537 538if string.sub(vlcversion,1,2) == "1." or string.sub(vlcversion,1,3) == "2.0" or string.sub(vlcversion,1,3) == "2.1" or string.sub(vlcversion,1,5) == "2.2.0" then 539 vlc.msg.err("This version of VLC does not support Syncplay. Please use VLC 2.2.1+ or an alternative media player.") 540 quit_vlc() 541else 542 l = vlc.net.listen_tcp(host, port) 543 vlc.msg.info("Hosting Syncplay interface on port: "..port) 544end 545 546 -- main loop, which alternates between writing and reading 547 548while running == true do 549 --accept new connections and select active clients 550 local quitcheckcounter = 0 551 local fd = l:accept() 552 local buffer, inputbuffer, responsebuffer = "", "", "" 553 while fd >= 0 and running == true do 554 555 -- handle read mode 556 557 local str = vlc.net.recv ( fd, 1000) 558 559 local responsebuffer 560 if str == nil then str = "" end 561 562 local safestr = string.gsub(tostring(str), "\r", "") 563 if inputbuffer == nil then inputbuffer = "" end 564 565 inputbuffer = inputbuffer .. safestr 566 567 while string.find(inputbuffer, msgterminator) and running == true do 568 local index = string.find(inputbuffer, msgterminator) 569 local request = string.sub(inputbuffer, 0, index - 1) 570 local command 571 local argument 572 inputbuffer = string.sub(inputbuffer, index + string.len(msgterminator)) 573 574 if (string.find(request, msgseperator)) then 575 index = string.find(request, msgseperator) 576 command = string.sub(request, 0, index - 1) 577 argument = string.sub(request, index + string.len(msgseperator)) 578 579 else 580 command = request 581 end 582 583 if (responsebuffer) then 584 responsebuffer = responsebuffer .. do_command(command,argument) 585 else 586 responsebuffer = do_command(command,argument) 587 end 588 589 end 590 591 if (running == false) then 592 net.close(fd) 593 end 594 595 -- handle write mode 596 597 if (responsebuffer and running == true) then 598 vlc.net.send( fd, responsebuffer ) 599 responsebuffer = "" 600 end 601 vlc.misc.mwait(vlc.misc.mdate() + loopsleepduration) -- Don't waste processor time 602 603 -- check if VLC has been closed 604 605 quitcheckcounter = quitcheckcounter + 1 606 607 if quitcheckcounter > quitcheckfrequency then 608 if vlc.volume.get() == -256 then 609 running = false 610 end 611 quitcheckcounter = 0 612 end 613 614 end 615 616end