diff --git a/build.gradle b/build.gradle index 85be5356..5071e3fe 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,8 @@ -import groovyx.gpars.GParsPool -import groovy.xml.NamespaceBuilder -import org.kohsuke.github.GitHub -import org.ajoberstar.grgit.Grgit -import org.ajoberstar.grgit.Credentials - buildscript { repositories { - jcenter() + mavenCentral() maven { - url "http://repo.jenkins-ci.org/releases/" + url "https://repo.jenkins-ci.org/releases/" } } dependencies { @@ -17,10 +11,6 @@ buildscript { } } -plugins { - id 'org.ajoberstar.grgit' version '1.3.2' -} - apply plugin: 'java' apply plugin: 'eclipse' @@ -63,15 +53,20 @@ def keyAlias = 'cscert' def tsaURL = 'http://timestamp.comodoca.com/rfc3161' repositories { - jcenter() - // This repository is directly included even though we use jcenter - // because it makes it easier to get the smallest set of marytts that - // meets our needs without missing the freetts dependency + mavenCentral() + // Bintray is defunct — marytts artifacts are available on Maven Central + // and the DFKI Maven repo below. The phrack repo is no longer needed. + maven { + url "https://maven.dcm4che.org/" + } maven { - url "http://dl.bintray.com/marytts/marytts/" + url "https://raw.githubusercontent.com/DFKI-MLT/Maven-Repository/main/" } maven { - url "https://dl.bintray.com/phrack/maven/" + url "https://nrgxnat.jfrog.io/artifactory/libs-release/" + } + maven { + url "https://nexus.terrestris.de/repository/public/" } } @@ -80,69 +75,69 @@ configurations { // Some dependencies (webcam-capture and marytts?) pull in slf4j implementations that conflict // with logback - compile.exclude group: 'org.slf4j', module: 'slf4j-log4j12' + implementation.exclude group: 'org.slf4j', module: 'slf4j-log4j12' } dependencies { jfxant files("$javaHome" + "/../lib/ant-javafx.jar") // FindBugs annotations to suppress warnings - compile group: 'com.google.code.findbugs', name: 'annotations', version: '3.+' + implementation group: 'com.google.code.findbugs', name: 'annotations', version: '3.+' // Raven, exception reporting client (requires logback) - compile group: 'com.getsentry.raven', name: 'raven-logback', version: '7.+' + //implementation group: 'com.getsentry.raven', name: 'raven-logback', version: '7.+' // Logback to enable exception reporting - compile group: 'ch.qos.logback', name: 'logback-core', version: '1.+' - compile group: 'ch.qos.logback', name: 'logback-classic', version: '1.+' + implementation group: 'ch.qos.logback', name: 'logback-core', version: '1.2.12' + implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.12' // bridj is here because webcam-capture depends on it but fetches 0.6.2, which // does not play nicely with stackguard in newer versions of the JVM. - compile group: 'com.nativelibs4java', name: 'bridj', version: '0.7.0' - compile group: 'com.github.sarxos', name: 'webcam-capture', version: '0.3.+' - compile group: 'com.github.sarxos', name: 'webcam-capture-driver-ipcam', version: '0.3.+' - compile group: 'com.github.sarxos', name: 'webcam-capture-driver-v4l4j', version: '0.3.+' + implementation group: 'com.nativelibs4java', name: 'bridj', version: '0.7.0' + implementation group: 'com.github.sarxos', name: 'webcam-capture', version: '0.3.+' + implementation group: 'com.github.sarxos', name: 'webcam-capture-driver-ipcam', version: '0.3.+' + implementation group: 'com.github.sarxos', name: 'webcam-capture-driver-v4l4j', version: '0.3.+' - compile group: 'commons-cli', name: 'commons-cli', version: '1.+' - compile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.+' - compile group: 'org.apache.httpcomponents', name: 'httpmime', version: '4.+' + implementation group: 'commons-cli', name: 'commons-cli', version: '1.+' + implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.+' + implementation group: 'org.apache.httpcomponents', name: 'httpmime', version: '4.+' // tts dependencies - compile group: 'de.dfki.mary', name: 'marytts-runtime', version: '5.1.+' - compile group: 'de.dfki.mary', name: 'marytts-lang-en', version: '5.1.+' - compile group: 'de.dfki.mary', name: 'voice-cmu-slt-hsmm', version: '5.1.+' + implementation group: 'de.dfki.mary', name: 'marytts-runtime', version: '5.2.+' + implementation group: 'de.dfki.mary', name: 'marytts-lang-en', version: '5.2.+' + implementation group: 'de.dfki.mary', name: 'voice-cmu-slt-hsmm', version: '5.2.+' // xuggle for recording and playing back video - compile group: 'xuggle', name: 'xuggle-xuggler', version: '5.4' + implementation group: 'xuggle', name: 'xuggle-xuggler', version: '5.4' // JSON - compile group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1+' + implementation group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1+' // OpenImaj - compile('org.openimaj:core:1.+') { + implementation('org.openimaj:core:1.+') { // OpenImaj transitive dependency that we don't need and that doesn't seem to exist in // repos anymore exclude group: 'vigna.dsi.unimi.it' } //OpenCV - compile 'org.openpnp:opencv:2.4.+' + implementation 'org.openpnp:opencv:2.4.+' // OSHI to collect HW and system state data - compile group: 'com.github.dblock', name: 'oshi-core', version: '3.+' + implementation group: 'com.github.dblock', name: 'oshi-core', version: '3.+' // JSoup to get processor benchmark data - compile group: 'org.jsoup', name: 'jsoup', version: '1.+' - + implementation group: 'org.jsoup', name: 'jsoup', version: '1.+' + // Bluetooth libraries, QR code generator, and JSON serializer for headless mode - compile 'net.sf.bluecove:bluecove:2.1.0' + implementation 'net.sf.bluecove:bluecove:2.1.0' // Assumption that headless mode will only be supported on Linux - compile 'net.sf.bluecove:bluecove-gpl:2.1.0' - compile 'com.google.zxing:core:3.3.0' - compile 'com.google.code.gson:gson:2.8.0' + implementation 'net.sf.bluecove:bluecove-gpl:2.1.0' + implementation 'com.google.zxing:core:3.3.0' + implementation 'com.google.code.gson:gson:2.8.0' - testCompile group: 'junit', name: 'junit', version: '4.+' - testCompile group: 'org.hamcrest', name: 'hamcrest-core', version: '1.+' + testImplementation group: 'junit', name: 'junit', version: '4.+' + testImplementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.+' } test { @@ -177,7 +172,7 @@ task copySounds(type:Copy) { } task copyLibs(type:Copy) { - from { configurations.default.collect { it.isDirectory() ? it : it } } + from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : it } } into libTempDir exclude "*jfxrt.jar" exclude "*java2html.jar" @@ -202,6 +197,23 @@ task updateJars() { } } +task simpleJar(type: Jar, dependsOn: [build, 'copyConfig', 'copyTargets', 'copyCourses', 'copySounds', 'copyLibs', 'copyEyeCam']) { + description 'Create a runnable jar for ShootOFF (without ant-javafx)' + group 'Package' + + destinationDirectory = file('build/dist') + archiveFileName = 'ShootOFF.jar' + + from sourceSets.main.output + + manifest { + attributes( + 'Main-Class': mainClassName, + 'Class-Path': configurations.runtimeClasspath.collect { 'libs/' + it.name }.join(' ') + ) + } +} + task fxJar(dependsOn: build) { dependsOn('copyConfig') dependsOn('copyTargets') @@ -213,11 +225,11 @@ task fxJar(dependsOn: build) { description 'Create a runnable jar for ShootOFF' group 'Package' - inputs.dir sourceSets.main.output.classesDir + inputs.files sourceSets.main.output.classesDirs inputs.dir sourceSets.main.output.resourcesDir outputs.file archivePath - def antfx = NamespaceBuilder.newInstance( + def antfx = groovy.xml.NamespaceBuilder.newInstance( ant, 'javafx:com.sun.javafx.tools.ant') @@ -234,7 +246,9 @@ task fxJar(dependsOn: build) { antfx.jar(destfile: archivePath) { application(refid: project.name) - fileset(dir: sourceSets.main.output.classesDir) + sourceSets.main.output.classesDirs.each { classDir -> + fileset(dir: classDir) + } fileset(dir: sourceSets.main.output.resourcesDir) antfx.resources() { fileset(dir: 'build/dist/', includes: 'libs/*.jar') @@ -270,8 +284,8 @@ task zipRelease(type: Zip) { include 'eyeCam64.dll' } - archiveName = releaseZip - destinationDir = file(dist) + archiveFileName = releaseZip + destinationDirectory = file(dist) } task getDiagnosticJar() { @@ -286,7 +300,7 @@ task getDiagnosticJar() { password = new String(password) } - gh = GitHub.connectUsingPassword(username, password) + gh = org.kohsuke.github.GitHub.connectUsingPassword(username, password) if (!gh.isCredentialValid()) { throw new InvalidUserDataException('Incorrect GitHub credentials') @@ -329,7 +343,7 @@ task pushZipRelease(dependsOn: zipRelease) { password = new String(password) } - if (!gh) gh = GitHub.connectUsingPassword(username, password) + if (!gh) gh = org.kohsuke.github.GitHub.connectUsingPassword(username, password) if (!gh.isCredentialValid()) { throw new InvalidUserDataException('Incorrect GitHub credentials') @@ -364,7 +378,7 @@ task pushZipRelease(dependsOn: zipRelease) { // Update version metadata - grgit = Grgit.open('.', new Credentials(username: username, password: password)) + grgit = org.ajoberstar.grgit.Grgit.open(file('.')) grgit.checkout(branch: 'gh-pages', createBranch: false) def versionMetadata = '\n' + @@ -406,7 +420,7 @@ task fxSignedJar(dependsOn: fxJar) { } task fxJarWritableResources() { - def antfx = NamespaceBuilder.newInstance( + def antfx = groovy.xml.NamespaceBuilder.newInstance( ant, 'javafx:com.sun.javafx.tools.ant') @@ -431,7 +445,7 @@ task msiRelease() { dependsOn('updateJars') dependsOn(getDiagnosticJar) - def antfx = NamespaceBuilder.newInstance( + def antfx = groovy.xml.NamespaceBuilder.newInstance( ant, 'javafx:com.sun.javafx.tools.ant') @@ -558,7 +572,7 @@ task fxWebstartSignedJar() { ) } - GParsPool.withPool { + groovyx.gpars.GParsPool.withPool { path.list().eachParallel { f -> def antLocal = project.createAntBuilder() antLocal.signjar( @@ -574,7 +588,7 @@ task fxWebstartSignedJar() { } task fxRelease(dependsOn: fxWebstartSignedJar) { - def antfx = NamespaceBuilder.newInstance( + def antfx = groovy.xml.NamespaceBuilder.newInstance( ant, 'javafx:com.sun.javafx.tools.ant') @@ -678,7 +692,7 @@ task pushFxRelease(dependsOn: fxRelease) { password = new String(password) } - grgit = Grgit.open('.', new Credentials(username: username, password: password)) + grgit = org.ajoberstar.grgit.Grgit.open(file('.')) grgit.checkout(branch: 'gh-pages', createBranch: false) delete jwsDir + 'libs' diff --git a/shootoff.sh b/shootoff.sh new file mode 100755 index 00000000..80b4cd1e --- /dev/null +++ b/shootoff.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# ShootOFF launcher for Linux +# Finds and preloads v4l1compat.so automatically + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Find v4l1compat.so +V4L_PATHS=( + "/usr/lib/libv4l/v4l1compat.so" + "/usr/lib/x86_64-linux-gnu/libv4l/v4l1compat.so" + "/usr/lib/aarch64-linux-gnu/libv4l/v4l1compat.so" + "/usr/lib/i386-linux-gnu/libv4l/v4l1compat.so" +) + +V4L_COMPAT="" +for path in "${V4L_PATHS[@]}"; do + if [ -f "$path" ]; then + V4L_COMPAT="$path" + break + fi +done + +if [ -z "$V4L_COMPAT" ]; then + # Try to find it dynamically + V4L_COMPAT=$(find /usr/lib -name "v4l1compat.so" 2>/dev/null | head -1) +fi + +if [ -n "$V4L_COMPAT" ]; then + echo "Using v4l1compat: $V4L_COMPAT" + export LD_PRELOAD="$V4L_COMPAT" +else + echo "WARNING: v4l1compat.so not found. Camera may not work." + echo "Install it with: sudo apt install libv4l-0" +fi + +cd "$SCRIPT_DIR" +exec java -jar build/dist/ShootOFF.jar "$@" diff --git a/src/main/java/com/shootoff/Main.java b/src/main/java/com/shootoff/Main.java index a190f74f..9107501b 100644 --- a/src/main/java/com/shootoff/Main.java +++ b/src/main/java/com/shootoff/Main.java @@ -50,8 +50,6 @@ import com.shootoff.util.HardwareData; import com.shootoff.util.SystemInfo; import com.shootoff.util.VersionChecker; -import com.sun.deploy.uitoolkit.impl.fx.HostServicesFactory; -import com.sun.javafx.application.HostServicesDelegate; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import javafx.application.Application; @@ -520,8 +518,7 @@ public void checkVersion() { final Hyperlink lnk = new Hyperlink(link); lnk.setOnAction((event) -> { - final HostServicesDelegate hostServices = HostServicesFactory.getInstance(this); - hostServices.showDocument(link); + getHostServices().showDocument(link); lnk.setVisited(true); }); @@ -549,7 +546,8 @@ public void runShootOFF() { return; } - if (version.isPresent() && !config.inDebugMode() && !isJWS) checkVersion(); + // shootoffapp.com is defunct — skip the version check + // if (version.isPresent() && !config.inDebugMode() && !isJWS) checkVersion(); // This initializes the TTS engine TextToSpeech.say(""); @@ -788,18 +786,31 @@ public static void main(String[] args) { CameraFactory.getDefault(); } else if (SystemInfo.isLinux()) { // Need to ensure v4l1compat is preloaded if it exists otherwise - // OpenCV won't work - final File v4lCompat = new File("/usr/lib/libv4l/v4l1compat.so"); + // OpenCV won't work. Check multiple known paths since distros + // vary in where they install this library. + final String[] v4lPaths = { + "/usr/lib/libv4l/v4l1compat.so", + "/usr/lib/x86_64-linux-gnu/libv4l/v4l1compat.so", + "/usr/lib/aarch64-linux-gnu/libv4l/v4l1compat.so", + "/usr/lib/i386-linux-gnu/libv4l/v4l1compat.so" + }; + + File v4lCompat = null; + for (final String path : v4lPaths) { + final File candidate = new File(path); + if (candidate.exists()) { + v4lCompat = candidate; + break; + } + } - if (v4lCompat.exists()) { + if (v4lCompat != null) { final String preload = System.getenv("LD_PRELOAD"); if (preload == null || !preload.contains(v4lCompat.getPath())) { closeNoV4lCompat(v4lCompat); } } else { - // The over-exuberance here is because a lot of people miss this - // message logger.warn("!!!!!!!!!!!!!!!!!!!!!!!!!!!\n" + "This system is running Linux, and likely therefore also v4l. " + "If ShootOFF fails to run or has camera problems, it's likely because you need " diff --git a/src/main/java/com/shootoff/camera/CameraFactory.java b/src/main/java/com/shootoff/camera/CameraFactory.java index 1ef86a21..57d96bc1 100644 --- a/src/main/java/com/shootoff/camera/CameraFactory.java +++ b/src/main/java/com/shootoff/camera/CameraFactory.java @@ -28,6 +28,7 @@ import com.github.sarxos.webcam.Webcam; import com.github.sarxos.webcam.WebcamCompositeDriver; +import com.github.sarxos.webcam.WebcamDiscoveryService; import com.github.sarxos.webcam.ds.buildin.WebcamDefaultDriver; import com.github.sarxos.webcam.ds.ipcam.IpCamDevice; import com.github.sarxos.webcam.ds.ipcam.IpCamDriver; @@ -51,6 +52,10 @@ public final class CameraFactory { private static final boolean isMac; private static Webcam defaultWebcam = null; private static List knownWebcams; + // Cache of sarxos Webcam objects from initial discovery (Linux). + // Re-calling Webcam.getWebcams() after stopping the discovery service + // can fail, so we cache the result. + private static List cachedSarxosWebcams = null; private static List openCameras = Collections.synchronizedList(new ArrayList<>()); @@ -106,9 +111,10 @@ public static Optional getDefault() { else defaultCam = new SarxosCaptureCamera(defaultWebcam.getName()); } else { - final Webcam cam = Webcam.getDefault(); - - defaultCam = cam == null ? null : new SarxosCaptureCamera(cam.getName()); + // Use getWebcams() instead of Webcam.getDefault() to avoid + // re-triggering discovery after we've stopped the service + final List all = getWebcams(); + defaultCam = all.isEmpty() ? null : all.get(0); } if (defaultCam == null && !registeredCameras.isEmpty()) { @@ -123,8 +129,19 @@ public static List getWebcams() { final List webcams = new ArrayList<>(); + if (cachedSarxosWebcams == null) { + cachedSarxosWebcams = Webcam.getWebcams(); + + // Stop the background discovery service to prevent it from leaking + // V4L2 file descriptors on Linux. The bridj V4L2 driver opens devices + // during periodic scans but doesn't always close them, exhausting the + // 16-device limit and preventing ffmpeg from accessing cameras. + stopDiscoveryService(); + } + final List sarxosWebcams = cachedSarxosWebcams; + int cameraIndex = 0; - for (final Webcam w : Webcam.getWebcams()) { + for (final Webcam w : sarxosWebcams) { final Camera c; if (w.getDevice() instanceof IpCamDevice) c = new IpCamera(w); @@ -165,6 +182,18 @@ public static void openCamerasAdd(Camera camera) { } } + private static void stopDiscoveryService() { + try { + final WebcamDiscoveryService ds = Webcam.getDiscoveryServiceRef(); + if (ds != null && ds.isRunning()) { + ds.stop(); + logger.info("Stopped webcam discovery service to prevent V4L2 FD leak"); + } + } catch (final Exception e) { + logger.warn("Could not stop webcam discovery service", e); + } + } + public static boolean isMac() { return isMac; } diff --git a/src/main/java/com/shootoff/camera/cameratypes/SarxosCaptureCamera.java b/src/main/java/com/shootoff/camera/cameratypes/SarxosCaptureCamera.java index 5720f1fe..36d3e522 100644 --- a/src/main/java/com/shootoff/camera/cameratypes/SarxosCaptureCamera.java +++ b/src/main/java/com/shootoff/camera/cameratypes/SarxosCaptureCamera.java @@ -1,17 +1,17 @@ /* * ShootOFF - Software for Laser Dry Fire Training * Copyright (C) 2016 phrack - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ @@ -20,10 +20,15 @@ import java.awt.Dimension; import java.awt.image.BufferedImage; +import java.awt.image.DataBufferByte; +import java.io.InputStream; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.opencv.core.CvType; import org.opencv.core.Mat; import org.opencv.highgui.Highgui; import org.opencv.highgui.VideoCapture; @@ -45,9 +50,40 @@ public class SarxosCaptureCamera extends CalculatedFPSCamera { public static final int CV_CAP_PROP_EXPOSURE = 15; private int cameraIndex = -1; + private int discoveryIndex = -1; + private String cameraName; private final VideoCapture camera; + // Fallback: use ffmpeg process when OpenCV can't handle the device + private Process ffmpegProcess = null; + private InputStream ffmpegStream = null; + private boolean usingFfmpegFallback = false; + private int ffmpegWidth = 640; + private int ffmpegHeight = 480; + private String devicePath = null; + private final AtomicBoolean closing = new AtomicBoolean(false); + private static final Pattern DEV_VIDEO_PATTERN = Pattern.compile("/dev/video(\\d+)"); + + private static int resolveDeviceIndex(String cameraName, int fallbackIndex) { + final Matcher m = DEV_VIDEO_PATTERN.matcher(cameraName); + if (m.find()) { + final int deviceIndex = Integer.parseInt(m.group(1)); + if (deviceIndex != fallbackIndex) { + LoggerFactory.getLogger(SarxosCaptureCamera.class).info( + "Camera '{}': using /dev/video{} instead of discovery index {}", + cameraName, deviceIndex, fallbackIndex); + } + return deviceIndex; + } + return fallbackIndex; + } + + private static String extractDevicePath(String cameraName) { + final Matcher m = DEV_VIDEO_PATTERN.matcher(cameraName); + if (m.find()) return m.group(); + return null; + } // For testing protected SarxosCaptureCamera() { @@ -68,26 +104,32 @@ public SarxosCaptureCamera(final String cameraName) { if (cameraIndex < 0) throw new IllegalArgumentException("Camera not found: " + cameraName); camera = new VideoCapture(); - this.cameraIndex = cameraIndex; - + this.cameraName = cameraName; + this.discoveryIndex = cameraIndex; + this.cameraIndex = resolveDeviceIndex(cameraName, cameraIndex); + this.devicePath = extractDevicePath(cameraName); } public SarxosCaptureCamera(final String cameraName, int cameraIndex) { if (cameraIndex < 0) throw new IllegalArgumentException("Camera not found: " + cameraName); camera = new VideoCapture(); - this.cameraIndex = cameraIndex; - + this.cameraName = cameraName; + this.discoveryIndex = cameraIndex; + this.cameraIndex = resolveDeviceIndex(cameraName, cameraIndex); + this.devicePath = extractDevicePath(cameraName); } @Override public Frame getFrame() { + if (usingFfmpegFallback) { + return getFfmpegFrame(); + } + final Mat frame = new Mat(); try { if (!isOpen() || !camera.read(frame) || frame.size().height == 0 || frame.size().width == 0) return null; } catch (final Exception e) { - // Sometimes there is a race condition on closing the camera vs. - // read() return null; } @@ -96,6 +138,33 @@ public Frame getFrame() { return new Frame(frame, currentFrameTimestamp); } + private Frame getFfmpegFrame() { + try { + if (ffmpegStream == null) return null; + final int frameSize = ffmpegWidth * ffmpegHeight * 3; + final byte[] buf = new byte[frameSize]; + int offset = 0; + while (offset < frameSize) { + final int read = ffmpegStream.read(buf, offset, frameSize - offset); + if (read == -1) { + logger.warn("ffmpeg stream ended"); + return null; + } + offset += read; + } + + final Mat mat = new Mat(ffmpegHeight, ffmpegWidth, CvType.CV_8UC3); + mat.put(0, 0, buf); + + final long currentFrameTimestamp = System.currentTimeMillis(); + frameCount++; + return new Frame(mat, currentFrameTimestamp); + } catch (final Exception e) { + logger.error("Error reading ffmpeg frame", e); + return null; + } + } + @Override public BufferedImage getBufferedImage() { final Frame frame = getFrame(); @@ -116,21 +185,127 @@ public synchronized boolean open() { closing.set(false); - final boolean open = camera.open(cameraIndex); + boolean open = false; - if (open) { - // Set the max FPS to 60. If we don't set this it defaults - // to 30, which unnecessarily hampers higher end cameras - camera.set(5, 60); + // On Linux with a known device path, use ffmpeg directly. + // OpenCV 2.4's v4l1compat interaction poisons the V4L2 device state, + // making it inaccessible to ffmpeg (and sometimes itself) afterwards. + if (devicePath != null && System.getProperty("os.name", "").toLowerCase().contains("linux")) { + logger.info("Linux detected with device path {}, using ffmpeg directly", devicePath); + open = openFfmpegFallback(); + } + if (!open) { + // Try OpenCV (works well on Windows/Mac, or Linux without device path) + open = camera.open(cameraIndex); + + if (open) { + // Verify we can actually read a frame + final Mat testFrame = new Mat(); + boolean canRead = false; + for (int i = 0; i < 3; i++) { + if (camera.read(testFrame) && testFrame.size().height > 0) { + canRead = true; + break; + } + try { Thread.sleep(100); } catch (InterruptedException ignored) {} + } + if (!canRead) { + logger.warn("OpenCV opened device {} but cannot read frames", cameraIndex); + camera.release(); + open = false; + } + } + } + + if (open) { + if (!usingFfmpegFallback) { + camera.set(5, 60); + } CameraFactory.openCamerasAdd(this); } return open; } + private boolean openFfmpegFallback() { + logger.info("Attempting ffmpeg fallback for {}", devicePath); + + // Try 640x480 first, then 320x240 + final int[][] resolutions = {{640, 480}, {320, 240}}; + + for (final int[] res : resolutions) { + // Retry up to 3 times — the webcam-capture discovery service may be + // holding the device briefly during background probing + for (int attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) { + logger.info("Retrying ffmpeg for {} (attempt {})", devicePath, attempt + 1); + try { Thread.sleep(1000); } catch (InterruptedException ignored) {} + } + try { + final ProcessBuilder pb = new ProcessBuilder( + "ffmpeg", + "-loglevel", "error", + "-f", "v4l2", + "-video_size", res[0] + "x" + res[1], + "-i", devicePath, + "-f", "rawvideo", + "-pix_fmt", "bgr24", + "-an", + "-" + ); + pb.redirectErrorStream(false); + // Send ffmpeg's stderr to /dev/null to prevent buffer fill-up + pb.redirectError(new java.io.File("/dev/null")); + final Process proc = pb.start(); + + // Read a test frame to verify it works + final InputStream stream = proc.getInputStream(); + final int frameSize = res[0] * res[1] * 3; + final byte[] testBuf = new byte[frameSize]; + int offset = 0; + final long deadline = System.currentTimeMillis() + 5000; + while (offset < frameSize && System.currentTimeMillis() < deadline) { + final int read = stream.read(testBuf, offset, frameSize - offset); + if (read == -1) break; + offset += read; + } + + if (offset == frameSize) { + ffmpegProcess = proc; + ffmpegStream = stream; + ffmpegWidth = res[0]; + ffmpegHeight = res[1]; + usingFfmpegFallback = true; + logger.info("ffmpeg fallback opened at {}x{} for {}", res[0], res[1], devicePath); + + // Ensure ffmpeg is killed when JVM exits + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (ffmpegProcess != null && ffmpegProcess.isAlive()) { + ffmpegProcess.destroyForcibly(); + } + })); + + return true; + } else { + logger.info("ffmpeg could not read full frame at {}x{} (got {} of {} bytes, attempt {})", + res[0], res[1], offset, frameSize, attempt + 1); + proc.destroyForcibly(); + } + } catch (final Exception e) { + logger.error("ffmpeg fallback failed at {}x{}", res[0], res[1], e); + } + } + } + + return false; + } + @Override public boolean isOpen() { + if (usingFfmpegFallback) { + return ffmpegProcess != null && ffmpegProcess.isAlive(); + } return camera.isOpened(); } @@ -141,12 +316,20 @@ public synchronized void close() { if (isOpen() && !closing.get()) { closing.set(true); - resetExposure(); - camera.release(); + if (usingFfmpegFallback) { + if (ffmpegProcess != null) { + ffmpegProcess.destroyForcibly(); + ffmpegProcess = null; + ffmpegStream = null; + } + } else { + resetExposure(); + camera.release(); + } CameraFactory.openCamerasRemove(this); if (cameraEventListener.isPresent()) cameraEventListener.get().cameraClosed(); - + } else if (isOpen() && closing.get()) { return; } else if (!isOpen()) { @@ -158,23 +341,29 @@ public synchronized void close() { @Override public String getName() { - return Webcam.getWebcams().get(cameraIndex).getName(); + if (cameraName != null) return cameraName; + return Webcam.getWebcams().get(discoveryIndex >= 0 ? discoveryIndex : cameraIndex).getName(); } @Override public void setViewSize(final Dimension size) { - camera.set(Highgui.CV_CAP_PROP_FRAME_WIDTH, size.getWidth()); - camera.set(Highgui.CV_CAP_PROP_FRAME_HEIGHT, size.getHeight()); + if (!usingFfmpegFallback) { + camera.set(Highgui.CV_CAP_PROP_FRAME_WIDTH, size.getWidth()); + camera.set(Highgui.CV_CAP_PROP_FRAME_HEIGHT, size.getHeight()); + } } @Override public Dimension getViewSize() { + if (usingFfmpegFallback) { + return new Dimension(ffmpegWidth, ffmpegHeight); + } return new Dimension((int) camera.get(Highgui.CV_CAP_PROP_FRAME_WIDTH), (int) camera.get(Highgui.CV_CAP_PROP_FRAME_HEIGHT)); } public void launchCameraSettings() { - camera.set(Highgui.CV_CAP_PROP_SETTINGS, 1); + if (!usingFfmpegFallback) camera.set(Highgui.CV_CAP_PROP_SETTINGS, 1); } @Override @@ -213,8 +402,8 @@ public boolean isLocked() { @Override public boolean supportsExposureAdjustment() { - // If we already verified that it works, - // we have an origExposure value set + if (usingFfmpegFallback) return false; + if (origExposure.isPresent()) return true; final double exp = camera.get(CV_CAP_PROP_EXPOSURE); @@ -237,15 +426,8 @@ public boolean supportsExposureAdjustment() { @Override public boolean decreaseExposure() { - // Logic: - // If camera exposure is positive, decrease towards zero - // If camera exposure is negative and between -9.9 and 0, increase - // towards zero (Logitech c270) - // If camera exposure is negative and less than -10, decrease away from - // zero (oCam) - - // In any case, if exposure doesn't change in the same direction when we - // change it, fail out. + if (usingFfmpegFallback) return false; + final double curExp = camera.get(CV_CAP_PROP_EXPOSURE); final double newExp; if (curExp <= -10.0) { @@ -256,7 +438,6 @@ public boolean decreaseExposure() { if (logger.isTraceEnabled()) logger.trace("curExp[ {} newExp {}", curExp, newExp); - // If they don't have the same sign, ABORT if (!((curExp < 0) == (newExp < 0)) || Math.abs(curExp - newExp) < .001f) return false; camera.set(CV_CAP_PROP_EXPOSURE, newExp); @@ -272,7 +453,7 @@ public boolean decreaseExposure() { @Override public void resetExposure() { - if (origExposure.isPresent()) camera.set(CV_CAP_PROP_EXPOSURE, origExposure.get()); + if (!usingFfmpegFallback && origExposure.isPresent()) camera.set(CV_CAP_PROP_EXPOSURE, origExposure.get()); } @Override diff --git a/src/main/java/com/shootoff/camera/shotdetection/JavaShotDetector.java b/src/main/java/com/shootoff/camera/shotdetection/JavaShotDetector.java index 4b60de45..9dc79084 100644 --- a/src/main/java/com/shootoff/camera/shotdetection/JavaShotDetector.java +++ b/src/main/java/com/shootoff/camera/shotdetection/JavaShotDetector.java @@ -71,7 +71,9 @@ public final class JavaShotDetector extends FrameProcessingShotDetector { // Individual pixel threshold private final static int MAXIMUM_LUM_VALUE = 65025; private final static int EXCESSIVE_BRIGHTNESS_THRESHOLD = (int) (.96 * MAXIMUM_LUM_VALUE); - private final static int MINIMUM_BRIGHTNESS_INCREASE = (int) (.117 * MAXIMUM_LUM_VALUE);; + private final static int DEFAULT_MINIMUM_BRIGHTNESS_INCREASE = (int) (.117 * MAXIMUM_LUM_VALUE); + private int minimumBrightnessIncrease = DEFAULT_MINIMUM_BRIGHTNESS_INCREASE; + private int dynamicThresholdDivisor = 4; // higher = less aggressive dynamic filtering // Aggregate # of pixel threshold private int BRIGHTNESS_WARNING_AVG_THRESHOLD; @@ -112,6 +114,20 @@ public JavaShotDetector(final CameraManager cameraManager, final CameraView came this.cameraManager = cameraManager; + // Apply detection sensitivity from config (1-10, default 5) + // Sensitivity 1 = 20% of max lum (least sensitive) + // Sensitivity 5 = 11.7% (original default) + // Sensitivity 10 = 1% (most sensitive) + final Configuration cfg = Configuration.getConfig(); + if (cfg != null) { + final int sensitivity = cfg.getDetectionSensitivity(); + // 1->0.20, 5->0.117, 10->0.01 + final double factor = Math.max(0.01, 0.222 - (sensitivity * 0.0213)); + minimumBrightnessIncrease = (int) (factor * MAXIMUM_LUM_VALUE); + // At high sensitivity, relax dynamic threshold slightly + dynamicThresholdDivisor = sensitivity >= 8 ? 5 : 4; + } + setFrameSize(cameraManager.getFeedWidth(), cameraManager.getFeedHeight()); pixelClusterManager = new PixelClusterManager(cameraManager.getFeedWidth(), cameraManager.getFeedHeight()); @@ -132,9 +148,16 @@ public void setFrameSize(final int width, final int height) { final double frameSize = width * height; - MOTION_WARNING_AVG_THRESHOLD = (int) (frameSize * .000395); - MOTION_WARNING_THRESHOLD_PIXELS = (int) (frameSize * 0.00195); - MAXIMUM_THRESHOLD_PIXELS_FOR_MOTION_AVG = (int) (frameSize * 0.00195); + // Scale motion thresholds with detection sensitivity — higher sensitivity + // means more pixels pass the brightness threshold, so we need to tolerate + // more threshold pixels before calling it "excessive motion" + final Configuration motionCfg = Configuration.getConfig(); + final int sens = (motionCfg != null) ? motionCfg.getDetectionSensitivity() : 5; + // sensitivity 5 -> 1x (original), 10 -> 4x + final double motionScale = 1.0 + (Math.max(0, sens - 5) * 0.6); + MOTION_WARNING_AVG_THRESHOLD = (int) (frameSize * .000395 * motionScale); + MOTION_WARNING_THRESHOLD_PIXELS = (int) (frameSize * 0.00195 * motionScale); + MAXIMUM_THRESHOLD_PIXELS_FOR_MOTION_AVG = (int) (frameSize * 0.00195 * motionScale); // Aggregate # of pixel threshold BRIGHTNESS_WARNING_AVG_THRESHOLD = (int) (frameSize * .000325); @@ -187,10 +210,9 @@ private boolean pixelAboveExcessiveBrightnessThreshold(int lumsMovingAverage) { private boolean pixelAboveThreshold(int currentLum, int lumsMovingAverage) { final int increase = (currentLum - lumsMovingAverage); - if (increase < MINIMUM_BRIGHTNESS_INCREASE) return false; + if (increase < minimumBrightnessIncrease) return false; - // (var >> 2) equivalent to (var / 4) - final int threshold = (MAXIMUM_LUM_VALUE - lumsMovingAverage) >> 2; + final int threshold = (MAXIMUM_LUM_VALUE - lumsMovingAverage) / dynamicThresholdDivisor; final int dynamic_increase = (int) ((MAXIMUM_LUM_VALUE - threshold) * ((double) avgThresholdPixels / (double) MAXIMUM_THRESHOLD_PIXELS_FOR_AVG)); diff --git a/src/main/java/com/shootoff/camera/shotdetection/PixelClusterManager.java b/src/main/java/com/shootoff/camera/shotdetection/PixelClusterManager.java index df4d8096..2e64f8f4 100644 --- a/src/main/java/com/shootoff/camera/shotdetection/PixelClusterManager.java +++ b/src/main/java/com/shootoff/camera/shotdetection/PixelClusterManager.java @@ -179,9 +179,22 @@ public Set clusterPixels(Set clusterablePixels, int minimum shotHeight, shotRatio, minX, minY, maxX, maxY); if ((shotWidth + shotHeight) > SMALL_SHOT_THRESHOLD - && (shotRatio < MINIMUM_SHOT_RATIO || shotRatio > MAXIMUM_SHOT_RATIO)) + && (shotRatio < MINIMUM_SHOT_RATIO || shotRatio > MAXIMUM_SHOT_RATIO)) { + // Cluster is elongated (e.g. laser streak from recoil). + // Use the brightest pixel as the shot origin instead of discarding. + final PixelCluster streakShot = extractBrightestSubCluster(cluster, minimumShotDimension); + if (streakShot != null) { + clusters.add(streakShot); + } + continue; + } + else if (shotRatio < MINIMUM_SHOT_RATIO_SMALL || shotRatio > MAXIMUM_SHOT_RATIO_SMALL) { + final PixelCluster streakShot = extractBrightestSubCluster(cluster, minimumShotDimension); + if (streakShot != null) { + clusters.add(streakShot); + } continue; - else if (shotRatio < MINIMUM_SHOT_RATIO_SMALL || shotRatio > MAXIMUM_SHOT_RATIO_SMALL) continue; + } final double r = (double) (shotWidth + shotHeight) / 4.0f; final double circleArea = Math.PI * r * r; @@ -190,7 +203,14 @@ public Set clusterPixels(Set clusterablePixels, int minimum if (logger.isTraceEnabled()) logger.trace("Cluster {}: density {} {} - {} {} - {}", i, shotWidth, shotHeight, circleArea, cluster.size(), density); - if (density < MINIMUM_DENSITY) continue; + if (density < MINIMUM_DENSITY) { + // Low density may indicate a streak — try to extract the impact point + final PixelCluster streakShot = extractBrightestSubCluster(cluster, minimumShotDimension); + if (streakShot != null) { + clusters.add(streakShot); + } + continue; + } cluster.centerPixelX = averageX; cluster.centerPixelY = averageY; @@ -203,4 +223,61 @@ public Set clusterPixels(Set clusterablePixels, int minimum return clusters; } + + /** + * For elongated clusters (laser streaks from recoil), find the brightest + * pixel — the initial impact point before recoil dragged the laser — and + * build a small sub-cluster around it. + */ + private PixelCluster extractBrightestSubCluster(PixelCluster cluster, int minimumShotDimension) { + if (cluster.size() < minimumShotDimension) return null; + + // Find the brightest pixel (highest currentLum) + Pixel brightest = null; + int maxLum = -1; + for (final Pixel p : cluster) { + if (p.getCurrentLum() > maxLum) { + maxLum = p.getCurrentLum(); + brightest = p; + } + } + + if (brightest == null) return null; + + // Collect nearby pixels within a small radius of the brightest point + final PixelCluster subCluster = new PixelCluster(); + final int radius = 5; + for (final Pixel p : cluster) { + final int dx = p.x - brightest.x; + final int dy = p.y - brightest.y; + if (dx * dx + dy * dy <= radius * radius) { + subCluster.add(p); + } + } + + if (subCluster.size() < minimumShotDimension) { + // Not enough pixels nearby — just use the brightest pixel location + subCluster.clear(); + subCluster.add(brightest); + } + + // Compute center weighted by connectedness + double weightedX = 0, weightedY = 0, totalWeight = 0; + for (final Pixel p : subCluster) { + final int weight = Math.max(1, p.getConnectedness()); + weightedX += p.x * weight; + weightedY += p.y * weight; + totalWeight += weight; + } + subCluster.centerPixelX = weightedX / totalWeight; + subCluster.centerPixelY = weightedY / totalWeight; + + if (logger.isTraceEnabled()) { + logger.trace("Streak detected: {} pixels, brightest at ({},{}), sub-cluster {} pixels at ({},{})", + cluster.size(), brightest.x, brightest.y, + subCluster.size(), subCluster.centerPixelX, subCluster.centerPixelY); + } + + return subCluster; + } } diff --git a/src/main/java/com/shootoff/config/Configuration.java b/src/main/java/com/shootoff/config/Configuration.java index 35ffe226..72bf2772 100644 --- a/src/main/java/com/shootoff/config/Configuration.java +++ b/src/main/java/com/shootoff/config/Configuration.java @@ -88,6 +88,7 @@ public class Configuration { private static final String WEBCAMS_PROP = "shootoff.webcams"; private static final String RECORDING_WEBCAMS_PROP = WEBCAMS_PROP + ".recording"; private static final String MARKER_RADIUS_PROP = "shootoff.markerradius"; + private static final String DETECTION_SENSITIVITY_PROP = "shootoff.detectionsensitivity"; private static final String IGNORE_LASER_COLOR_PROP = "shootoff.ignorelasercolor"; private static final String USE_RED_LASER_SOUND_PROP = "shootoff.redlasersound.use"; private static final String RED_LASER_SOUND_PROP = "shootoff.redlasersound"; @@ -133,6 +134,7 @@ public class Configuration { private final Map ipcamCredentials = new HashMap<>(); private final Map webcams = new HashMap<>(); private int markerRadius = 4; + private int detectionSensitivity = 5; // 1 (least sensitive) to 10 (most sensitive), default 5 private boolean ignoreLaserColor = false; private String ignoreLaserColorName = "None"; private boolean useRedLaserSound = false; @@ -290,7 +292,7 @@ private void readConfigurationFile() throws ConfigurationException, IOException final List webcamInternalNames = new ArrayList<>(); for (final String nameString : prop.getProperty(WEBCAMS_PROP).split(",")) { - final String[] names = nameString.split(":"); + final String[] names = nameString.split(":", 2); if (names.length > 1) { webcamNames.add(names[0].replaceAll("//`", ":")); webcamInternalNames.add(names[1].replaceAll("//`", ":")); @@ -321,6 +323,10 @@ private void readConfigurationFile() throws ConfigurationException, IOException setMarkerRadius(Integer.parseInt(prop.getProperty(MARKER_RADIUS_PROP))); } + if (prop.containsKey(DETECTION_SENSITIVITY_PROP)) { + setDetectionSensitivity(Integer.parseInt(prop.getProperty(DETECTION_SENSITIVITY_PROP))); + } + if (prop.containsKey(IGNORE_LASER_COLOR_PROP)) { final String colorName = prop.getProperty(IGNORE_LASER_COLOR_PROP); @@ -475,6 +481,7 @@ public boolean writeConfigurationFile() throws ConfigurationException, IOExcepti prop.setProperty(WEBCAMS_PROP, webcamList.toString()); prop.setProperty(RECORDING_WEBCAMS_PROP, recordingWebcamList.toString()); prop.setProperty(MARKER_RADIUS_PROP, String.valueOf(markerRadius)); + prop.setProperty(DETECTION_SENSITIVITY_PROP, String.valueOf(detectionSensitivity)); prop.setProperty(IGNORE_LASER_COLOR_PROP, ignoreLaserColorName); prop.setProperty(USE_RED_LASER_SOUND_PROP, String.valueOf(useRedLaserSound)); prop.setProperty(RED_LASER_SOUND_PROP, redLaserSound.getPath()); @@ -707,6 +714,14 @@ public void setMarkerRadius(int markRadius) { markerRadius = markRadius; } + public void setDetectionSensitivity(int sensitivity) { + detectionSensitivity = Math.max(1, Math.min(10, sensitivity)); + } + + public int getDetectionSensitivity() { + return detectionSensitivity; + } + public void setIgnoreLaserColor(boolean ignoreLaserColor) { this.ignoreLaserColor = ignoreLaserColor; } diff --git a/src/main/java/com/shootoff/gui/controller/PreferencesController.java b/src/main/java/com/shootoff/gui/controller/PreferencesController.java index 3519a639..b09739f0 100644 --- a/src/main/java/com/shootoff/gui/controller/PreferencesController.java +++ b/src/main/java/com/shootoff/gui/controller/PreferencesController.java @@ -76,6 +76,8 @@ public class PreferencesController implements DesignateShotRecorderListener, Cam @FXML private ListView webcamListView; @FXML private Slider markerRadiusSlider; @FXML private Label markerRadiusLabel; + @FXML private Slider detectionSensitivitySlider; + @FXML private Label detectionSensitivityLabel; @FXML private ChoiceBox ignoreLaserColorChoiceBox; @FXML private CheckBox redLaserSoundCheckBox; @FXML private TextField redLaserSoundTextField; @@ -136,6 +138,7 @@ public ListCell call(ListView list) { webcamListView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); linkSliderToLabel(markerRadiusSlider, markerRadiusLabel); + linkSliderToLabel(detectionSensitivitySlider, detectionSensitivityLabel); linkSliderToLabel(virtualMagazineSlider, virtualMagazineLabel); linkSliderToLabel(malfunctionsSlider, malfunctionsLabel); @@ -156,6 +159,7 @@ public ListCell call(ListView list) { webcamListView.setItems(cameras); markerRadiusSlider.setValue(config.getMarkerRadius()); + detectionSensitivitySlider.setValue(config.getDetectionSensitivity()); ignoreLaserColorChoiceBox.setValue(config.getIgnoreLaserColorName()); redLaserSoundCheckBox.setSelected(config.useRedLaserSound()); redLaserSoundTextField.setText(config.getRedLaserSound().getPath()); @@ -414,6 +418,7 @@ public void save() throws ConfigurationException, IOException { config.setWebcams(configuredNames, configuredCameras); config.setRecordingCameras(recordingCameras); config.setMarkerRadius((int) markerRadiusSlider.getValue()); + config.setDetectionSensitivity((int) detectionSensitivitySlider.getValue()); config.setIgnoreLaserColor(!ignoreLaserColorChoiceBox.getValue().equals("None")); config.setIgnoreLaserColorName(ignoreLaserColorChoiceBox.getValue()); config.setUseRedLaserSound(redLaserSoundCheckBox.isSelected()); diff --git a/src/main/java/com/shootoff/gui/controller/ShootOFFController.java b/src/main/java/com/shootoff/gui/controller/ShootOFFController.java index 3eac3146..463b202f 100644 --- a/src/main/java/com/shootoff/gui/controller/ShootOFFController.java +++ b/src/main/java/com/shootoff/gui/controller/ShootOFFController.java @@ -498,8 +498,27 @@ private boolean addCameraTab(String webcamName, Camera cameraInterface) { final Tab cameraTab = new Tab(webcamName); final Group cameraCanvasGroup = new Group(); - // 640 x 480 - cameraTab.setContent(new AnchorPane(cameraCanvasGroup)); + final AnchorPane cameraPane = new AnchorPane(cameraCanvasGroup); + cameraTab.setContent(cameraPane); + + // Scale the canvas group to fill available tab space while preserving + // the 4:3 aspect ratio. Coordinates stay at 640x480 internally. + final double baseWidth = config.getDisplayWidth(); + final double baseHeight = config.getDisplayHeight(); + final javafx.scene.transform.Scale scaleTransform = new javafx.scene.transform.Scale(1, 1); + cameraCanvasGroup.getTransforms().add(scaleTransform); + + final Runnable updateScale = () -> { + final double paneW = cameraPane.getWidth(); + final double paneH = cameraPane.getHeight(); + if (paneW > 0 && paneH > 0) { + final double scale = Math.min(paneW / baseWidth, paneH / baseHeight); + scaleTransform.setX(scale); + scaleTransform.setY(scale); + } + }; + cameraPane.widthProperty().addListener((obs, oldVal, newVal) -> updateScale.run()); + cameraPane.heightProperty().addListener((obs, oldVal, newVal) -> updateScale.run()); final CanvasManager canvasManager = new CanvasManager(cameraCanvasGroup, this, webcamName, shotEntries); final Optional cameraManagerOptional = camerasSupervisor.addCameraManager(cameraInterface, this, diff --git a/src/main/java/com/shootoff/gui/pane/Slide.java b/src/main/java/com/shootoff/gui/pane/Slide.java index ea8fc202..b23d33e3 100644 --- a/src/main/java/com/shootoff/gui/pane/Slide.java +++ b/src/main/java/com/shootoff/gui/pane/Slide.java @@ -53,8 +53,8 @@ public Slide(Pane parentControls, Pane parentBody) { this.parentBody = parentBody; final ImageView backImage = new ImageView( - new Image(Slide.class.getResourceAsStream("/images/back_button.png"), 60.0, 60.0, true, true)); - final Button backButton = addSlideControlButton("", (Event) -> hide()); + new Image(Slide.class.getResourceAsStream("/images/back_button.png"), 16.0, 16.0, true, true)); + final Button backButton = addSlideControlButton("Back", (Event) -> hide()); backButton.setGraphic(backImage); } diff --git a/src/main/java/com/shootoff/plugins/ProjectorTrainingExerciseBase.java b/src/main/java/com/shootoff/plugins/ProjectorTrainingExerciseBase.java index 9c1c4719..3b9f112b 100644 --- a/src/main/java/com/shootoff/plugins/ProjectorTrainingExerciseBase.java +++ b/src/main/java/com/shootoff/plugins/ProjectorTrainingExerciseBase.java @@ -383,4 +383,8 @@ public void destroy() { super.destroy(); } + + protected ProjectorArenaPane getArenaPane() { + return arenaPane; + } } diff --git a/src/main/java/com/shootoff/plugins/TrainingExerciseBase.java b/src/main/java/com/shootoff/plugins/TrainingExerciseBase.java index 028d5d16..f6bebce3 100644 --- a/src/main/java/com/shootoff/plugins/TrainingExerciseBase.java +++ b/src/main/java/com/shootoff/plugins/TrainingExerciseBase.java @@ -39,6 +39,7 @@ import javax.sound.sampled.SourceDataLine; import javax.sound.sampled.UnsupportedAudioFileException; +import javafx.scene.layout.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,10 +65,6 @@ import javafx.scene.control.TableColumn.CellDataFeatures; import javafx.scene.control.TableView; import javafx.scene.control.TextField; -import javafx.scene.layout.ColumnConstraints; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.Pane; -import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.util.Callback; @@ -169,10 +166,8 @@ public DelayPane(DelayedStartListener listener) { getColumnConstraints().add(new ColumnConstraints(100)); setVgap(5); - final Label instructionsLabel = new Label("Set interval within which a beep will sound\n" - + "to signal the start of a round.\nDefault: A round starts after a random wait\n" - + "between 4 and 8 seconds in length.\n"); - instructionsLabel.setPrefSize(300, 77); + final Label instructionsLabel = new Label("Set interval within which a beep will sound to signal the start of a round.\n"); + instructionsLabel.setPrefSize(Region.USE_COMPUTED_SIZE, Region.USE_COMPUTED_SIZE); this.add(instructionsLabel, 0, 0, 2, 3); addRow(3, new Label("Min (s)")); diff --git a/src/main/resources/com/shootoff/gui/Preferences.fxml b/src/main/resources/com/shootoff/gui/Preferences.fxml index 72c261f7..f14ffca8 100644 --- a/src/main/resources/com/shootoff/gui/Preferences.fxml +++ b/src/main/resources/com/shootoff/gui/Preferences.fxml @@ -48,31 +48,35 @@ - - - - - - + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 14b26449..218699ef 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -7,6 +7,7 @@ +