Compare commits
9 Commits
d433179db4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 74e44a07ba | |||
| 6b8ad1f5d4 | |||
| e01589aab8 | |||
| 265efd1db0 | |||
| e0838be9b8 | |||
| 145bf4d7b1 | |||
| 9ded3dfdc6 | |||
| a4886c8ae6 | |||
| fb9fc290ad |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ gradlew*
|
|||||||
|
|
||||||
# Ignore Gradle build output directory
|
# Ignore Gradle build output directory
|
||||||
build
|
build
|
||||||
|
.idea
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -2,16 +2,25 @@
|
|||||||
|
|
||||||
OBS control from D-Bus messages
|
OBS control from D-Bus messages
|
||||||
|
|
||||||
Send a D-Bus signal to perform an operation with `src/obsdc-signal`, which takes a single operation name
|
Send a D-Bus signal to perform an operation with `src/obsdc-signal`, which takes a single operation command
|
||||||
as an argument.
|
as an argument, and performs an action in OBS.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
See [Op](src/main/kotlin/net/eksb/obsdc/Op.kt) for a list of 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
|
||||||
|
|
||||||
Configuration is in `$XDG_CONFIG_HOME/net.eksb.obsdc/config.properties`
|
Configuration is read from `$XDG_CONFIG_HOME/net.eksb.obsdc/config.properties`
|
||||||
|
|
||||||
### Configuration options
|
### Configuration options
|
||||||
|
|
||||||
@@ -19,6 +28,15 @@ Configuration is in `$XDG_CONFIG_HOME/net.eksb.obsdc/config.properties`
|
|||||||
* `port` - OBS websocket port (default: `4455`)
|
* `port` - OBS websocket port (default: `4455`)
|
||||||
* `password` - OBS websocket password (required)
|
* `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
|
## Build
|
||||||
|
|
||||||
`gradle build`
|
`gradle build`
|
||||||
@@ -49,9 +67,13 @@ using an `Obs`.
|
|||||||
I usually code on a 4k monitor in portrait mode. To show what I am doing on video calls,
|
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 use OBS to share that monitor, but it is a 2160x4096 source in a 1920x1080 scene.
|
||||||
|
|
||||||
I have [a macropad](https://www.keebmonkey.com/products/megalodon-dual-layer-knob-macro-pad) with two knobs.
|
I have
|
||||||
My [window manager](https://swaywm.org/) is configured to call `obsdc-signal` with `PAN_UP`/`PAN_DOWN` for the
|
[a macropad](https://keeb.io/collections/bdn9-collection/products/bdn9-rev-2-3x3-9-key-macropad-rotary-encoder-and-rgb)
|
||||||
keys emitted by one knob and `PAN_RIGHT`/`PAN_LEFT` for the keys emitted by the other knob.
|
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.
|
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.
|
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
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package net.eksb.obsdc
|
|
||||||
|
|
||||||
class Backoff {
|
|
||||||
fun backoff() {
|
|
||||||
// TODO: actual bakoff
|
|
||||||
Thread.sleep(5_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,7 @@ import org.slf4j.LoggerFactory
|
|||||||
*
|
*
|
||||||
* `src/scripts/obsdc-signal` takes an [Op] name and sends the signal.
|
* `src/scripts/obsdc-signal` takes an [Op] name and sends the signal.
|
||||||
*/
|
*/
|
||||||
class DBus(private val opRunner:OpRunner): AutoCloseable {
|
class DBus(private val opHandler:(String)->Unit): AutoCloseable {
|
||||||
|
|
||||||
// To monitor DBUS: `dbus-monitor`
|
// To monitor DBUS: `dbus-monitor`
|
||||||
// To see what is registered: `qdbus net.eksb.obsdc /`
|
// To see what is registered: `qdbus net.eksb.obsdc /`
|
||||||
@@ -37,9 +37,7 @@ class DBus(private val opRunner:OpRunner): AutoCloseable {
|
|||||||
|
|
||||||
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
|
dbus.addSigHandler<ObsdcDBusInterface.Signal> { signal ->
|
||||||
log.debug("signal: ${signal.op}")
|
log.debug("signal: ${signal.op}")
|
||||||
val op = Op.valueOf(signal.op)
|
opHandler.invoke(signal.op)
|
||||||
log.debug("op: ${op}")
|
|
||||||
opRunner.run(op)
|
|
||||||
}
|
}
|
||||||
log.debug("DBUS initialized")
|
log.debug("DBUS initialized")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ class Obs(
|
|||||||
/** Queue of requests to run. */
|
/** Queue of requests to run. */
|
||||||
private val q:BlockingQueue<Req> = LinkedBlockingQueue()
|
private val q:BlockingQueue<Req> = LinkedBlockingQueue()
|
||||||
|
|
||||||
/** Backoff on errors. */
|
|
||||||
private val backoff = Backoff()
|
|
||||||
|
|
||||||
/** Flag to set when closed to stop queue poll loop. */
|
/** Flag to set when closed to stop queue poll loop. */
|
||||||
private val closed = AtomicBoolean(false)
|
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.
|
* Flag to set when connected, unset when disconnected.
|
||||||
* Used to determine if we should reconnect on controller error.
|
* Used to determine if we should reconnect on controller error.
|
||||||
@@ -69,9 +71,21 @@ class Obs(
|
|||||||
addShutdownHook {
|
addShutdownHook {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
// connect() blocks until OBS is up, so fork it.
|
// Thread that connects if we are not connected/connecting.
|
||||||
thread(name="obs-init-connect", isDaemon=true, start=true) {
|
thread(name="obs-connect", isDaemon=true, start=true) {
|
||||||
controller.connect()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +96,10 @@ class Obs(
|
|||||||
|
|
||||||
private fun onControllerError(e:ReasonThrowable) {
|
private fun onControllerError(e:ReasonThrowable) {
|
||||||
log.debug("controller error - ${e.reason}",e.throwable)
|
log.debug("controller error - ${e.reason}",e.throwable)
|
||||||
if (!closed.get() && !connected.get()) {
|
// If we are not connected, a controller error means that connection failed and we will not connect.
|
||||||
log.debug("connection failed")
|
// If we are connected, it does not mean we are/will be disconnected.
|
||||||
backoff.backoff()
|
if (!connected.get()) {
|
||||||
log.debug("reconnect after connection failed...")
|
connectingOrConnected.set(false)
|
||||||
controller.connect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private fun onCommError(e:ReasonThrowable) {
|
private fun onCommError(e:ReasonThrowable) {
|
||||||
@@ -94,12 +107,8 @@ class Obs(
|
|||||||
}
|
}
|
||||||
private fun onDisconnect() {
|
private fun onDisconnect() {
|
||||||
log.debug("disconnected")
|
log.debug("disconnected")
|
||||||
|
connectingOrConnected.set(false)
|
||||||
connected.set(false)
|
connected.set(false)
|
||||||
if (! closed.get()) {
|
|
||||||
backoff.backoff()
|
|
||||||
log.debug("reconnect after disconnected..")
|
|
||||||
controller.connect()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onReady() {
|
private fun onReady() {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package net.eksb.obsdc
|
|
||||||
|
|
||||||
enum class Op {
|
|
||||||
/** If in studio mode, transition between scenes. */
|
|
||||||
STUDIO_TRANSITION,
|
|
||||||
/** Activate the first scene. */
|
|
||||||
SCENE_1,
|
|
||||||
/** Activate the second scene. */
|
|
||||||
SCENE_2,
|
|
||||||
/** Activate the third scene. */
|
|
||||||
SCENE_3,
|
|
||||||
/** Move the bottom-most unlocked source in the active scene up. */
|
|
||||||
PAN_UP,
|
|
||||||
/** Move the bottom-most unlocked source in the active scene down. */
|
|
||||||
PAN_DOWN,
|
|
||||||
/** Move the bottom-most unlocked source in the active scene left. */
|
|
||||||
PAN_LEFT,
|
|
||||||
/** Move the bottom-most unlocked source in the active scene right. */
|
|
||||||
PAN_RIGHT,
|
|
||||||
;
|
|
||||||
}
|
|
||||||
@@ -9,32 +9,74 @@ import io.obswebsocket.community.client.model.SceneItem.Transform.TransformBuild
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use an [obs] to run [Op]s.
|
* Use an [obs] to run an operation.
|
||||||
*
|
*
|
||||||
* Call [run] to run an OBS operation.
|
* Call [invoke] to run an OBS operation.
|
||||||
*/
|
*/
|
||||||
class OpRunner(private val obs:Obs) {
|
class OpRunner(private val obs:Obs): (String)->Unit {
|
||||||
|
|
||||||
/** How much to pan. */
|
/** How much to pan. */
|
||||||
private val panAmount = 50F
|
private val panAmount = 50F
|
||||||
|
|
||||||
private val controller = obs.controller
|
private val controller = obs.controller
|
||||||
|
|
||||||
fun run(op:Op) {
|
/**
|
||||||
|
* Run the specified [op].
|
||||||
|
*
|
||||||
|
* @param op Operation to run.
|
||||||
|
*/
|
||||||
|
override fun invoke(op:String) {
|
||||||
obs.submit { controller ->
|
obs.submit { controller ->
|
||||||
when(op) {
|
when {
|
||||||
Op.SCENE_1 -> scene { scenes -> scenes.firstOrNull() }
|
op == "SCENE_NEXT" -> scene { scenes, current ->
|
||||||
Op.SCENE_2 -> scene { scenes -> scenes.asSequence().drop(1).firstOrNull() }
|
if (current != null) {
|
||||||
Op.SCENE_3 -> scene { scenes -> scenes.asSequence().drop(2).firstOrNull() }
|
scenes.asSequence()
|
||||||
Op.STUDIO_TRANSITION -> {
|
.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 ->
|
controller.triggerStudioModeTransition { response ->
|
||||||
log.debug("studio transitioned: ${response.isSuccessful}")
|
log.debug("studio transitioned: ${response.isSuccessful}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Op.PAN_UP -> transform { old -> positionY(old.positionY - panAmount ) }
|
op == "STUDIO_MODE_TOGGLE" -> studioModeToggle()
|
||||||
Op.PAN_DOWN -> transform { old -> positionY(old.positionY + panAmount ) }
|
op == "PAN_UP" -> transform { old -> positionY(old.positionY - panAmount ) }
|
||||||
Op.PAN_LEFT -> transform { old -> positionX(old.positionX - panAmount ) }
|
op == "PAN_DOWN" -> transform { old -> positionY(old.positionY + panAmount ) }
|
||||||
Op.PAN_RIGHT -> transform { old -> positionX(old.positionX + 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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,14 +84,22 @@ class OpRunner(private val obs:Obs) {
|
|||||||
/**
|
/**
|
||||||
* Select a scene from the scene list with the supplied [selector] and set the selected scene (if any)
|
* Select a scene from the scene list with the supplied [selector] and set the selected scene (if any)
|
||||||
* as the current program scene.
|
* 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?) {
|
private fun scene(selector:(List<Scene>,Scene?)->Scene?) {
|
||||||
controller.getSceneList { response ->
|
controller.getCurrentProgramScene { response ->
|
||||||
val scene = selector(response.scenes.sortedBy(Scene::getSceneIndex).reversed())
|
val currentSceneName = response.currentProgramSceneName
|
||||||
log.debug("select scene ${scene?.sceneName} index:${scene?.sceneIndex}")
|
controller.getSceneList { response ->
|
||||||
if (scene != null) {
|
val scenes = response.scenes.sortedBy(Scene::getSceneIndex).reversed()
|
||||||
controller.setCurrentProgramScene(scene.sceneName) { response ->
|
val currentScene = scenes.find { scene -> scene.sceneName == currentSceneName }
|
||||||
log.debug("selected scene ${scene.sceneName}: ${response.isSuccessful}")
|
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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
org.slf4j.simpleLogger.log.net.eksb=debug
|
org.slf4j.simpleLogger.log.net.eksb=debug
|
||||||
|
org.slf4j.simpleLogger.log.org.freedesktop.dbus=debug
|
||||||
|
|||||||
Reference in New Issue
Block a user