Compare commits
20 Commits
ad9281b609
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e44a07ba | |||
| 6b8ad1f5d4 | |||
| e01589aab8 | |||
| 265efd1db0 | |||
| e0838be9b8 | |||
| 145bf4d7b1 | |||
| 9ded3dfdc6 | |||
| a4886c8ae6 | |||
| fb9fc290ad | |||
| d433179db4 | |||
| 70a7f3768e | |||
| 596dddd630 | |||
| ee173f7eae | |||
| 917af288ab | |||
| 180e4d2bf4 | |||
| f4ac82de05 | |||
| 8f46a3e78a | |||
| 449fae8e22 | |||
| 467a54c607 | |||
| e4640181c3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ gradlew*
|
|||||||
|
|
||||||
# Ignore Gradle build output directory
|
# Ignore Gradle build output directory
|
||||||
build
|
build
|
||||||
|
.idea
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Stephen Byrne
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# obsdc
|
||||||
|
|
||||||
|
OBS control from D-Bus messages
|
||||||
|
|
||||||
|
Send a D-Bus signal to perform an operation with `src/obsdc-signal`, which takes a single operation command
|
||||||
|
as an argument, and performs an action in OBS.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
* `SCENE_NEXT` - switch to the next scene
|
||||||
|
* `SCENE_PREV` - switch to the previous scene
|
||||||
|
* `SCENE_#` - switch to the indicated scene
|
||||||
|
* replace `#` with scene index, starting with 1.
|
||||||
|
* `STUDIO_TRANSITION` - transition the studio preview and program
|
||||||
|
* `STUDIO_MODE_TOGGLE` - toggle studio mode
|
||||||
|
* `PAN_UP` - move the top unlocked source up 50px
|
||||||
|
* `PAN_DOWN` - move the top unlocked source down 50px
|
||||||
|
* `PAN_LEFT` - move the top unlocked source left 50px
|
||||||
|
* `PAN_RIGHT` - move the top unlocked source right 50pxt
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is read from `$XDG_CONFIG_HOME/net.eksb.obsdc/config.properties`
|
||||||
|
|
||||||
|
### Configuration options
|
||||||
|
|
||||||
|
* `host` - OBS websocket host (default: `localhost`)
|
||||||
|
* `port` - OBS websocket port (default: `4455`)
|
||||||
|
* `password` - OBS websocket password (required)
|
||||||
|
|
||||||
|
### systemd
|
||||||
|
|
||||||
|
A systemd unit file ([obsdc.service](src/main/dist/obsdc.service)) is included.
|
||||||
|
|
||||||
|
1. Copy the file to `~/.config/systemd/user/`.
|
||||||
|
2. Edit the `ExecStart` line for where you have installed obsdc.
|
||||||
|
3. `systemctl --user enable obsdc`
|
||||||
|
4. `systemctl --user start obsdc`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
`gradle build`
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
1. `tar xf build/distributions/obsdc.tar`
|
||||||
|
2. `cd obsdc`
|
||||||
|
3. Create config file. See [#Configuration].
|
||||||
|
4. `bin/obsdc`
|
||||||
|
5. `bin/obsdc-signal` to send an op.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
Start at [Main](src/main/kotlin/net/eksb/obsdc/Main.kt).
|
||||||
|
|
||||||
|
[Obs](src/main/kotlin/net/eksb/obsdc/Obs.kt) is a wrapper around
|
||||||
|
[obs-websocket-java](https://github.com/obs-websocket-community-projects/obs-websocket-java)
|
||||||
|
that handles reconnects and queuing operations until the websocket it ready.
|
||||||
|
|
||||||
|
[OpRunner](src/main/kotlin/net/eksb/obsdc/OpRunner.kt) has the logic to run [Op](src/main/kotlin/net/eksb/obsdc/Op.kt)s
|
||||||
|
using an `Obs`.
|
||||||
|
|
||||||
|
[DBus](src/main/kotlin/net/eksb/obsdc/DBus.kt) listens for the `D-Bus` signals and calls `OpRunner`.
|
||||||
|
|
||||||
|
## History/Rationale
|
||||||
|
|
||||||
|
I usually code on a 4k monitor in portrait mode. To show what I am doing on video calls,
|
||||||
|
I use OBS to share that monitor, but it is a 2160x4096 source in a 1920x1080 scene.
|
||||||
|
|
||||||
|
I have
|
||||||
|
[a macropad](https://keeb.io/collections/bdn9-collection/products/bdn9-rev-2-3x3-9-key-macropad-rotary-encoder-and-rgb)
|
||||||
|
with three knobs.
|
||||||
|
My [window manager](https://swaywm.org/) is configured to call `obsdc-signal` with
|
||||||
|
`PAN_UP`/`PAN_DOWN` for the keys emitted by the left knob,
|
||||||
|
`PAN_RIGHT`/`PAN_LEFT` for the keys emitted by the right knob, and
|
||||||
|
`SCENE_NEXT`/`SCENE_PREV` for the keys emitted by the center knob.
|
||||||
|
The regular keys on the macropad are mapped to the other actions.
|
||||||
|
|
||||||
|
So now I can quickly control what people can see without having to focus (or even make visible) the OBS window.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("org.jetbrains.kotlin.jvm") version "1.9.0"
|
id("org.jetbrains.kotlin.jvm") version "2.0.0"
|
||||||
application
|
application
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ dependencies {
|
|||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.3")
|
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.3")
|
||||||
|
|
||||||
implementation("com.github.hypfvieh:dbus-java-core:4.3.1")
|
implementation("com.github.hypfvieh:dbus-java-core:5.0.0")
|
||||||
runtimeOnly("com.github.hypfvieh:dbus-java-transport-native-unixsocket:4.3.1")
|
runtimeOnly("com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0")
|
||||||
|
|
||||||
implementation("io.obs-websocket.community:client:2.0.0")
|
implementation("io.obs-websocket.community:client:2.0.0")
|
||||||
|
|
||||||
|
|||||||
10
src/main/dist/obsdc.service
vendored
Normal file
10
src/main/dist/obsdc.service
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=OBSDC
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=dbus
|
||||||
|
BusName=net.eksb.obsdc
|
||||||
|
ExecStart=/opt/obsdc/bin/obsdc
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -3,55 +3,55 @@ package net.eksb.obsdc
|
|||||||
import org.freedesktop.dbus.annotations.DBusInterfaceName
|
import org.freedesktop.dbus.annotations.DBusInterfaceName
|
||||||
import org.freedesktop.dbus.connections.impl.DBusConnection
|
import org.freedesktop.dbus.connections.impl.DBusConnection
|
||||||
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
|
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
|
||||||
|
import org.freedesktop.dbus.exceptions.DBusException
|
||||||
import org.freedesktop.dbus.interfaces.DBusInterface
|
import org.freedesktop.dbus.interfaces.DBusInterface
|
||||||
import org.freedesktop.dbus.interfaces.DBusSigHandler
|
import org.freedesktop.dbus.interfaces.DBusSigHandler
|
||||||
import org.freedesktop.dbus.messages.DBusSignal
|
import org.freedesktop.dbus.messages.DBusSignal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.util.concurrent.BlockingQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
class DBus(private val q: BlockingQueue<Op>) {
|
/**
|
||||||
|
* Listen to signals on the session DBUS, and send the ops to [q].
|
||||||
|
*
|
||||||
|
* To send an op signal:
|
||||||
|
* `dbus-send --session --type=signal --dest=net.eksb.obsdc / net.eksb.Obsdc.Signal string:{OP_NAME}`
|
||||||
|
*
|
||||||
|
* `{OP_NAME}` is the name of an [Op].
|
||||||
|
*
|
||||||
|
* `src/scripts/obsdc-signal` takes an [Op] name and sends the signal.
|
||||||
|
*/
|
||||||
|
class DBus(private val opHandler:(String)->Unit): AutoCloseable {
|
||||||
|
|
||||||
private val thread = thread(
|
// To monitor DBUS: `dbus-monitor`
|
||||||
start = true,
|
// To see what is registered: `qdbus net.eksb.obsdc /`
|
||||||
isDaemon = true,
|
|
||||||
name = "dbus",
|
|
||||||
) {
|
|
||||||
DBusConnectionBuilder.forSessionBus().build().use { dbus ->
|
|
||||||
|
|
||||||
|
val dbus = DBusConnectionBuilder.forSessionBus().build()
|
||||||
|
|
||||||
|
init {
|
||||||
// These lines are not necessary to handle signals, but are necessary to register methods.
|
// These lines are not necessary to handle signals, but are necessary to register methods.
|
||||||
dbus.requestBusName("net.eksb.obsdc")
|
try {
|
||||||
|
dbus.requestBusName(BUS_NAME)
|
||||||
|
} catch (e:DBusException) {
|
||||||
|
error("Error requesting bus. Already running?")
|
||||||
|
}
|
||||||
dbus.exportObject("/", ObsdcDBusInterfaceImpl())
|
dbus.exportObject("/", ObsdcDBusInterfaceImpl())
|
||||||
|
|
||||||
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
|
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
|
||||||
log.info("signal: ${signal.op}")
|
log.debug("signal: ${signal.op}")
|
||||||
val op = Op.valueOf(signal.op)
|
opHandler.invoke(signal.op)
|
||||||
log.info("op: ${op}")
|
|
||||||
try {
|
|
||||||
q.offer(op, 1, TimeUnit.SECONDS)
|
|
||||||
} catch (e:InterruptedException) {
|
|
||||||
log.debug("queue offer interrupted")
|
|
||||||
}
|
}
|
||||||
}
|
log.debug("DBUS initialized")
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(60_000)
|
|
||||||
} catch (e:InterruptedException) {
|
|
||||||
log.info("interrupted")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("done")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
override fun close() {
|
||||||
thread.interrupt()
|
dbus.releaseBusName(BUS_NAME)
|
||||||
|
dbus.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val BUS_NAME = "net.eksb.obsdc"
|
||||||
private val log = LoggerFactory.getLogger(DBus::class.java)
|
private val log = LoggerFactory.getLogger(DBus::class.java)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified T: DBusSignal> DBusConnection.addSigHandler(handler: DBusSigHandler<T>) {
|
inline fun <reified T: DBusSignal> DBusConnection.addSigHandler(handler: DBusSigHandler<T>) {
|
||||||
addSigHandler(T::class.java, handler)
|
addSigHandler(T::class.java, handler)
|
||||||
@@ -62,24 +62,10 @@ interface ObsdcDBusInterface: DBusInterface {
|
|||||||
fun echo(message:String): String
|
fun echo(message:String): String
|
||||||
class Signal(path:String, val op:String): DBusSignal(path, op)
|
class Signal(path:String, val op:String): DBusSignal(path, op)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ObsdcDBusInterfaceImpl: ObsdcDBusInterface {
|
class ObsdcDBusInterfaceImpl: ObsdcDBusInterface {
|
||||||
override fun echo(message: String):String {
|
override fun echo(message: String):String {
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
override fun getObjectPath(): String = "/"
|
override fun getObjectPath(): String = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
Monitor:
|
|
||||||
`dbus-monitor`
|
|
||||||
|
|
||||||
See what is registered:
|
|
||||||
`qdbus net.eksb.obsdc /`
|
|
||||||
|
|
||||||
Send signal:
|
|
||||||
`dbus-send --session --type=signal --dest=net.eksb.obsdc / net.eksb.Obsdc.Signal string:a`
|
|
||||||
|
|
||||||
Call method:
|
|
||||||
`dbus-send --session --type=method_call --print-reply --dest=net.eksb.obsdc / net.eksb.Obsdc.echo string:b`
|
|
||||||
`qdbus net.eksb.obsdc / net.eksb.Obsdc.echo hello`
|
|
||||||
*/
|
|
||||||
|
|||||||
50
src/main/kotlin/net/eksb/obsdc/Gate.kt
Normal file
50
src/main/kotlin/net/eksb/obsdc/Gate.kt
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package net.eksb.obsdc
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.locks.Lock
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Gate blocks [enter] until the gate is opened by [open].
|
||||||
|
*/
|
||||||
|
class Gate {
|
||||||
|
|
||||||
|
private val lock:Lock = ReentrantLock()
|
||||||
|
private val open = AtomicBoolean(false)
|
||||||
|
private val condition = lock.newCondition()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the gate; allow all waiting [enter]s to run.
|
||||||
|
*/
|
||||||
|
fun open() {
|
||||||
|
lock.lock()
|
||||||
|
try {
|
||||||
|
open.set(true)
|
||||||
|
condition.signalAll()
|
||||||
|
} finally {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the gate; any subsequent calls to [enter] will block until [open] is called.
|
||||||
|
*/
|
||||||
|
fun close() {
|
||||||
|
open.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter the gate: run the specified [block] as soon as the gate is open.
|
||||||
|
*/
|
||||||
|
fun <R> enter(block:()->R): R {
|
||||||
|
lock.lock()
|
||||||
|
try {
|
||||||
|
while(!open.get()) {
|
||||||
|
condition.await()
|
||||||
|
}
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
package net.eksb.obsdc
|
package net.eksb.obsdc
|
||||||
|
|
||||||
import java.util.concurrent.BlockingQueue
|
import org.slf4j.LoggerFactory
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
|
||||||
|
|
||||||
object Main {
|
object Main {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
val q:BlockingQueue<Op> = LinkedBlockingQueue()
|
try {
|
||||||
DBus(q) // forks daemon thread
|
val config = CONFIG_FILE.properties()
|
||||||
Obs(q).run() // blocks
|
Obs(
|
||||||
|
host = config.getProperty("host") ?: "localhost",
|
||||||
|
port = config.getProperty("port")?.toInt() ?: 4455,
|
||||||
|
password = config.getProperty("password") ?: error("config missing \"password\""),
|
||||||
|
connectionTimeout = config.getProperty("connectionTimeout")?.toInt() ?: 5
|
||||||
|
).use { obs ->
|
||||||
|
DBus(OpRunner(obs)).use { dbus ->
|
||||||
|
waitForShutdown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e:Exception) {
|
||||||
|
log.error(e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(Main::class.java)
|
||||||
|
}
|
||||||
@@ -1,129 +1,186 @@
|
|||||||
package net.eksb.obsdc
|
package net.eksb.obsdc
|
||||||
|
|
||||||
import io.obswebsocket.community.client.OBSRemoteController
|
import io.obswebsocket.community.client.OBSRemoteController
|
||||||
import io.obswebsocket.community.client.message.event.ui.StudioModeStateChangedEvent
|
import io.obswebsocket.community.client.WebSocketCloseCode
|
||||||
import io.obswebsocket.community.client.model.Scene
|
import io.obswebsocket.community.client.listener.lifecycle.ReasonThrowable
|
||||||
import io.obswebsocket.community.client.model.SceneItem.Transform
|
|
||||||
import io.obswebsocket.community.client.model.SceneItem.Transform.TransformBuilder
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.util.concurrent.BlockingQueue
|
import java.util.concurrent.BlockingQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
// protocol docs: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
/**
|
||||||
class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
|
* Wrapper for an [OBSRemoteController] that handles connecting, reconnecting, and queuing operations
|
||||||
|
* until ready.
|
||||||
|
*
|
||||||
|
* Call [submit] to submit a request.
|
||||||
|
*
|
||||||
|
* protocol docs: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||||
|
*/
|
||||||
|
class Obs(
|
||||||
|
host:String = "localhost",
|
||||||
|
port:Int = 4455,
|
||||||
|
password:String,
|
||||||
|
connectionTimeout:Int = 5 // seconds
|
||||||
|
): AutoCloseable {
|
||||||
|
|
||||||
private val panAmount = 50F
|
/** Queue of requests to run. */
|
||||||
|
private val q:BlockingQueue<Req> = LinkedBlockingQueue()
|
||||||
|
|
||||||
private val controller = OBSRemoteController.builder()
|
/** Flag to set when closed to stop queue poll loop. */
|
||||||
.host("localhost")
|
private val closed = AtomicBoolean(false)
|
||||||
.port(4455)
|
|
||||||
.password("R3tRkVXhFofJ2wRF") // TODO put this in a file
|
/**
|
||||||
|
* Flag set when we start trying to connect, unset when disconnected.
|
||||||
|
* Used to determine if we should reconnect on controller error.
|
||||||
|
*/
|
||||||
|
private val connectingOrConnected = AtomicBoolean(false)
|
||||||
|
/**
|
||||||
|
* Flag to set when connected, unset when disconnected.
|
||||||
|
* Used to determine if we should reconnect on controller error.
|
||||||
|
*/
|
||||||
|
private val connected = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** Gate to block queue poll loop when not ready. */
|
||||||
|
private val ready:Gate = Gate()
|
||||||
|
|
||||||
|
/** The OBS controller. */
|
||||||
|
val controller:OBSRemoteController = OBSRemoteController.builder()
|
||||||
|
.host(host)
|
||||||
|
.port(port)
|
||||||
|
.password(password)
|
||||||
.autoConnect(false)
|
.autoConnect(false)
|
||||||
.connectionTimeout(3)
|
.connectionTimeout(connectionTimeout)
|
||||||
.lifecycle()
|
.lifecycle()
|
||||||
.onReady(::ready)
|
.onReady(::onReady)
|
||||||
.onClose { code -> log.error("closed:${code}")}
|
.onClose(::onClose)
|
||||||
.onControllerError { e -> log.error("controller error", e ) }
|
.onControllerError(::onControllerError)
|
||||||
.onCommunicatorError { e -> log.error("comm error", e ) }
|
.onCommunicatorError(::onCommError)
|
||||||
.onDisconnect { log.info("disconnected") }
|
.onDisconnect(::onDisconnect)
|
||||||
.and()
|
.onConnect {
|
||||||
.registerEventListener(StudioModeStateChangedEvent::class.java) {
|
log.debug("connected")
|
||||||
log.info("studio mode state change: ${it}")
|
connected.set(true)
|
||||||
}
|
}
|
||||||
|
.and()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private var alive = true
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Runtime.getRuntime().addShutdownHook(thread(start=false) {
|
// OBSRemoteController starts a non-daemon thread. It probably should not do that.
|
||||||
log.info("shutdown")
|
// Kill it on shutdown.
|
||||||
alive = false
|
addShutdownHook {
|
||||||
controller.stop()
|
close()
|
||||||
})
|
}
|
||||||
|
// Thread that connects if we are not connected/connecting.
|
||||||
|
thread(name="obs-connect", isDaemon=true, start=true) {
|
||||||
|
while(!closed.get()) {
|
||||||
|
if (connectingOrConnected.compareAndSet(false,true)) {
|
||||||
|
log.debug("Not closed, not connected. Try to connect...")
|
||||||
|
try {
|
||||||
|
// Only call connect from here; if you try to call connect from [onControllerError] or [onDisconnect]
|
||||||
|
// eventually you will get stack overflow.
|
||||||
|
controller.connect()
|
||||||
|
} catch (e:Exception) {
|
||||||
|
log.warn("failed to connect: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(connectionTimeout.toLong() * 1000L) // in case the error was immediate
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun run() {
|
private fun onClose(e:WebSocketCloseCode) {
|
||||||
controller.connect()
|
log.debug("closed: ${e.code}")
|
||||||
|
ready.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onControllerError(e:ReasonThrowable) {
|
||||||
|
log.debug("controller error - ${e.reason}",e.throwable)
|
||||||
|
// If we are not connected, a controller error means that connection failed and we will not connect.
|
||||||
|
// If we are connected, it does not mean we are/will be disconnected.
|
||||||
|
if (!connected.get()) {
|
||||||
|
connectingOrConnected.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun onCommError(e:ReasonThrowable) {
|
||||||
|
log.debug("comm error - ${e.reason}",e.throwable)
|
||||||
|
}
|
||||||
|
private fun onDisconnect() {
|
||||||
|
log.debug("disconnected")
|
||||||
|
connectingOrConnected.set(false)
|
||||||
|
connected.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onReady() {
|
||||||
|
log.debug("ready")
|
||||||
|
ready.open()
|
||||||
|
// The docs say that you are only supposed to send requests from the [onReady] handler,
|
||||||
|
// but you cannot block the [onReady] handler.
|
||||||
|
// (If you block the [onReady] handler other handlers are not called. [onDisconnect] is not called so you
|
||||||
|
// cannot reconnect if OBS is restarted.)
|
||||||
|
// This would be fine if `onReady` was called after each response, but it is not.
|
||||||
|
// So we keep track of if it is ready, and make requests from another thread ([opThread]).
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread that runs submitted requests from [q] when [ready].
|
||||||
|
*/
|
||||||
|
private val opThread = thread(name="obs-op", isDaemon=false, start=true) {
|
||||||
|
while(!closed.get()) {
|
||||||
|
val req = try {
|
||||||
|
q.take()
|
||||||
|
} catch (e:InterruptedException) {
|
||||||
|
log.debug("interrupted taking req")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.debug("got req: ${req}, wait for ready")
|
||||||
|
try {
|
||||||
|
ready.enter {
|
||||||
|
log.debug("ready")
|
||||||
|
if (!req.expired()) {
|
||||||
|
req.block.invoke(controller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e:Exception) {
|
||||||
|
log.error("req ${req} failed", e )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug("done")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a request to run when ready.
|
||||||
|
*
|
||||||
|
* @param timeout If this time has elapsed before ready, do not run. Always run if null.
|
||||||
|
* @param block the request to run
|
||||||
|
*/
|
||||||
|
fun submit(
|
||||||
|
timeout:Duration? = null,
|
||||||
|
block:(OBSRemoteController)->Unit,
|
||||||
|
) {
|
||||||
|
q.put(Req(block, timeout?.inWholeNanoseconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
|
closed.set(true)
|
||||||
|
opThread.interrupt()
|
||||||
controller.disconnect()
|
controller.disconnect()
|
||||||
}
|
controller.stop()
|
||||||
|
|
||||||
private fun ready() {
|
|
||||||
log.info("ready")
|
|
||||||
while(alive) {
|
|
||||||
val op: Op? = q.poll(1, TimeUnit.SECONDS) // blocks
|
|
||||||
if (op != null) {
|
|
||||||
log.info("op: ${op}")
|
|
||||||
op(op)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun op(op:Op) {
|
|
||||||
when(op) {
|
|
||||||
Op.SCENE_1 -> scene { scenes -> scenes.firstOrNull() }
|
|
||||||
Op.SCENE_2 -> scene { scenes -> scenes.asSequence().drop(1).firstOrNull() }
|
|
||||||
Op.STUDIO_TRANSITION -> {
|
|
||||||
controller.triggerStudioModeTransition { response ->
|
|
||||||
// This does not get called?
|
|
||||||
log.info("Response successful: ${response.isSuccessful}")
|
|
||||||
ready()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Op.PAN_UP -> pan { old -> positionY(old.positionY - panAmount ) }
|
|
||||||
Op.PAN_DOWN -> pan { old -> positionY(old.positionY + panAmount ) }
|
|
||||||
Op.PAN_LEFT -> pan { old -> positionX(old.positionX - panAmount ) }
|
|
||||||
Op.PAN_RIGHT -> pan { old -> positionX(old.positionX + panAmount ) }
|
|
||||||
Op.TODO -> {
|
|
||||||
log.info("OP=TODO")
|
|
||||||
ready()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scene(selector:(List<Scene>)->Scene?) {
|
|
||||||
controller.getSceneList { response ->
|
|
||||||
val scene = selector(response.scenes.sortedBy(Scene::getSceneIndex).reversed())
|
|
||||||
log.info("select scene ${scene?.sceneName} index:${scene?.sceneIndex}")
|
|
||||||
if (scene != null) {
|
|
||||||
controller.setCurrentProgramScene(scene.sceneName) { response ->
|
|
||||||
ready()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ready()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pan(block:TransformBuilder.(Transform)->TransformBuilder) {
|
|
||||||
controller.getCurrentProgramScene { response ->
|
|
||||||
val sceneName = response.currentProgramSceneName
|
|
||||||
log.info("scene name: ${sceneName}")
|
|
||||||
controller.getSceneItemList(sceneName) { response ->
|
|
||||||
val item = response.sceneItems.last()
|
|
||||||
val itemId = item.sceneItemId
|
|
||||||
log.info("last item id: ${itemId}")
|
|
||||||
controller.getSceneItemTransform(sceneName, itemId) { response ->
|
|
||||||
val transform = response.sceneItemTransform
|
|
||||||
log.info("position: ${transform.positionX} x ${transform.positionY}")
|
|
||||||
val newTransform = block(Transform.builder(), transform).build()
|
|
||||||
controller.setSceneItemTransform(sceneName, itemId, newTransform) { response ->
|
|
||||||
log.info("transform successful: ${response.isSuccessful}")
|
|
||||||
// Have to set the current scene to take effect if in studio mode.
|
|
||||||
controller.setCurrentProgramScene(sceneName) { response ->
|
|
||||||
ready()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val log = LoggerFactory.getLogger(Obs::class.java)
|
private val log = LoggerFactory.getLogger(Obs::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a request and keep track of timeout.
|
||||||
|
*/
|
||||||
|
private class Req(
|
||||||
|
val block:(OBSRemoteController)->Unit,
|
||||||
|
val timeout:Long?,
|
||||||
|
) {
|
||||||
|
val submitTime = System.nanoTime()
|
||||||
|
fun expired():Boolean = timeout != null && System.nanoTime() - submitTime > timeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package net.eksb.obsdc
|
|
||||||
|
|
||||||
enum class Op {
|
|
||||||
STUDIO_TRANSITION,
|
|
||||||
SCENE_1,
|
|
||||||
SCENE_2,
|
|
||||||
PAN_UP,
|
|
||||||
PAN_DOWN,
|
|
||||||
PAN_LEFT,
|
|
||||||
PAN_RIGHT,
|
|
||||||
TODO,
|
|
||||||
;
|
|
||||||
}
|
|
||||||
166
src/main/kotlin/net/eksb/obsdc/OpRunner.kt
Normal file
166
src/main/kotlin/net/eksb/obsdc/OpRunner.kt
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package net.eksb.obsdc
|
||||||
|
|
||||||
|
import io.obswebsocket.community.client.message.request.RequestBatch
|
||||||
|
import io.obswebsocket.community.client.message.request.sceneitems.GetSceneItemLockedRequest
|
||||||
|
import io.obswebsocket.community.client.message.response.sceneitems.GetSceneItemLockedResponse
|
||||||
|
import io.obswebsocket.community.client.model.Scene
|
||||||
|
import io.obswebsocket.community.client.model.SceneItem.Transform
|
||||||
|
import io.obswebsocket.community.client.model.SceneItem.Transform.TransformBuilder
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use an [obs] to run an operation.
|
||||||
|
*
|
||||||
|
* Call [invoke] to run an OBS operation.
|
||||||
|
*/
|
||||||
|
class OpRunner(private val obs:Obs): (String)->Unit {
|
||||||
|
|
||||||
|
/** How much to pan. */
|
||||||
|
private val panAmount = 50F
|
||||||
|
|
||||||
|
private val controller = obs.controller
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the specified [op].
|
||||||
|
*
|
||||||
|
* @param op Operation to run.
|
||||||
|
*/
|
||||||
|
override fun invoke(op:String) {
|
||||||
|
obs.submit { controller ->
|
||||||
|
when {
|
||||||
|
op == "SCENE_NEXT" -> scene { scenes, current ->
|
||||||
|
if (current != null) {
|
||||||
|
scenes.asSequence()
|
||||||
|
.dropWhile { scene -> scene != current }
|
||||||
|
.drop(1)
|
||||||
|
.firstOrNull()
|
||||||
|
?: scenes.firstOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op == "SCENE_PREV" -> scene { scenes, current ->
|
||||||
|
if (current != null) {
|
||||||
|
scenes.reversed().asSequence()
|
||||||
|
.dropWhile { scene -> scene != current }
|
||||||
|
.drop(1)
|
||||||
|
.firstOrNull()
|
||||||
|
?: scenes.lastOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op.startsWith("SCENE_") -> scene { scenes, current ->
|
||||||
|
val drop = op.drop("SCENE_".length).toInt() - 1
|
||||||
|
scenes.asSequence().drop(drop).firstOrNull()
|
||||||
|
}
|
||||||
|
op == "STUDIO_TRANSITION" -> {
|
||||||
|
controller.triggerStudioModeTransition { response ->
|
||||||
|
log.debug("studio transitioned: ${response.isSuccessful}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op == "STUDIO_MODE_TOGGLE" -> studioModeToggle()
|
||||||
|
op == "PAN_UP" -> transform { old -> positionY(old.positionY - panAmount ) }
|
||||||
|
op == "PAN_DOWN" -> transform { old -> positionY(old.positionY + panAmount ) }
|
||||||
|
op == "PAN_LEFT" -> transform { old -> positionX(old.positionX - panAmount ) }
|
||||||
|
op == "PAN_RIGHT" -> transform { old -> positionX(old.positionX + panAmount ) }
|
||||||
|
else -> log.error("Unhandled op \"${op}\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun studioModeToggle() {
|
||||||
|
controller.getStudioModeEnabled { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val enable = !response.studioModeEnabled
|
||||||
|
log.debug("toggle studio mode: ${!enable}")
|
||||||
|
controller.setStudioModeEnabled(enable) { response ->
|
||||||
|
log.debug("toggled studio mode: ${enable}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a scene from the scene list with the supplied [selector] and set the selected scene (if any)
|
||||||
|
* as the current program scene.
|
||||||
|
*
|
||||||
|
* @param selector Lambda that takes as arguments the list of scenes and the current scene (or null if the current
|
||||||
|
* scene is unknown), and returns the scene to select, or null to not change the scene.
|
||||||
|
*/
|
||||||
|
private fun scene(selector:(List<Scene>,Scene?)->Scene?) {
|
||||||
|
controller.getCurrentProgramScene { response ->
|
||||||
|
val currentSceneName = response.currentProgramSceneName
|
||||||
|
controller.getSceneList { response ->
|
||||||
|
val scenes = response.scenes.sortedBy(Scene::getSceneIndex).reversed()
|
||||||
|
val currentScene = scenes.find { scene -> scene.sceneName == currentSceneName }
|
||||||
|
val scene = selector(response.scenes.sortedBy(Scene::getSceneIndex).reversed(), currentScene)
|
||||||
|
log.debug("select scene ${scene?.sceneName} index:${scene?.sceneIndex}")
|
||||||
|
if (scene != null) {
|
||||||
|
controller.setCurrentProgramScene(scene.sceneName) { response ->
|
||||||
|
log.debug("selected scene ${scene.sceneName}: ${response.isSuccessful}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a transform for the lowest non-locked item in the current program scene with the
|
||||||
|
* supplied [transformBuilder], and apply that transform to the item.
|
||||||
|
*/
|
||||||
|
private fun transform(transformBuilder:TransformBuilder.(Transform)->TransformBuilder) {
|
||||||
|
controller.getCurrentProgramScene { response ->
|
||||||
|
val sceneName = response.currentProgramSceneName
|
||||||
|
log.debug("scene name: ${sceneName}")
|
||||||
|
controller.getSceneItemList(sceneName) { response ->
|
||||||
|
val items = response.sceneItems
|
||||||
|
// Even though locked status is in the response from OBS, the library does not parse it.
|
||||||
|
// So we have to ask for it explicitly:
|
||||||
|
controller.sendRequestBatch(
|
||||||
|
RequestBatch.builder()
|
||||||
|
.requests(
|
||||||
|
response.sceneItems.map { item ->
|
||||||
|
GetSceneItemLockedRequest.builder()
|
||||||
|
.sceneName(sceneName)
|
||||||
|
.sceneItemId(item.sceneItemId)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
) { response ->
|
||||||
|
val item = response.data.results
|
||||||
|
.map { result ->
|
||||||
|
(result.responseData as GetSceneItemLockedResponse.SpecificData).sceneItemLocked
|
||||||
|
}
|
||||||
|
.zip(items)
|
||||||
|
.asSequence()
|
||||||
|
.filter { (locked,item) -> ! locked }
|
||||||
|
.map { (locked,item) -> item }
|
||||||
|
.sortedBy { item -> item.sceneItemIndex }
|
||||||
|
.firstOrNull()
|
||||||
|
log.debug("item to pan: ${item?.sceneItemId}")
|
||||||
|
if (item != null) {
|
||||||
|
controller.getSceneItemTransform(sceneName, item.sceneItemId) { response ->
|
||||||
|
val transform = response.sceneItemTransform
|
||||||
|
log.debug("position: ${transform.positionX} x ${transform.positionY}")
|
||||||
|
val newTransform = transformBuilder(Transform.builder(), transform).build()
|
||||||
|
controller.setSceneItemTransform(sceneName, item.sceneItemId, newTransform) { response ->
|
||||||
|
log.debug("transform successful: ${response.isSuccessful}")
|
||||||
|
// Have to set the current scene to take effect if in studio mode.
|
||||||
|
controller.setCurrentProgramScene(sceneName) { response ->
|
||||||
|
log.debug("set current program to ${sceneName}: ${response.isSuccessful}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val log = LoggerFactory.getLogger(OpRunner::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
27
src/main/kotlin/net/eksb/obsdc/Util.kt
Normal file
27
src/main/kotlin/net/eksb/obsdc/Util.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package net.eksb.obsdc
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
val HOME:File = System.getProperty("user.home")?.let(::File) ?: error("No user.home")
|
||||||
|
val CONFIG_HOME:File = System.getenv("XDG_CONFIG_HOME")?.let(::File) ?: File(HOME, ".config")
|
||||||
|
val CONFIG_FILE:File = File(CONFIG_HOME,"net.eksb.obsdc/config.properties")
|
||||||
|
|
||||||
|
fun File.properties(): Properties = Properties()
|
||||||
|
.also { properties ->
|
||||||
|
if (isFile) {
|
||||||
|
inputStream().use(properties::load)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addShutdownHook(block:()->Unit) = Runtime.getRuntime().addShutdownHook(thread(start=false) { block() })
|
||||||
|
|
||||||
|
fun waitForShutdown() {
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
addShutdownHook {
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
latch.await()
|
||||||
|
}
|
||||||
2
src/main/resources/simplelogger.properties
Normal file
2
src/main/resources/simplelogger.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
org.slf4j.simpleLogger.log.net.eksb=debug
|
||||||
|
org.slf4j.simpleLogger.log.org.freedesktop.dbus=debug
|
||||||
Reference in New Issue
Block a user