Compare commits

...

16 Commits

Author SHA1 Message Date
74e44a07ba ignore .idea 2024-05-23 14:50:45 -04:00
6b8ad1f5d4 kotlin 2, dbus-java-core 5 2024-05-23 14:50:26 -04:00
e01589aab8 docs 2024-04-18 12:02:55 -04:00
265efd1db0 use String instead of Op; support SCENE_<number> 2024-03-13 15:31:20 -04:00
e0838be9b8 remove unused Backoff.kt 2024-03-13 15:27:39 -04:00
145bf4d7b1 Connect from connect thread. Avoids stack overflow 2023-11-30 14:59:36 -05:00
9ded3dfdc6 update rationale section in readme 2023-11-10 11:59:23 -05:00
a4886c8ae6 systemd unit file 2023-11-10 11:55:38 -05:00
fb9fc290ad add STUDIO_MODE_TOGGLE, SCENE_PREV, and SCENE_NEXT 2023-10-29 18:58:18 -04:00
d433179db4 better error handling and log messages 2023-10-24 08:17:13 -04:00
70a7f3768e fix Obs shutdown hook 2023-10-24 07:43:12 -04:00
596dddd630 license 2023-10-22 10:52:47 -04:00
ee173f7eae Separate OBS retry/queue logic (Obs) from ops (OpRunner). 2023-10-22 10:45:04 -04:00
917af288ab docs, put obsdc-signal in tar 2023-10-21 14:59:24 -04:00
180e4d2bf4 config from file 2023-10-21 14:34:10 -04:00
f4ac82de05 cleanup, docs 2023-10-21 12:05:44 -04:00
15 changed files with 529 additions and 220 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ gradlew*
# Ignore Gradle build output directory # Ignore Gradle build output directory
build build
.idea

21
LICENSE Normal file
View 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
View 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.

View File

@@ -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
View File

@@ -0,0 +1,10 @@
[Unit]
Description=OBSDC
[Service]
Type=dbus
BusName=net.eksb.obsdc
ExecStart=/opt/obsdc/bin/obsdc
[Install]
WantedBy=default.target

View File

@@ -1,7 +0,0 @@
package net.eksb.obsdc
class Backoff {
fun backoff() {
Thread.sleep(5_000)
}
}

View File

@@ -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 = false,
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`
*/

View 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()
}
}
}

View File

@@ -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 non-daemon thread val config = CONFIG_FILE.properties()
Obs(q).start() // forks non-daemon thread 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)
}

View File

@@ -3,34 +3,55 @@ package net.eksb.obsdc
import io.obswebsocket.community.client.OBSRemoteController import io.obswebsocket.community.client.OBSRemoteController
import io.obswebsocket.community.client.WebSocketCloseCode import io.obswebsocket.community.client.WebSocketCloseCode
import io.obswebsocket.community.client.listener.lifecycle.ReasonThrowable import io.obswebsocket.community.client.listener.lifecycle.ReasonThrowable
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 java.util.concurrent.BlockingQueue import java.util.concurrent.BlockingQueue
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread import kotlin.concurrent.thread
import org.slf4j.LoggerFactory 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 backoff = Backoff() /** Flag to set when closed to stop queue poll loop. */
private val closed = AtomicBoolean(false)
private val started = AtomicBoolean(false) /**
* 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) private val connected = AtomicBoolean(false)
private val ready = AtomicBoolean(false)
private val controller = OBSRemoteController.builder() /** Gate to block queue poll loop when not ready. */
.host("localhost") private val ready:Gate = Gate()
.port(4455)
.password("R3tRkVXhFofJ2wRF") // TODO put this in a file /** 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(::onReady) .onReady(::onReady)
.onClose(::onClose) .onClose(::onClose)
@@ -38,60 +59,61 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
.onCommunicatorError(::onCommError) .onCommunicatorError(::onCommError)
.onDisconnect(::onDisconnect) .onDisconnect(::onDisconnect)
.onConnect { .onConnect {
log.info("connected") log.debug("connected")
connected.set(true) connected.set(true)
} }
.and() .and()
.build() .build()
init { init {
Runtime.getRuntime().addShutdownHook(thread(start=false) { // OBSRemoteController starts a non-daemon thread. It probably should not do that.
// Kill it on shutdown.
addShutdownHook {
close() close()
})
} }
// Thread that connects if we are not connected/connecting.
fun start() { thread(name="obs-connect", isDaemon=true, start=true) {
if (!started.compareAndExchange(false,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() controller.connect()
} catch (e:Exception) {
log.warn("failed to connect: ${e.message}", e)
} }
} }
fun stop() { Thread.sleep(connectionTimeout.toLong() * 1000L) // in case the error was immediate
if (started.compareAndExchange(true,false)) { }
controller.disconnect()
} }
} }
private fun onClose(e:WebSocketCloseCode) { private fun onClose(e:WebSocketCloseCode) {
log.info("closed: ${e.code}") log.debug("closed: ${e.code}")
ready.set(false) ready.close()
} }
private fun onControllerError(e:ReasonThrowable) { private fun onControllerError(e:ReasonThrowable) {
log.info("controller error - ${e.reason}",e.throwable) log.debug("controller error - ${e.reason}",e.throwable)
if (started.get() && ! connected.get()) { // If we are not connected, a controller error means that connection failed and we will not connect.
log.info("connection failed") // If we are connected, it does not mean we are/will be disconnected.
backoff.backoff() if (!connected.get()) {
log.info("reconnect after connection failed...") connectingOrConnected.set(false)
controller.connect()
} }
} }
private fun onCommError(e:ReasonThrowable) { private fun onCommError(e:ReasonThrowable) {
log.info("comm error - ${e.reason}",e.throwable) log.debug("comm error - ${e.reason}",e.throwable)
} }
private fun onDisconnect() { private fun onDisconnect() {
log.info("disconnected") log.debug("disconnected")
ready.set(false) connectingOrConnected.set(false)
connected.set(false) connected.set(false)
if (started.get()) {
backoff.backoff()
log.info("reconnect after disconnected..")
controller.connect()
}
} }
private fun onReady() { private fun onReady() {
log.info("ready") log.debug("ready")
ready.set(true) ready.open()
// The docs say that you are only supposed to send requests from the [onReady] handler, // The docs say that you are only supposed to send requests from the [onReady] handler,
// but you cannot block 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 // (If you block the [onReady] handler other handlers are not called. [onDisconnect] is not called so you
@@ -100,113 +122,65 @@ class Obs(private val q:BlockingQueue<Op>): AutoCloseable {
// So we keep track of if it is ready, and make requests from another thread ([opThread]). // 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) { private val opThread = thread(name="obs-op", isDaemon=false, start=true) {
while(true) { while(!closed.get()) {
val op = q.take() val req = try {
log.info("got op: ${op}") q.take()
if (ready.get()) {
try {
op(op)
} catch (e:InterruptedException) { } catch (e:InterruptedException) {
log.info("op thread interrupted") log.debug("interrupted taking req")
break 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) { } catch (e:Exception) {
log.error("op ${op} failed", e ) log.error("req ${req} failed", e )
}
} else {
// This would be way more complicated if we had to buffer ops.
log.info("skipping op ${op} because not yet ready")
} }
} }
log.debug("done")
} }
private fun op(op:Op) { /**
when(op) { * Submit a request to run when ready.
Op.SCENE_1 -> scene { scenes -> scenes.firstOrNull() } *
Op.SCENE_2 -> scene { scenes -> scenes.asSequence().drop(1).firstOrNull() } * @param timeout If this time has elapsed before ready, do not run. Always run if null.
Op.SCENE_3 -> scene { scenes -> scenes.asSequence().drop(2).firstOrNull() } * @param block the request to run
Op.STUDIO_TRANSITION -> { */
controller.triggerStudioModeTransition { response -> fun submit(
log.info("studio transitioned: ${response.isSuccessful}") timeout:Duration? = null,
} block:(OBSRemoteController)->Unit,
} ) {
Op.PAN_UP -> pan { old -> positionY(old.positionY - panAmount ) } q.put(Req(block, timeout?.inWholeNanoseconds))
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 ) }
}
}
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 ->
log.info("selected scene ${scene.sceneName}: ${response.isSuccessful}")
}
}
}
}
// Pan the bottom-most non-locked item.
private fun pan(block:TransformBuilder.(Transform)->TransformBuilder) {
controller.getCurrentProgramScene { response ->
val sceneName = response.currentProgramSceneName
log.info("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.info("item to pan: ${item?.sceneItemId}")
if (item != null) {
controller.getSceneItemTransform(sceneName, item.sceneItemId) { response ->
val transform = response.sceneItemTransform
log.info("position: ${transform.positionX} x ${transform.positionY}")
val newTransform = block(Transform.builder(), transform).build()
controller.setSceneItemTransform(sceneName, item.sceneItemId, 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 ->
log.info("set current program to ${sceneName}: ${response.isSuccessful}")
}
}
}
}
}
}
}
} }
override fun close() { override fun close() {
log.info("close") closed.set(true)
stop()
opThread.interrupt() opThread.interrupt()
controller.disconnect()
controller.stop()
} }
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
} }
} }
}

View File

@@ -1,13 +0,0 @@
package net.eksb.obsdc
enum class Op {
STUDIO_TRANSITION,
SCENE_1,
SCENE_2,
SCENE_3,
PAN_UP,
PAN_DOWN,
PAN_LEFT,
PAN_RIGHT,
;
}

View 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)
}
}

View 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()
}

View File

@@ -0,0 +1,2 @@
org.slf4j.simpleLogger.log.net.eksb=debug
org.slf4j.simpleLogger.log.org.freedesktop.dbus=debug