Compare commits

...

27 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
8f46a3e78a docs 2023-10-21 11:45:29 -04:00
449fae8e22 send ops to obs from separate thread; handle obs restart 2023-10-21 11:42:23 -04:00
467a54c607 scene 3 2023-10-20 19:04:23 -04:00
e4640181c3 pan lowest non-locked item 2023-10-20 17:40:13 -04:00
ad9281b609 graceful shutdown 2023-10-20 16:34:09 -04:00
33a0298735 scene 1 2 2023-10-20 15:39:22 -04:00
ba5e7aa7f8 pan 2023-10-20 15:31:37 -04:00
199e7d1f1c SCENE_TEST, fix respsonse handling 2023-10-20 14:56:37 -04:00
e96e73136a use a queue 2023-10-20 13:40:35 -04:00
d5d52ad2bd implement studio transition 2023-10-20 11:18:06 -04:00
6b0f5b99f9 obs impl. 2023-10-20 11:16:12 -04:00
13 changed files with 637 additions and 60 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,13 +1,17 @@
plugins { plugins {
id("org.jetbrains.kotlin.jvm") version "1.9.0" id("org.jetbrains.kotlin.jvm") version "2.0.0"
application application
} }
dependencies { 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")
runtimeOnly("com.github.hypfvieh:dbus-java-transport-native-unixsocket:4.3.1") implementation("com.github.hypfvieh:dbus-java-core:5.0.0")
runtimeOnly("com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0")
implementation("io.obs-websocket.community:client:2.0.0")
implementation("org.slf4j:slf4j-api:2.0.9") implementation("org.slf4j:slf4j-api:2.0.9")
runtimeOnly("org.slf4j:slf4j-simple:2.0.9") runtimeOnly("org.slf4j:slf4j-simple:2.0.9")
} }

2
src/main/dist/bin/obsdc-signal vendored Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
dbus-send --session --type=signal --dest=net.eksb.obsdc / net.eksb.Obsdc.Signal string:$1

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

@@ -0,0 +1,71 @@
package net.eksb.obsdc
import org.freedesktop.dbus.annotations.DBusInterfaceName
import org.freedesktop.dbus.connections.impl.DBusConnection
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
import org.freedesktop.dbus.exceptions.DBusException
import org.freedesktop.dbus.interfaces.DBusInterface
import org.freedesktop.dbus.interfaces.DBusSigHandler
import org.freedesktop.dbus.messages.DBusSignal
import org.slf4j.LoggerFactory
/**
* 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 {
// To monitor DBUS: `dbus-monitor`
// To see what is registered: `qdbus net.eksb.obsdc /`
val dbus = DBusConnectionBuilder.forSessionBus().build()
init {
// These lines are not necessary to handle signals, but are necessary to register methods.
try {
dbus.requestBusName(BUS_NAME)
} catch (e:DBusException) {
error("Error requesting bus. Already running?")
}
dbus.exportObject("/", ObsdcDBusInterfaceImpl())
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
log.debug("signal: ${signal.op}")
opHandler.invoke(signal.op)
}
log.debug("DBUS initialized")
}
override fun close() {
dbus.releaseBusName(BUS_NAME)
dbus.close()
}
companion object {
private const val BUS_NAME = "net.eksb.obsdc"
private val log = LoggerFactory.getLogger(DBus::class.java)
}
}
inline fun <reified T: DBusSignal> DBusConnection.addSigHandler(handler: DBusSigHandler<T>) {
addSigHandler(T::class.java, handler)
}
@DBusInterfaceName("net.eksb.Obsdc")
interface ObsdcDBusInterface: DBusInterface {
fun echo(message:String): String
class Signal(path:String, val op:String): DBusSignal(path, op)
}
class ObsdcDBusInterfaceImpl: ObsdcDBusInterface {
override fun echo(message: String):String {
return message
}
override fun getObjectPath(): String = "/"
}

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,68 +1,26 @@
package net.eksb.obsdc package net.eksb.obsdc
import org.freedesktop.dbus.annotations.DBusInterfaceName
import org.freedesktop.dbus.connections.impl.DBusConnection
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
import org.freedesktop.dbus.interfaces.DBusInterface
import org.freedesktop.dbus.interfaces.DBusSigHandler
import org.freedesktop.dbus.messages.DBusSignal
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
object Main { object Main {
@JvmStatic @JvmStatic
fun main(args: Array<String>) { fun main(args: Array<String>) {
DBusConnectionBuilder.forSessionBus().build().use { dbus ->
// These lines are not necessary to handle signals, but are necessary to register methods.
dbus.requestBusName("net.eksb.obsdc")
dbus.exportObject("/", ObsdcDBusInterfaceImpl())
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal -> log.info("signal: ${signal.message}") }
while (true) {
try { try {
Thread.sleep(60_000) val config = CONFIG_FILE.properties()
} catch (e:InterruptedException) { Obs(
log.info("interrupted") host = config.getProperty("host") ?: "localhost",
break 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)
} }
log.info("done")
}
}
inline fun <reified T:DBusSignal> DBusConnection.addSigHandler(handler:DBusSigHandler<T>) {
addSigHandler(T::class.java, handler)
} }
private val log = LoggerFactory.getLogger(Main::class.java) private val log = LoggerFactory.getLogger(Main::class.java)
@DBusInterfaceName("net.eksb.Obsdc")
interface ObsdcDBusInterface: DBusInterface {
fun echo(message:String): String
class Signal(path:String, val message:String): DBusSignal(path, message)
} }
class ObsdcDBusInterfaceImpl: ObsdcDBusInterface {
override fun echo(message: String):String {
log.info("echo: ${message}")
return message
}
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,186 @@
package net.eksb.obsdc
import io.obswebsocket.community.client.OBSRemoteController
import io.obswebsocket.community.client.WebSocketCloseCode
import io.obswebsocket.community.client.listener.lifecycle.ReasonThrowable
import java.util.concurrent.BlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import org.slf4j.LoggerFactory
import java.util.concurrent.LinkedBlockingQueue
import kotlin.time.Duration
/**
* 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 {
/** Queue of requests to run. */
private val q:BlockingQueue<Req> = LinkedBlockingQueue()
/** Flag to set when closed to stop queue poll loop. */
private val closed = 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)
/** 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)
.connectionTimeout(connectionTimeout)
.lifecycle()
.onReady(::onReady)
.onClose(::onClose)
.onControllerError(::onControllerError)
.onCommunicatorError(::onCommError)
.onDisconnect(::onDisconnect)
.onConnect {
log.debug("connected")
connected.set(true)
}
.and()
.build()
init {
// OBSRemoteController starts a non-daemon thread. It probably should not do that.
// Kill it on shutdown.
addShutdownHook {
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
}
}
}
private fun onClose(e:WebSocketCloseCode) {
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() {
closed.set(true)
opThread.interrupt()
controller.disconnect()
controller.stop()
}
companion object {
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

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