/* * Insta360 Link Joystick - a Plasma 6 applet. * * The joystick is a velocity control: displacement sets a rate, and a timer * issues relative pan/tilt steps while the stick is held off-centre. * * It uses relative moves (`linkctl left/right/up/down`) rather than absolute * ones on purpose. `linkctl status` reports no pan/tilt at all while the * camera is idle, so there is no reliable position to drive an absolute * target from, and relative steps cannot drift out of sync with the camera. * linkctl clamps each step to the device's own range, so holding the stick at * a limit is harmless. */ import QtQuick import QtQuick.Layouts import org.kde.plasma.plasmoid import org.kde.plasma.components as PlasmaComponents import org.kde.plasma.plasma5support as Plasma5Support import org.kde.kirigami as Kirigami import "logic.js" as Logic PlasmoidItem { id: root readonly property string linkctl: Plasmoid.configuration.linkctlPath || "linkctl" readonly property string device: Plasmoid.configuration.device readonly property int stepMax: Plasmoid.configuration.maxStep readonly property int tickMs: Plasmoid.configuration.tickInterval readonly property bool invertPan: Plasmoid.configuration.invertPan readonly property bool invertTilt: Plasmoid.configuration.invertTilt property real panDeg: NaN property real tiltDeg: NaN property real zoomFactor: 1.0 property int trackingState: -1 // -1 unknown, 0 off, 1 on property string statusText: "Not queried yet" property bool busy: false // Held movement keys. Separate booleans rather than one object, because // mutating a member of a `var` object does not re-evaluate bindings. property bool keyLeft: false property bool keyRight: false property bool keyUp: false property bool keyDown: false readonly property var keyVec: Logic.vectorFromKeys({ left: root.keyLeft, right: root.keyRight, up: root.keyUp, down: root.keyDown }) readonly property bool keyEngaged: root.keyVec.nx !== 0 || root.keyVec.ny !== 0 // The pointer wins while it is down, so a drag is never fought over by a // key left held down underneath it. readonly property real inNx: stick.held ? stick.nx : root.keyVec.nx readonly property real inNy: stick.held ? stick.ny : root.keyVec.ny readonly property bool inEngaged: stick.engaged || root.keyEngaged // WASD and the arrow keys. Returns "" for anything that is not a // movement key, so the handler can leave it unaccepted. function dirForKey(key) { switch (key) { case Qt.Key_A: case Qt.Key_Left: return "left"; case Qt.Key_D: case Qt.Key_Right: return "right"; case Qt.Key_W: case Qt.Key_Up: return "up"; case Qt.Key_S: case Qt.Key_Down: return "down"; } return ""; } function setKey(dir, down) { switch (dir) { case "left": root.keyLeft = down; break; case "right": root.keyRight = down; break; case "up": root.keyUp = down; break; case "down": root.keyDown = down; break; } } function clearKeys() { root.keyLeft = false; root.keyRight = false; root.keyUp = false; root.keyDown = false; } function baseCmd() { return Logic.baseCmd(root.linkctl, root.device); } function absorb(stdout) { const u = Logic.absorb(stdout); if (u.pan !== undefined) { root.panDeg = u.pan; } if (u.tilt !== undefined) { root.tiltDeg = u.tilt; } if (u.zoom !== undefined) { root.zoomFactor = u.zoom; } if (u.tracking !== undefined) { root.trackingState = u.tracking ? 1 : 0; } if (u.state !== undefined) { root.statusText = u.state === "active" ? "Active" : "Idle"; } } Plasma5Support.DataSource { id: sh engine: "executable" connectedSources: [] onNewData: (source, data) => { const code = data["exit code"]; const out = data.stdout || ""; const err = (data.stderr || "").trim(); disconnectSource(source); root.busy = false; if (code === 0) { root.absorb(out); } else { root.statusText = Logic.errorMessage(out, err, code); } } function exec(cmd) { root.busy = true; connectSource(cmd); } } function refresh() { sh.exec(root.baseCmd() + " status"); } function refreshTracking() { sh.exec(root.baseCmd() + " tracking status"); } function action(args) { sh.exec(root.baseCmd() + " " + args); } Component.onCompleted: refresh() fullRepresentation: ColumnLayout { id: panel Layout.minimumWidth: Kirigami.Units.gridUnit * 14 Layout.minimumHeight: Kirigami.Units.gridUnit * 22 spacing: Kirigami.Units.smallSpacing // Keyboard control lives here rather than on the pad so the keys work // as soon as the popup opens, without having to click the pad first. focus: true Component.onCompleted: forceActiveFocus() Keys.onPressed: (event) => { // Auto-repeat would otherwise look like a rapid press/release // cycle and make the motion stutter. if (event.isAutoRepeat) { event.accepted = true; return; } const d = root.dirForKey(event.key); if (d !== "") { root.setKey(d, true); event.accepted = true; } } Keys.onReleased: (event) => { if (event.isAutoRepeat) { event.accepted = true; return; } const d = root.dirForKey(event.key); if (d !== "") { root.setKey(d, false); event.accepted = true; } } // Losing focus mid-press means the release never arrives, which would // leave the camera panning on its own. onActiveFocusChanged: { if (!activeFocus) { root.clearKeys(); } } PlasmaComponents.Label { Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter text: { if (isNaN(root.panDeg) && isNaN(root.tiltDeg)) { return root.statusText; } const p = isNaN(root.panDeg) ? "?" : root.panDeg.toFixed(1); const t = isNaN(root.tiltDeg) ? "?" : root.tiltDeg.toFixed(1); return "Pan " + p + "° Tilt " + t + "°"; } elide: Text.ElideRight } Joystick { id: stick Layout.alignment: Qt.AlignHCenter Layout.preferredWidth: Kirigami.Units.gridUnit * 10 Layout.preferredHeight: Layout.preferredWidth deadzone: Plasmoid.configuration.deadzone / 100.0 // Re-read the true position once motion stops, so the readout is // the camera's own value rather than an accumulation of steps. onReleased: root.refresh() } PlasmaComponents.Label { Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter text: "Drag, or use WASD / arrow keys" opacity: 0.6 font: Kirigami.Theme.smallFont } Timer { // Drives both input methods. Only runs while something is asking // for movement, so an idle popup costs nothing. interval: root.tickMs repeat: true running: root.inEngaged triggeredOnStart: true onTriggered: { if (root.busy) { return; // let the previous step finish, skip this one } const cmd = Logic.tickCommand(root.baseCmd(), root.inNx, root.inNy, root.stepMax, root.invertPan, root.invertTilt); if (cmd !== "") { sh.exec(cmd); } } } // Refresh the readout once keyboard movement stops, matching what the // pad does on release. Connections { target: root function onKeyEngagedChanged() { if (!root.keyEngaged) { root.refresh(); } } } RowLayout { Layout.fillWidth: true spacing: Kirigami.Units.smallSpacing PlasmaComponents.Label { text: "Zoom" } PlasmaComponents.Slider { id: zoomSlider Layout.fillWidth: true // Keep arrow keys with the panel instead of nudging zoom. focusPolicy: Qt.NoFocus from: 1.0 to: 4.0 stepSize: 0.1 value: root.zoomFactor // Only act when the drag ends: one command per gesture. onPressedChanged: { if (!pressed) { root.action("zoom " + value.toFixed(1)); } } } PlasmaComponents.Label { text: zoomSlider.value.toFixed(1) + "x" } } RowLayout { Layout.fillWidth: true spacing: Kirigami.Units.smallSpacing PlasmaComponents.Button { focusPolicy: Qt.NoFocus Layout.fillWidth: true icon.name: "go-home" text: "Home" onClicked: root.action("preset load " + Logic.shq(Plasmoid.configuration.homePreset)) } PlasmaComponents.Button { focusPolicy: Qt.NoFocus Layout.fillWidth: true icon.name: "crosshairs" text: "Center" onClicked: root.action("center") } } PlasmaComponents.Button { focusPolicy: Qt.NoFocus Layout.fillWidth: true icon.name: root.trackingState === 1 ? "view-visible" : "view-hidden" text: root.trackingState === 1 ? "Tracking: on" : root.trackingState === 0 ? "Tracking: off" : "Tracking: unknown" onClicked: root.action("tracking toggle") } PlasmaComponents.Button { focusPolicy: Qt.NoFocus Layout.fillWidth: true icon.name: "view-refresh" text: "Refresh" onClicked: { root.refresh(); root.refreshTracking(); } } Item { Layout.fillHeight: true } } }