diff --git a/src/main/java/io/swtc/SwingIFrame.java b/src/main/java/io/swtc/SwingIFrame.java index 033ea8b..7f0de41 100644 --- a/src/main/java/io/swtc/SwingIFrame.java +++ b/src/main/java/io/swtc/SwingIFrame.java @@ -2,6 +2,8 @@ package io.swtc; import com.github.sarxos.webcam.Webcam; import io.swtc.proccessing.ui.IconSetter; +import io.swtc.proccessing.ui.desktop.DIM; +import io.swtc.proccessing.ui.desktop.evidence.EvidenceExportFrame; import io.swtc.proccessing.ui.iframe.*; import javax.swing.*; import java.awt.*; @@ -15,6 +17,7 @@ import java.util.Objects; public class SwingIFrame { private final JFrame mainFrame; private final DesktopPane desktopPane; + private final DIM desktopIconManager; private final Map cameraToEffects = new HashMap<>(); private boolean fullscreen = false; @@ -25,6 +28,7 @@ public class SwingIFrame { private final JPopupMenu popupMenu = new JPopupMenu(); + public SwingIFrame() { mainFrame = new JFrame("Viewer"); mainFrame.setSize(1280, 720); @@ -36,6 +40,10 @@ public class SwingIFrame { desktopPane.setBackground(defDesktopBg); mainFrame.add(desktopPane, BorderLayout.CENTER); + desktopIconManager = new DIM(desktopPane); + + setupDesktopExportFrame(); + setupFullscreenToggle(); setupBlackBg(); initPopupMenu(); @@ -43,6 +51,14 @@ public class SwingIFrame { desktopPane.addMouseListener(popupListener()); } + private void setupDesktopExportFrame() { + desktopIconManager.addIcon( + "Export Evidence", + IconSetter.getSaveIconAsImageIcon(), + EvidenceExportFrame::showExport + ); + } + public void addCameraInternalFrame(Webcam webcam) { CameraInternalFrame cameraFrame = new CameraInternalFrame(webcam, this::handleEffectsRequest); diff --git a/src/main/java/io/swtc/proccessing/CameraPanel.java b/src/main/java/io/swtc/proccessing/CameraPanel.java index bf24ebf..2259704 100644 --- a/src/main/java/io/swtc/proccessing/CameraPanel.java +++ b/src/main/java/io/swtc/proccessing/CameraPanel.java @@ -1,12 +1,19 @@ package io.swtc.proccessing; +import io.swtc.proccessing.ui.IconSetter; import io.swtc.proccessing.ui.ShowError; import io.swtc.recording.cv.AVRecorder; import javax.swing.*; import java.awt.*; import java.awt.event.*; +import java.awt.font.GlyphVector; import java.awt.image.BufferedImage; +import java.io.InputStream; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; @@ -17,6 +24,21 @@ public class CameraPanel extends JPanel { private volatile BufferedImage processedImage; private Function imageProcessor; + private Font overlayFont; + private final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + private final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); + + // Cached glyph vectors for performance + private GlyphVector dateGlyphs; + private GlyphVector timeGlyphs; + private String lastDate = ""; + private String lastTime = ""; + + // Pre-calculated positions to avoid repeated calculations + private static final int OVERLAY_X = 8; + private static final int OVERLAY_Y = 20; + private static final int TIME_Y_OFFSET = 19; + private volatile AVRecorder recorder; // this is now javacv, jcodec is stoopid private final AtomicBoolean repaintScheduled = new AtomicBoolean(false); @@ -35,12 +57,26 @@ public class CameraPanel extends JPanel { setBackground(Color.BLACK); setPreferredSize(new Dimension(640, 480)); + loadFont(); + graphicsConfig = GraphicsEnvironment.getLocalGraphicsEnvironment() .getDefaultScreenDevice().getDefaultConfiguration(); initInteractionListeners(); } + private void loadFont() { + try (InputStream is = getClass().getResourceAsStream("/font/OverlayFont.ttf")) { + if (is != null) { + overlayFont = Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(15f); + } else { + overlayFont = new Font(Font.MONOSPACED, Font.PLAIN, 15); + } + } catch (Exception e) { + overlayFont = new Font(Font.MONOSPACED, Font.PLAIN, 15); + } + } + private void initInteractionListeners() { MouseAdapter mouseHandler = new MouseAdapter() { @Override @@ -135,8 +171,40 @@ public class CameraPanel extends JPanel { } Graphics2D g2d = processedImage.createGraphics(); - g2d.drawImage(temp, 0, 0, null); - g2d.dispose(); + try { + g2d.drawImage(temp, 0, 0, null); // this is fucking expensive + drawTimeOverlay(g2d); + } finally { + g2d.dispose(); + } + } + + + private void drawTimeOverlay(Graphics2D g) { + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + g.setFont(overlayFont); + + LocalDateTime now = LocalDateTime.now(); + String dateStr = now.format(dateFormatter); + String timeStr = now.format(timeFormatter); + + if (!dateStr.equals(lastDate)) { + dateGlyphs = overlayFont.createGlyphVector(g.getFontRenderContext(), dateStr); + lastDate = dateStr; + } + + if (!timeStr.equals(lastTime)) { + timeGlyphs = overlayFont.createGlyphVector(g.getFontRenderContext(), timeStr); + lastTime = timeStr; + } + + g.setColor(Color.BLACK); + g.drawGlyphVector(dateGlyphs, OVERLAY_X + 2, OVERLAY_Y + 2); + g.drawGlyphVector(timeGlyphs, OVERLAY_X + 2, OVERLAY_Y + TIME_Y_OFFSET + 2); + + g.setColor(Color.WHITE); + g.drawGlyphVector(dateGlyphs, OVERLAY_X, OVERLAY_Y); + g.drawGlyphVector(timeGlyphs, OVERLAY_X, OVERLAY_Y + TIME_Y_OFFSET); } private void scheduleRepaint() { diff --git a/src/main/java/io/swtc/proccessing/ui/IconSetter.java b/src/main/java/io/swtc/proccessing/ui/IconSetter.java index afbc5ba..57f741b 100644 --- a/src/main/java/io/swtc/proccessing/ui/IconSetter.java +++ b/src/main/java/io/swtc/proccessing/ui/IconSetter.java @@ -2,6 +2,7 @@ package io.swtc.proccessing.ui; import javax.swing.*; import java.awt.*; +import java.io.InputStream; import java.net.URL; import java.util.Objects; @@ -37,4 +38,13 @@ public class IconSetter { } return ICON_ICON; } + + public static ImageIcon getSaveIconAsImageIcon() { + if (Objects.isNull(ICON_ICON)) { + URL url = IconSetter.class.getResource("/icons/save.png"); + if (Objects.isNull(url)) throw new RuntimeException("Icon not found: /icons/save.ico"); + ICON_ICON = new ImageIcon(url); + } + return ICON_ICON; + } } diff --git a/src/main/java/io/swtc/proccessing/ui/desktop/DIM.java b/src/main/java/io/swtc/proccessing/ui/desktop/DIM.java new file mode 100644 index 0000000..c7435d1 --- /dev/null +++ b/src/main/java/io/swtc/proccessing/ui/desktop/DIM.java @@ -0,0 +1,50 @@ +package io.swtc.proccessing.ui.desktop; + +import javax.swing.*; +import java.awt.*; + +/* DesktopIconManager */ +public class DIM { + + private final JDesktopPane desktop; + + private int cX; + private int cY; + + private static final int PAD = 6; + + public DIM(JDesktopPane desktop) { + this.desktop = desktop; + + Insets insets = desktop.getInsets(); + this.cX = insets.left + PAD; + this.cY = insets.top + PAD; + } + + public void addIcon(String label, Icon icon, Runnable runaction) { + DesktopIcon desktopIcon = new DesktopIcon(label, icon, runaction); + + Dimension pref = desktopIcon.getPreferredSize(); + int w = pref.width; + int h = pref.height; + + Insets insets = desktop.getInsets(); + int usableHeight = desktop.getHeight() - insets.top - insets.bottom; + + if (usableHeight <= 0) { + usableHeight = Integer.MAX_VALUE; + } + + if (cY + h > usableHeight) { + cY = insets.top + PAD; + cX += w + PAD; + } + + desktopIcon.setBounds(cX, cY, w, h); + + desktop.add(desktopIcon, JLayeredPane.DEFAULT_LAYER); + + cY += h + PAD; + desktop.repaint(); + } +} diff --git a/src/main/java/io/swtc/proccessing/ui/desktop/DesktopIcon.java b/src/main/java/io/swtc/proccessing/ui/desktop/DesktopIcon.java new file mode 100644 index 0000000..8ac62e8 --- /dev/null +++ b/src/main/java/io/swtc/proccessing/ui/desktop/DesktopIcon.java @@ -0,0 +1,107 @@ +package io.swtc.proccessing.ui.desktop; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + +public class DesktopIcon extends JPanel { + + private boolean hovered = false; + + private final JLabel iconLabel; + private final JLabel textLabel; + + public DesktopIcon(String label, Icon icon, Runnable action) { + + setLayout(new BorderLayout(4, 4)); + setOpaque(false); + + if (icon instanceof ImageIcon) { + Image img = ((ImageIcon) icon).getImage(); + Image scaled = img.getScaledInstance(64, 64, Image.SCALE_SMOOTH); + icon = new ImageIcon(scaled); + } + + iconLabel = new JLabel(icon, SwingConstants.CENTER); + textLabel = new ShadowLabel(label); + textLabel.setHorizontalAlignment(SwingConstants.CENTER); + + add(iconLabel, BorderLayout.CENTER); + add(textLabel, BorderLayout.SOUTH); + + addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) { + if (action != null) action.run(); + } + } + + @Override + public void mouseEntered(MouseEvent e) { + hovered = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) { + hovered = false; + repaint(); + } + }); + } + + @Override + public Dimension getPreferredSize() { + Dimension icon = iconLabel.getPreferredSize(); + Dimension text = textLabel.getPreferredSize(); + + int w = Math.max(icon.width, text.width) + 12; + int h = icon.height + text.height + 12; + + return new Dimension(w, h); + } + + @Override + protected void paintComponent(Graphics g) { + if (hovered) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON); + + boolean lightBg = isBackgroundLight(); + + Color fill = lightBg + ? new Color(0, 0, 0, 30) + : new Color(255, 255, 255, 40); + + Color border = lightBg + ? new Color(0, 0, 0, 80) + : new Color(255, 255, 255, 100); + + g2.setColor(fill); + g2.fillRoundRect(2, 2, getWidth() - 4, getHeight() - 4, 10, 10); + + g2.setColor(border); + g2.drawRoundRect(2, 2, getWidth() - 5, getHeight() - 5, 10, 10); + + g2.dispose(); + } + + super.paintComponent(g); + } + + private boolean isBackgroundLight() { + Container p = getParent(); + if (p == null) return true; + + Color bg = p.getBackground(); + int luminance = (int) ( + bg.getRed() * 0.299 + + bg.getGreen() * 0.587 + + bg.getBlue() * 0.114 + ); + return luminance > 180; + } +} diff --git a/src/main/java/io/swtc/proccessing/ui/desktop/ShadowLabel.java b/src/main/java/io/swtc/proccessing/ui/desktop/ShadowLabel.java new file mode 100644 index 0000000..6e5075f --- /dev/null +++ b/src/main/java/io/swtc/proccessing/ui/desktop/ShadowLabel.java @@ -0,0 +1,32 @@ +package io.swtc.proccessing.ui.desktop; + +import javax.swing.*; +import java.awt.*; + +public class ShadowLabel extends JLabel { + public ShadowLabel(String text) { + super(text, SwingConstants.CENTER); + setForeground(Color.WHITE); + setPreferredSize(new Dimension(15, 20)); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2d = (Graphics2D) g.create(); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + FontMetrics fm = g2d.getFontMetrics(); + String text = getText(); + + int x = (getWidth() - fm.stringWidth(text)) / 2; + int y = ((getHeight() - fm.getHeight()) / 2) + fm.getAscent(); + + g2d.setColor(new Color(0, 0, 0, 200)); + g2d.drawString(text, x + 1, y + 1); + + g2d.setColor(getForeground()); + g2d.drawString(text, x, y); + + g2d.dispose(); + } +} \ No newline at end of file diff --git a/src/main/java/io/swtc/proccessing/ui/desktop/evidence/EvidenceExportFrame.java b/src/main/java/io/swtc/proccessing/ui/desktop/evidence/EvidenceExportFrame.java new file mode 100644 index 0000000..ff1f891 --- /dev/null +++ b/src/main/java/io/swtc/proccessing/ui/desktop/evidence/EvidenceExportFrame.java @@ -0,0 +1,110 @@ +package io.swtc.proccessing.ui.desktop.evidence; + +import io.swtc.proccessing.ui.ShowError; +import io.swtc.recording.evidence.USBExportManager; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.nio.file.Path; +import java.io.File; + +public class EvidenceExportFrame extends JFrame { + + private final JProgressBar progressBar; + private final JLabel statusLabel; + private final JLabel detailLabel; + private final JButton actionBtn; + + private EvidenceExportFrame(Path sourceDir, Path usbTargetDir) { + setTitle("Export"); + setSize(400, 220); + setLocationRelativeTo(null); + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + + JPanel contentPane = new JPanel(); + contentPane.setLayout(new BoxLayout(contentPane, BoxLayout.Y_AXIS)); + contentPane.setBorder(new EmptyBorder(25, 25, 25, 25)); + + statusLabel = new JLabel("Starting export"); + statusLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + statusLabel.setFont(new Font(statusLabel.getFont().getName(), Font.BOLD, 14)); + detailLabel = new JLabel("Initializing"); + detailLabel.setAlignmentX(Component.LEFT_ALIGNMENT); + detailLabel.setForeground(Color.GRAY); + + progressBar = new JProgressBar(0, 100); + progressBar.setAlignmentX(Component.LEFT_ALIGNMENT); + progressBar.setMaximumSize(new Dimension(Integer.MAX_VALUE, 20)); + + actionBtn = new JButton("Cancel"); + actionBtn.setAlignmentX(Component.LEFT_ALIGNMENT); + contentPane.add(statusLabel); + contentPane.add(Box.createRigidArea(new Dimension(0, 10))); + contentPane.add(detailLabel); + contentPane.add(Box.createRigidArea(new Dimension(0, 20))); + contentPane.add(progressBar); + contentPane.add(Box.createVerticalGlue()); + contentPane.add(actionBtn); + + add(contentPane); + + actionBtn.addActionListener(e -> handleAction()); + + setVisible(true); + startExport(sourceDir, usbTargetDir); + } + + private void handleAction() { + if (actionBtn.getText().equals("Close")) { + dispose(); + return; + } + + int confirm = JOptionPane.showConfirmDialog(this, + "Stop export?", "Confirm", JOptionPane.YES_NO_OPTION); + if (confirm == JOptionPane.YES_OPTION) dispose(); + } + + private void startExport(Path sourceDir, Path usbTargetDir) { + USBExportManager.exportAsync( + sourceDir, + usbTargetDir, + stats -> SwingUtilities.invokeLater(() -> { + progressBar.setValue(stats.percent()); + statusLabel.setText("Exporting " + stats.percent() + "%"); + detailLabel.setText(String.format("%s | %s remaining", + stats.getSpeedMBps(), stats.timeLeft())); + }), + () -> SwingUtilities.invokeLater(() -> { + progressBar.setValue(100); + statusLabel.setText("Export Complete"); + detailLabel.setText("Files saved"); + actionBtn.setText("Close"); + setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + }), + ex -> SwingUtilities.invokeLater(() -> { + statusLabel.setText("Export Failed"); + detailLabel.setText(ex.getMessage()); + ShowError.error(this, ex.getMessage(), "Error"); + }) + ); + } + + public static void showExport() { + SwingUtilities.invokeLater(() -> { + File videoDir = new File(System.getProperty("user.home"), "Videos/swtcctv-rec"); + if (!videoDir.exists()) { + ShowError.warning(null, "No recordings found.", "Not Found"); + return; + } + + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + Path target = chooser.getSelectedFile().toPath().resolve("swtcctv-rec_" + System.currentTimeMillis() / 1000); + new EvidenceExportFrame(videoDir.toPath(), target); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/io/swtc/proccessing/ui/iframe/DesktopPane.java b/src/main/java/io/swtc/proccessing/ui/iframe/DesktopPane.java index 9fc933d..e2b5267 100644 --- a/src/main/java/io/swtc/proccessing/ui/iframe/DesktopPane.java +++ b/src/main/java/io/swtc/proccessing/ui/iframe/DesktopPane.java @@ -2,7 +2,6 @@ package io.swtc.proccessing.ui.iframe; import javax.swing.*; import java.awt.*; -import java.awt.geom.CubicCurve2D; import java.util.HashMap; import java.util.Map; import java.util.Random; @@ -38,29 +37,8 @@ public class DesktopPane extends JDesktopPane { if (camera.isVisible() && effects.isVisible() && !camera.isIcon() && !effects.isIcon()) { g2d.setColor(getConnectionColor(camera)); - drawBezierConnection(g2d, camera, effects); } } g2d.dispose(); } - - private void drawBezierConnection(Graphics2D g2d, JInternalFrame from, JInternalFrame to) { - Rectangle f = from.getBounds(); - Rectangle t = to.getBounds(); - - int x1 = f.x + f.width; - int y1 = f.y + (f.height / 2); - int x2 = t.x; - int y2 = t.y + (t.height / 2); - - int ctrlOffset = Math.min(Math.abs(x2 - x1) / 2, 150); - CubicCurve2D curve = new CubicCurve2D.Double(x1, y1, x1 + ctrlOffset, y1, x2 - ctrlOffset, y2, x2, y2); - - //g2d.setStroke(new BasicStroke(3f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); - //g2d.draw(curve); - - // Terminals - //g2d.fillOval(x1 - 5, y1 - 5, 10, 10); - //g2d.fillOval(x2 - 5, y2 - 5, 10, 10); - } } \ No newline at end of file diff --git a/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java b/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java index 1d6848d..234ebc6 100644 --- a/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java +++ b/src/main/java/io/swtc/proccessing/ui/iframe/RecordingFrame.java @@ -5,8 +5,8 @@ import io.swtc.proccessing.ui.IconSetter; import io.swtc.proccessing.ui.ShowError; import io.swtc.proccessing.ui.sections.recording.ExportSection; import io.swtc.recording.cv.AVRecorder; +import io.swtc.recording.cv.Quality; import io.swtc.recording.cv.RecorderConfig; -import io.swtc.recording.evidence.USBExportManager; import javax.swing.*; import java.awt.*; @@ -14,7 +14,6 @@ import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import javax.imageio.ImageIO; -import java.nio.file.Path; public class RecordingFrame extends JInternalFrame { private AVRecorder avRecorder; @@ -24,6 +23,7 @@ public class RecordingFrame extends JInternalFrame { private JButton recordBtn; private JLabel statusLabel; private JLabel statsLabel; + private JComboBox presetCombo; private File outputDirectory; private File currentFile; @@ -44,7 +44,6 @@ public class RecordingFrame extends JInternalFrame { initializeUI(); - // Timer for UI updates only (1 FPS is enough for the clock) this.statsTimer = new Timer(1000, e -> updateStats()); this.statsTimer.setCoalesce(true); @@ -56,10 +55,7 @@ public class RecordingFrame extends JInternalFrame { outputDirectory = new File(videoDir, "swtcctv-rec"); if (!outputDirectory.exists()) { - boolean created = outputDirectory.mkdirs(); - if (!created) { - System.err.println("Could not create recording directory: " + outputDirectory.getAbsolutePath()); - } + outputDirectory.mkdirs(); } } @@ -69,21 +65,38 @@ public class RecordingFrame extends JInternalFrame { mainContent.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); exportSection = new ExportSection(this, outputDirectory, statusLabel); - - JPanel actionPanel = createActionPanel(); - + JPanel settingsPanel = createSettingsPanel(); JPanel statsPanel = createStatsPanel(); + JPanel actionPanel = createActionPanel(); mainContent.add(exportSection); mainContent.add(Box.createVerticalStrut(10)); + + mainContent.add(settingsPanel); + mainContent.add(Box.createVerticalStrut(10)); + mainContent.add(statsPanel); mainContent.add(Box.createVerticalStrut(10)); + mainContent.add(actionPanel); - getContentPane().add(mainContent); } + private JPanel createSettingsPanel() { + JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + panel.setBorder(BorderFactory.createTitledBorder("Encoding Settings")); + + presetCombo = new JComboBox<>(Quality.values()); + presetCombo.setSelectedItem(Quality.SUPERFAST); // Default + + panel.add(new JLabel("CPU Preset:")); + panel.add(presetCombo); + + // Disable combo box during recording to prevent mid-stream config changes + return panel; + } + private JPanel createStatsPanel() { JPanel panel = new JPanel(new GridLayout(2, 1)); panel.setBorder(BorderFactory.createTitledBorder("Session Info")); @@ -91,20 +104,95 @@ public class RecordingFrame extends JInternalFrame { statusLabel = new JLabel("Status: Idle"); statsLabel = new JLabel("Length: 00:00 | Size: 0.00 MB"); statsLabel.setFont(new Font("Monospaced", Font.PLAIN, 12)); - statsLabel.setPreferredSize(new Dimension(240, 20)); panel.add(statusLabel); panel.add(statsLabel); return panel; } + private JPanel createActionPanel() { + JPanel panel = new JPanel(new GridLayout(1, 2, 5, 5)); + recordBtn = new JButton("Start Recording"); + recordBtn.addActionListener(e -> toggleRecording()); + + JButton snapBtn = new JButton("Snapshot"); + snapBtn.addActionListener(e -> takeSnapshot()); + + panel.add(recordBtn); + panel.add(snapBtn); + return panel; + } + + private void startRec() { + BufferedImage sample = cameraPanel.getCurrentProcessedImage(); + if (sample == null) { + ShowError.warning(this, "No camera feed detected.", "Warning"); + return; + } + + try { + currentFile = new File(outputDirectory, "vid_" + System.currentTimeMillis() + ".mp4"); + + Quality selected = (Quality) presetCombo.getSelectedItem(); + String preset = (selected != null) ? selected.getFFmpegValue() : "superfast"; + + RecorderConfig config = new RecorderConfig( + currentFile, + sample.getWidth(), + sample.getHeight(), + 20, + 18, + preset + ); + + avRecorder = new AVRecorder(config); + avRecorder.start(); + + cameraPanel.setExternalRecorder(avRecorder); + + startTime = System.currentTimeMillis(); + statsTimer.start(); + + // UI Feedback + presetCombo.setEnabled(false); // Lock settings during recording + recordBtn.setText("Stop Recording"); + recordBtn.setForeground(Color.RED); + statusLabel.setText("Recording..."); + } catch (Exception ex) { + ShowError.error(this, "Failed to start: " + ex.getMessage(), "Error"); + } + } + + private void stopRec() { + if (avRecorder != null) { + statusLabel.setText("Finalizing file..."); + avRecorder.stop(); + cameraPanel.setExternalRecorder(null); + } + + statsTimer.stop(); + presetCombo.setEnabled(true); // Unlock settings + recordBtn.setText("Start Recording"); + recordBtn.setForeground(null); + + String fileName = (currentFile != null) ? currentFile.getName() : "N/A"; + statusLabel.setText("File saved: " + fileName); + } + + private void toggleRecording() { + if (avRecorder == null || !avRecorder.isRecording()) { + startRec(); + } else { + stopRec(); + } + } + private void updateStats() { if (avRecorder == null || !avRecorder.isRecording() || currentFile == null) return; long elapsedSecs = (System.currentTimeMillis() - startTime) / 1000; long minutes = elapsedSecs / 60; long seconds = elapsedSecs % 60; - double sizeInMb = currentFile.length() / 1048576.0; sb.setLength(0); @@ -118,163 +206,10 @@ public class RecordingFrame extends JInternalFrame { statsLabel.setText(sb.toString()); } - private void toggleRecording() { - // Updated check for the new recorder state - if (avRecorder == null || !avRecorder.isRecording()) { - startRec(); - } else { - stopRec(); - } - } - - private void startRec() { - BufferedImage sample = cameraPanel.getCurrentProcessedImage(); - if (sample == null) { - ShowError.warning(this, "No camera feed detected. Start camera first.", "Warning"); - return; - } - - try { - if (!outputDirectory.exists() && !outputDirectory.mkdirs()) { - throw new IOException("Failed to create directory: " + outputDirectory); - } - - currentFile = new File(outputDirectory, "vid_" + System.currentTimeMillis() + ".mp4"); - - // 1. Define the production config - RecorderConfig config = new RecorderConfig( - currentFile, - sample.getWidth(), - sample.getHeight(), - 20, // Frame Rate - 18, // CRF (Quality) - "superfast" - ); - - // 2. Initialize the HA Recorder - avRecorder = new AVRecorder(config); - avRecorder.start(); - - // 3. Link to CameraPanel (Ensure CameraPanel calls .accept(img)) - cameraPanel.setExternalRecorder(avRecorder); - - startTime = System.currentTimeMillis(); - statsTimer.start(); - - recordBtn.setText("Stop Recording"); - recordBtn.setForeground(Color.RED); - statusLabel.setText("Recording..."); - } catch (Exception ex) { - ShowError.error(this, "Failed to start Recorder: " + ex.getMessage(), "Error"); - ex.printStackTrace(); - } - } - - private void stopRec() { - if (avRecorder != null) { - statusLabel.setText("Finalizing file..."); - avRecorder.stop(); - cameraPanel.setExternalRecorder(null); - } - - statsTimer.stop(); - recordBtn.setText("Start Recording"); - recordBtn.setForeground(null); - - String fileName = (currentFile != null) ? currentFile.getName() : "N/A"; - statusLabel.setText("File saved: " + fileName); - } - - private JPanel createStoragePanel() { - JPanel panel = new JPanel(new BorderLayout(5, 5)); - panel.setBorder(BorderFactory.createTitledBorder("Storage Folder")); - - JTextField pathField = new JTextField(outputDirectory.getAbsolutePath()); - pathField.setEditable(false); - - JButton browseBtn = new JButton("..."); - browseBtn.addActionListener(e -> { - JFileChooser chooser = new JFileChooser(outputDirectory); - chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); - if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { - outputDirectory = chooser.getSelectedFile(); - pathField.setText(outputDirectory.getAbsolutePath()); - } - }); - - panel.add(pathField, BorderLayout.CENTER); - panel.add(browseBtn, BorderLayout.EAST); - return panel; - } - - private void exportToUSB() { - JFileChooser chooser = new JFileChooser(); - chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); - chooser.setDialogTitle("Select USB destination"); - - if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return; - - Path usbRoot = chooser.getSelectedFile().toPath(); - Path exportTarget = usbRoot.resolve("CCTV_EXPORT"); - - try { - long size = USBExportManager.calculateDirectorySize(outputDirectory.toPath()); - - if (!USBExportManager.hasEnoughSpace(usbRoot, size)) { - ShowError.error(this, "Not enough space on USB device.", "Export failed"); - return; - } - - JProgressBar bar = new JProgressBar(0, 100); - JOptionPane pane = new JOptionPane(bar, - JOptionPane.INFORMATION_MESSAGE, - JOptionPane.DEFAULT_OPTION, - null, - new Object[]{}); - - JDialog dialog = pane.createDialog(this, "Exporting..."); - dialog.setModal(false); - dialog.setVisible(true); - - USBExportManager.exportAsync( - outputDirectory.toPath(), - exportTarget, - stats -> bar.setValue(stats.percent()), - () -> { - dialog.dispose(); - statusLabel.setText("Export completed"); - }, - ex -> { - dialog.dispose(); - ShowError.error(this, ex.getMessage(), "Export error"); - } - ); - - } catch (IOException ex) { - ShowError.error(this, ex.getMessage(), "Export error"); - } - } - - - private JPanel createActionPanel() { - JPanel panel = new JPanel(new GridLayout(1, 2, 5, 5)); - recordBtn = new JButton("Start Recording"); - recordBtn.addActionListener(e -> toggleRecording()); - - JButton snapBtn = new JButton("Snapshot"); - snapBtn.addActionListener(e -> takeSnapshot()); - - // Export is now handled by the ExportSection component - panel.add(recordBtn); - panel.add(snapBtn); - return panel; - } - private void takeSnapshot() { BufferedImage img = cameraPanel.getCurrentProcessedImage(); if (img != null) { try { - if (!outputDirectory.exists()) outputDirectory.mkdirs(); File file = new File(outputDirectory, "snap_" + System.currentTimeMillis() + ".png"); ImageIO.write(img, "PNG", file); statusLabel.setText("Snapshot: " + file.getName()); diff --git a/src/main/java/io/swtc/proccessing/ui/sections/recording/ExportSection.java b/src/main/java/io/swtc/proccessing/ui/sections/recording/ExportSection.java index fe149d1..2656fb7 100644 --- a/src/main/java/io/swtc/proccessing/ui/sections/recording/ExportSection.java +++ b/src/main/java/io/swtc/proccessing/ui/sections/recording/ExportSection.java @@ -27,16 +27,13 @@ public class ExportSection extends JPanel { this.pathField.setEditable(false); JTabbedPane tabbedPane = new JTabbedPane(); - tabbedPane.addTab("Evidence-Export", createTransferTab()); tabbedPane.addTab("Storage Settings", createSettingsTab()); + tabbedPane.addTab("Evidence-Export", createTransferTab()); + add(tabbedPane, BorderLayout.CENTER); } - private static JLabel getStatusLabel(JLabel statusLabel) { - return statusLabel; - } - private JPanel createTransferTab() { JPanel panel = new JPanel(new GridBagLayout()); panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); @@ -48,7 +45,7 @@ public class ExportSection extends JPanel { exportBtn.addActionListener(e -> startUsbExport()); JLabel infoLabel = new JLabel("" + - "Export Evidence to a USB Drive!" + + "Export Evidence (Can also be done via Desktop)" + ""); gbc.gridy = 0; diff --git a/src/main/java/io/swtc/recording/cv/Quality.java b/src/main/java/io/swtc/recording/cv/Quality.java new file mode 100644 index 0000000..b102727 --- /dev/null +++ b/src/main/java/io/swtc/recording/cv/Quality.java @@ -0,0 +1,25 @@ +package io.swtc.recording.cv; + +public enum Quality { + ULTRAFAST("Ultrafast (Lowest CPU)"), + SUPERFAST("Superfast"), + VERYFAST("Very Fast"), + FASTER("Faster"), + FAST("Fast"), + MEDIUM("Medium (Best Quality/Size)"); + + private final String label; + + Quality(String label) { + this.label = label; + } + + public String getFFmpegValue() { + return this.name().toLowerCase(); + } + + @Override + public String toString() { + return label; + } +} diff --git a/src/main/java/io/swtc/recording/evidence/ExportStats.java b/src/main/java/io/swtc/recording/evidence/ExportStats.java index cf5c321..9804f0d 100644 --- a/src/main/java/io/swtc/recording/evidence/ExportStats.java +++ b/src/main/java/io/swtc/recording/evidence/ExportStats.java @@ -5,38 +5,50 @@ import java.util.concurrent.TimeUnit; public record ExportStats( long totalBytes, long copiedBytes, - long startTimeNanos + long startTimeNanos, + String currentFileName ) { public int percent() { - return totalBytes == 0 ? 0 : - (int) ((copiedBytes * 100) / totalBytes); + if (totalBytes <= 0) return 0; + return (int) Math.min(100, (copiedBytes * 100) / totalBytes); } - /* timeLeft, ill implement this in the future (in the UI) i think - * this is the proper way to do this? idk - * */ public String timeLeft() { - if (copiedBytes == 0) return "0"; + if (copiedBytes <= 0) return "Calculating..."; long elapsedNanos = System.nanoTime() - startTimeNanos; + if (elapsedNanos <= 0) return "Calculating..."; - double bytesPerNano = (double) copiedBytes / elapsedNanos; + // Bytes per nanosecond + double bps = (double) copiedBytes / elapsedNanos; long remainingBytes = totalBytes - copiedBytes; - long remainingNanos = (long) (remainingBytes / bytesPerNano); + if (remainingBytes <= 0) return "Done"; + long remainingNanos = (long) (remainingBytes / bps); return formatDuration(remainingNanos); } + public String getSpeedMBps() { + long elapsedNanos = System.nanoTime() - startTimeNanos; + if (elapsedNanos <= 0 || copiedBytes <= 0) return "0 MB/s"; + + double seconds = elapsedNanos / 1_000_000_000.0; + double megabytes = copiedBytes / (1024.0 * 1024.0); + return String.format("%.1f MB/s", megabytes / seconds); + } + private static String formatDuration(long nanos) { - long seconds = TimeUnit.NANOSECONDS.toSeconds(nanos); - long mins = seconds / 60; - long secs = seconds % 60; + long totalSeconds = TimeUnit.NANOSECONDS.toSeconds(nanos); + if (totalSeconds < 1) return "less than a sec"; + + long mins = totalSeconds / 60; + long secs = totalSeconds % 60; if (mins > 0) { - return mins + "m " + secs + "s"; + return String.format("%dm %ds", mins, secs); } return secs + "s"; } -} +} \ No newline at end of file diff --git a/src/main/java/io/swtc/recording/evidence/USBExportManager.java b/src/main/java/io/swtc/recording/evidence/USBExportManager.java index a24d220..3c2c45d 100644 --- a/src/main/java/io/swtc/recording/evidence/USBExportManager.java +++ b/src/main/java/io/swtc/recording/evidence/USBExportManager.java @@ -1,114 +1,87 @@ package io.swtc.recording.evidence; -import javax.swing.*; import java.io.IOException; import java.nio.file.*; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.concurrent.atomic.AtomicLong; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; import java.util.function.Consumer; - public class USBExportManager { - public static long calculateDirectorySize(Path dir) throws IOException { - AtomicLong size = new AtomicLong(); - - Files.walkFileTree(dir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - size.addAndGet(attrs.size()); - return FileVisitResult.CONTINUE; + public static void exportAsync(Path source, Path target, Consumer progress, Runnable onDone, Consumer onError) { + CompletableFuture.runAsync(() -> { + try { + performExport(source, target, progress); + onDone.run(); + } catch (Exception e) { + onError.accept(e); } }); + } - return size.get(); + private static void performExport(Path source, Path target, Consumer progress) throws IOException { + List allFiles; + try (Stream stream = Files.walk(source)) { + allFiles = stream.filter(Files::isRegularFile).toList(); + } + + long totalBytes = allFiles.stream().mapToLong(p -> p.toFile().length()).sum(); + + if (!hasEnoughSpace(target, totalBytes)) { + throw new IOException("Not enough space on target drive. Required: " + (totalBytes / 1024 / 1024) + " MB"); + } + + long totalCopied = 0; + long startTimeNanos = System.nanoTime(); + + for (Path file : allFiles) { + String fileName = file.getFileName().toString(); + + if (progress != null) { + progress.accept(new ExportStats(totalBytes, totalCopied, startTimeNanos, fileName)); + } + + Path relativePath = source.relativize(file); + Path destination = target.resolve(relativePath); + + Files.createDirectories(destination.getParent()); + + Files.copy(file, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + + totalCopied += Files.size(file); + + if (progress != null) { + progress.accept(new ExportStats(totalBytes, totalCopied, startTimeNanos, fileName)); + } + } } public static boolean hasEnoughSpace(Path target, long requiredBytes) throws IOException { - FileStore store = Files.getFileStore(target); + Path root = target; + while (root != null && !Files.exists(root)) { + root = root.getParent(); + } + if (root == null) return true; + + FileStore store = Files.getFileStore(root); return store.getUsableSpace() >= requiredBytes; } - public static void exportDirectory( - Path sourceDir, - Path usbTargetDir, - Consumer progressCallback - ) throws IOException { - if (!Files.isDirectory(sourceDir)) { - throw new IOException("Source is not a directory: " + sourceDir); + public static long calculateDirectorySize(Path dir) throws IOException { + if (dir == null || !Files.exists(dir)) return 0L; + + try (Stream walk = Files.walk(dir)) { + return walk.filter(Files::isRegularFile) + .mapToLong(p -> { + try { + return Files.size(p); + } catch (IOException e) { + return 0L; // Skip files that can't be read + } + }) + .sum(); } - - Files.createDirectories(usbTargetDir); - - long totalBytes = calculateDirectorySize(sourceDir); - AtomicLong copiedBytes = new AtomicLong(); - - Files.walkFileTree(sourceDir, new SimpleFileVisitor<>() { - - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) - throws IOException { - - Path targetDir = usbTargetDir.resolve(sourceDir.relativize(dir)); - Files.createDirectories(targetDir); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) - throws IOException { - - Path targetFile = usbTargetDir.resolve(sourceDir.relativize(file)); - - Files.copy( - file, - targetFile, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.COPY_ATTRIBUTES - ); - - copiedBytes.addAndGet(attrs.size()); - - long startTime = System.nanoTime(); - - if (progressCallback != null) { - progressCallback.accept( - new ExportStats(totalBytes, copiedBytes.get(), startTime) - ); - } - - return FileVisitResult.CONTINUE; - } - }); } - - - - public static void exportAsync( - Path sourceDir, - Path usbTargetDir, - Consumer progressCallback, - Runnable onDone, - Consumer onError - ) { - - new Thread(() -> { - try { - exportDirectory(sourceDir, usbTargetDir, stats -> - SwingUtilities.invokeLater(() -> - progressCallback.accept(stats)) - ); - - if (onDone != null) { - SwingUtilities.invokeLater(onDone); - } - - } catch (Exception ex) { - if (onError != null) { - SwingUtilities.invokeLater(() -> onError.accept(ex)); - } - } - }, "USB-Export-Thread").start(); - } -} +} \ No newline at end of file diff --git a/src/main/resources/font/OverlayFont.ttf b/src/main/resources/font/OverlayFont.ttf new file mode 100644 index 0000000..2442aff Binary files /dev/null and b/src/main/resources/font/OverlayFont.ttf differ diff --git a/src/main/resources/icons/save.png b/src/main/resources/icons/save.png new file mode 100644 index 0000000..61cf68e Binary files /dev/null and b/src/main/resources/icons/save.png differ