diff --git a/pom.xml b/pom.xml index 861494d..46ab53d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ nl.andrewlalis BlockBookBinder - 0.0.2 + 1.0.0 12 diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/control/BookExportActionListener.java b/src/main/java/nl/andrewlalis/blockbookbinder/control/BookExportActionListener.java index 88fde8a..14315f8 100644 --- a/src/main/java/nl/andrewlalis/blockbookbinder/control/BookExportActionListener.java +++ b/src/main/java/nl/andrewlalis/blockbookbinder/control/BookExportActionListener.java @@ -2,6 +2,7 @@ package nl.andrewlalis.blockbookbinder.control; import nl.andrewlalis.blockbookbinder.model.Book; import nl.andrewlalis.blockbookbinder.view.BookPreviewPanel; +import nl.andrewlalis.blockbookbinder.view.export.ExportToBookDialog; import org.jnativehook.GlobalScreen; import org.jnativehook.NativeHookException; @@ -34,7 +35,7 @@ public class BookExportActionListener implements ActionListener { final Book book = this.bookPreviewPanel.getBook(); int choice = JOptionPane.showConfirmDialog( this.bookPreviewPanel.getRootPane(), - "Press CTRL+V to initialize export.", + "Press OK to initialize export.", "Confirm Export", JOptionPane.OK_CANCEL_OPTION ); diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/control/BookPagePasteListener.java b/src/main/java/nl/andrewlalis/blockbookbinder/control/BookPagePasteListener.java index 3f4c3a3..78b845f 100644 --- a/src/main/java/nl/andrewlalis/blockbookbinder/control/BookPagePasteListener.java +++ b/src/main/java/nl/andrewlalis/blockbookbinder/control/BookPagePasteListener.java @@ -8,9 +8,12 @@ import org.jnativehook.keyboard.NativeKeyEvent; import org.jnativehook.keyboard.NativeKeyListener; import javax.swing.*; +import java.awt.*; import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.StringSelection; import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; /** * Listener that listens for native key-presses that indicate the user has @@ -22,6 +25,7 @@ public class BookPagePasteListener implements NativeKeyListener { private final BookPreviewPanel bookPreviewPanel; private final JButton cancelExportButton; private final ActionListener cancelExportActionListener; + private Robot robot; private int nextPage; public BookPagePasteListener(Book book, Clipboard clipboard, BookPreviewPanel bookPreviewPanel, JButton cancelExportButton) { @@ -32,6 +36,11 @@ public class BookPagePasteListener implements NativeKeyListener { this.nextPage = 0; this.cancelExportActionListener = (e) -> this.cancelExport(); this.cancelExportButton.addActionListener(this.cancelExportActionListener); + try { + this.robot = new Robot(); + } catch (AWTException e) { + e.printStackTrace(); + } } public void exportNextPage() { @@ -68,7 +77,22 @@ public class BookPagePasteListener implements NativeKeyListener { // If we've reached the end of the book, unregister this listener and remove native hooks. if (this.nextPage >= this.book.getPageCount()) { this.cancelExport(); + return; } + + // Automatically do a CTRL+V and click the mouse to go to the next page. + this.robot.keyPress(KeyEvent.VK_CONTROL); + this.robot.keyPress(KeyEvent.VK_V); + try { + Thread.sleep(50); + } catch (InterruptedException e) { + e.printStackTrace(); + } + this.robot.keyRelease(KeyEvent.VK_V); + this.robot.keyRelease(KeyEvent.VK_CONTROL); + + this.robot.mousePress(MouseEvent.BUTTON1_DOWN_MASK); + this.robot.mouseRelease(MouseEvent.BUTTON1_DOWN_MASK); } public void cancelExport() { diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/control/export/BookExporter.java b/src/main/java/nl/andrewlalis/blockbookbinder/control/export/BookExporter.java new file mode 100644 index 0000000..432cd03 --- /dev/null +++ b/src/main/java/nl/andrewlalis/blockbookbinder/control/export/BookExporter.java @@ -0,0 +1,214 @@ +package nl.andrewlalis.blockbookbinder.control.export; + +import lombok.Setter; +import nl.andrewlalis.blockbookbinder.model.Book; +import nl.andrewlalis.blockbookbinder.util.ApplicationProperties; +import org.jnativehook.GlobalScreen; +import org.jnativehook.NativeHookException; + +import javax.sound.sampled.*; +import java.awt.*; +import java.awt.datatransfer.Clipboard; +import java.awt.datatransfer.StringSelection; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.io.IOException; +import java.io.InputStream; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A separate runnable process which handles exporting a book, page by page, + * into one's clipboard and pasting the pages. + */ +public class BookExporter implements Runnable { + private final static int START_DELAY = 10; + private final static int CLIPBOARD_RETRY_DELAY_MS = 100; + private final static int ROBOT_ACTION_DELAY_MS = 100; + + private final Book book; + private final boolean autoPaste; + + @Setter + private volatile boolean running; + + @Setter + private volatile boolean nextPageRequested; + + private final PagePasteListener pagePasteListener; + private final Clipboard clipboard; + private Robot robot; + + // Some sound clips to play as user feedback. + private final Clip beepClip; + private final Clip beginningExportClip; + + public BookExporter(Book book, boolean autoPaste) { + this.book = book; + this.autoPaste = autoPaste; + this.beepClip = this.loadAudioClip(ApplicationProperties.getProp("export_dialog.beep_sound")); + this.beginningExportClip = this.loadAudioClip(ApplicationProperties.getProp("export_dialog.beginning_export")); + this.clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); + this.pagePasteListener = new PagePasteListener(this); + if (this.autoPaste) { // Only initialize the robot if we'll need it. + try { + this.robot = new Robot(); + } catch (AWTException e) { + e.printStackTrace(); + } + } + } + + @Override + public void run() { + this.running = true; + this.nextPageRequested = true; + long startTime = System.currentTimeMillis(); + long lastAudioPlayedAt = 0; + long lastPageExportedAt = 0; + int nextPageToExport = 0; + while (this.running) { + long currentTime = System.currentTimeMillis(); + // Check if we're still in the first few seconds of runtime. + boolean inStartPhase = (currentTime - startTime < (START_DELAY * 1000)); + if (inStartPhase && currentTime - lastAudioPlayedAt > 1000) { + this.playAudioClip(this.beepClip); + lastAudioPlayedAt = currentTime; + } + // Otherwise, export one page. + if (!inStartPhase && this.nextPageRequested) { + System.out.println("Page requested: " + nextPageToExport); + this.nextPageRequested = false; // Reset the flag so that some other process has to set it before the next page is exported. + // If this is the first time we're exporting, play a sound. + if (lastPageExportedAt == 0) { + this.initNativeListener(); + this.playAudioClip(this.beginningExportClip); + } + this.exportPageToClipboard(nextPageToExport); + if (this.autoPaste) { + this.pasteAndTurnPage(); + } + this.playAudioClip(this.beepClip); + nextPageToExport++; + // If we've reached the end of the book, stop the exporter. + if (nextPageToExport >= this.book.getPageCount()) { + System.out.println("Export finished: " + this.book.getPageCount() + " pages exported."); + if (!this.autoPaste) { + this.stopNativeListener(); + } + this.running = false; + break; + } + // Since there may be significant delay, get a fresh timestamp. + lastPageExportedAt = System.currentTimeMillis(); + } + } + } + + /** + * Loads the given page onto the system clipboard so either a user or this + * program can paste it into a minecraft book. + * @param page The index of the page to export. + */ + private void exportPageToClipboard(int page) { + boolean clipboardSuccess = false; + int attempts = 0; + while (!clipboardSuccess) { + try { + attempts++; + clipboard.setContents(new StringSelection(book.getPages().get(page).toString()), null); + clipboardSuccess = true; + } catch (IllegalStateException e) { + System.err.println("Could not open and set contents of system clipboard."); + } + if (!clipboardSuccess) { + try { + Thread.sleep(CLIPBOARD_RETRY_DELAY_MS); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + if (attempts > 10) { + throw new RuntimeException("Could not insert page into clipboard after " + attempts + " attempts."); + } + } + System.out.println("Exported page " + page + " to clipboard."); + } + + /** + * Automatically pastes and turns the page of the minecraft book, so that + * the next page can be pasted in. + */ + private void pasteAndTurnPage() { + this.robot.keyPress(KeyEvent.VK_CONTROL); + this.robot.keyPress(KeyEvent.VK_V); + try { + Thread.sleep(ROBOT_ACTION_DELAY_MS); + } catch (InterruptedException e) { + e.printStackTrace(); + } + this.robot.keyRelease(KeyEvent.VK_V); + this.robot.keyRelease(KeyEvent.VK_CONTROL); + System.out.println("Pasted."); + this.robot.mousePress(MouseEvent.BUTTON1_DOWN_MASK); + try { + Thread.sleep(ROBOT_ACTION_DELAY_MS); + } catch (InterruptedException e) { + e.printStackTrace(); + } + this.robot.mouseRelease(MouseEvent.BUTTON1_DOWN_MASK); + try { // Wait for minecraft to turn the page. + Thread.sleep(ROBOT_ACTION_DELAY_MS); + } catch (InterruptedException e) { + e.printStackTrace(); + } + System.out.println("Clicked mouse."); + this.nextPageRequested = true; + } + + private void initNativeListener() { + try { + // For catching native events, set logging here. + Logger logger = Logger.getLogger(GlobalScreen.class.getPackage().getName()); + logger.setLevel(Level.WARNING); + logger.setUseParentHandlers(false); + GlobalScreen.registerNativeHook(); + GlobalScreen.addNativeKeyListener(this.pagePasteListener); + } catch (NativeHookException nativeHookException) { + System.err.println("Could not register native hook."); + nativeHookException.printStackTrace(); + } + } + + private void stopNativeListener() { + try { + GlobalScreen.removeNativeKeyListener(this.pagePasteListener); + GlobalScreen.unregisterNativeHook(); + } catch (NativeHookException nativeHookException) { + System.err.println("Could not unregister a native hook."); + nativeHookException.printStackTrace(); + } + } + + private void playAudioClip(Clip clip) { + clip.setFramePosition(0); + clip.start(); + } + + private Clip loadAudioClip(String path) { + try { + Clip clip = AudioSystem.getClip(); + InputStream fileInputStream = this.getClass().getClassLoader().getResourceAsStream(path); + if (fileInputStream == null) { + return null; + } + AudioInputStream ais = AudioSystem.getAudioInputStream(fileInputStream); + clip.open(ais); + return clip; + } catch (LineUnavailableException | IOException | UnsupportedAudioFileException e) { + System.err.println("Could not load audio clip."); + e.printStackTrace(); + return null; + } + } +} diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/control/export/PagePasteListener.java b/src/main/java/nl/andrewlalis/blockbookbinder/control/export/PagePasteListener.java new file mode 100644 index 0000000..a5b0899 --- /dev/null +++ b/src/main/java/nl/andrewlalis/blockbookbinder/control/export/PagePasteListener.java @@ -0,0 +1,31 @@ +package nl.andrewlalis.blockbookbinder.control.export; + +import org.jnativehook.keyboard.NativeKeyEvent; +import org.jnativehook.keyboard.NativeKeyListener; + +public class PagePasteListener implements NativeKeyListener { + private BookExporter exporterRunnable; + + public PagePasteListener(BookExporter exporterRunnable) { + this.exporterRunnable = exporterRunnable; + } + + @Override + public void nativeKeyTyped(NativeKeyEvent nativeKeyEvent) {} + + @Override + public void nativeKeyPressed(NativeKeyEvent nativeKeyEvent) { + if (nativeKeyEvent.getKeyCode() == NativeKeyEvent.VC_V && (nativeKeyEvent.getModifiers() & NativeKeyEvent.CTRL_MASK) > 0) { + // Wait a little bit so that we can let the system do whatever it was planning to do with the original paste action. + try { + Thread.sleep(100); + } catch (InterruptedException e) { + e.printStackTrace(); + } + this.exporterRunnable.setNextPageRequested(true); + } + } + + @Override + public void nativeKeyReleased(NativeKeyEvent nativeKeyEvent) {} +} diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/model/Book.java b/src/main/java/nl/andrewlalis/blockbookbinder/model/Book.java index d51a0d9..9277706 100644 --- a/src/main/java/nl/andrewlalis/blockbookbinder/model/Book.java +++ b/src/main/java/nl/andrewlalis/blockbookbinder/model/Book.java @@ -21,6 +21,20 @@ public class Book { this.pages.add(page); } + /** + * Gets a book containing the pages specified by the range. + * @param firstIndex The index of the first page to include. + * @param count The number of pages to include. + * @return The book containing the range of pages. + */ + public Book getPageRange(int firstIndex, int count) { + Book book = new Book(); + for (int i = 0; i < count; i++) { + book.addPage(this.pages.get(firstIndex + i)); + } + return book; + } + @Override public String toString() { StringBuilder sb = new StringBuilder("Book of " + this.getPageCount() + " pages:\n"); diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/view/MainFrame.java b/src/main/java/nl/andrewlalis/blockbookbinder/view/MainFrame.java index 4f83b40..b749d26 100644 --- a/src/main/java/nl/andrewlalis/blockbookbinder/view/MainFrame.java +++ b/src/main/java/nl/andrewlalis/blockbookbinder/view/MainFrame.java @@ -2,7 +2,9 @@ package nl.andrewlalis.blockbookbinder.view; import nl.andrewlalis.blockbookbinder.control.BookExportActionListener; import nl.andrewlalis.blockbookbinder.control.ImportAction; +import nl.andrewlalis.blockbookbinder.model.Book; import nl.andrewlalis.blockbookbinder.util.ApplicationProperties; +import nl.andrewlalis.blockbookbinder.view.export.ExportToBookDialog; import javax.swing.*; import java.awt.*; @@ -67,7 +69,15 @@ public class MainFrame extends JFrame { JButton exportButton = new JButton("Export to Book"); JButton cancelExportButton = new JButton("Cancel Export"); cancelExportButton.setEnabled(false); - exportButton.addActionListener(new BookExportActionListener(bookPreviewPanel, cancelExportButton)); + exportButton.addActionListener(e -> { + final Book book = bookPreviewPanel.getBook(); + if (book == null || book.getPageCount() == 0) { + JOptionPane.showMessageDialog(this, "Cannot export an empty book.", "Empty Book", JOptionPane.WARNING_MESSAGE); + return; + } + ExportToBookDialog dialog = new ExportToBookDialog(this, bookPreviewPanel.getBook()); + dialog.setupAndShow(); + }); bottomPanel.add(exportButton); bottomPanel.add(cancelExportButton); mainPanel.add(bottomPanel, BorderLayout.SOUTH); diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/view/export/ExportStatusPanel.java b/src/main/java/nl/andrewlalis/blockbookbinder/view/export/ExportStatusPanel.java new file mode 100644 index 0000000..c58202e --- /dev/null +++ b/src/main/java/nl/andrewlalis/blockbookbinder/view/export/ExportStatusPanel.java @@ -0,0 +1,18 @@ +package nl.andrewlalis.blockbookbinder.view.export; + +import javax.swing.*; +import java.awt.*; + +/** + * A panel with some components for displaying the current status of an export + * job. + */ +public class ExportStatusPanel extends JPanel { + private JProgressBar exportProgressBar; + + public ExportStatusPanel() { + this.setLayout(new BorderLayout()); + + this.exportProgressBar = new JProgressBar(); + } +} diff --git a/src/main/java/nl/andrewlalis/blockbookbinder/view/export/ExportToBookDialog.java b/src/main/java/nl/andrewlalis/blockbookbinder/view/export/ExportToBookDialog.java new file mode 100644 index 0000000..325ea1d --- /dev/null +++ b/src/main/java/nl/andrewlalis/blockbookbinder/view/export/ExportToBookDialog.java @@ -0,0 +1,133 @@ +package nl.andrewlalis.blockbookbinder.view.export; + +import nl.andrewlalis.blockbookbinder.control.export.BookExporter; +import nl.andrewlalis.blockbookbinder.model.Book; +import nl.andrewlalis.blockbookbinder.util.ApplicationProperties; + +import javax.swing.*; +import java.awt.*; + +/** + * A special dialog box that's shown during the process of exporting a book into + * minecraft. + */ +public class ExportToBookDialog extends JDialog { + private final static String SETUP_CARD = "SETUP"; + private final static String STATUS_CARD = "STATUS"; + + private final Book book; + + // Setup input fields. + private JCheckBox autoCheckbox; + private JSpinner firstPageSpinner; + private JSpinner lastPageSpinner; + + private JButton startButton; + private JButton stopButton; + private JPanel centerCardPanel; + private ExportStatusPanel exportStatusPanel; + + private Thread exporterThread; + private BookExporter exporterRunnable; + + public ExportToBookDialog(Frame owner, Book book) { + super(owner, "Export to Book", true); + this.book = book; + } + + public void setupAndShow() { + this.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + this.setContentPane(this.buildContentPane()); + this.setMinimumSize(new Dimension( + ApplicationProperties.getIntProp("export_dialog.min_width"), + ApplicationProperties.getIntProp("export_dialog.min_height") + )); + this.pack(); + this.setLocationRelativeTo(null); + this.setVisible(true); + } + + private Container buildContentPane() { + JPanel mainPanel = new JPanel(new BorderLayout()); + + JPanel setupPanel = new JPanel(); + setupPanel.setLayout(new BoxLayout(setupPanel, BoxLayout.PAGE_AXIS)); + this.autoCheckbox = new JCheckBox("Auto-paste", true); + this.firstPageSpinner = new JSpinner(new SpinnerNumberModel(1, 1, this.book.getPageCount(), 1)); + this.lastPageSpinner = new JSpinner(new SpinnerNumberModel(this.book.getPageCount(), 1, this.book.getPageCount(), 1)); + setupPanel.add(this.autoCheckbox); + setupPanel.add(this.firstPageSpinner); + setupPanel.add(this.lastPageSpinner); + + this.exportStatusPanel = new ExportStatusPanel(); + + this.centerCardPanel = new JPanel(new CardLayout()); + centerCardPanel.add(setupPanel, SETUP_CARD); + centerCardPanel.add(this.exportStatusPanel, STATUS_CARD); + this.showCardByName(SETUP_CARD); + + mainPanel.add(centerCardPanel, BorderLayout.CENTER); + + JPanel controlPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); + this.startButton = new JButton("Start"); + this.startButton.addActionListener(e -> { + int choice = JOptionPane.showConfirmDialog( + this.rootPane, + "Exporting will begin after roughly 10 seconds.\n" + + "If you have selected \"Auto-paste\", then place\n" + + "your mouse cursor over the right arrow of the book\n" + + "so that BlockBookBinder can automatically click it.\n\n" + + "You have chosen to export pages " + this.firstPageSpinner.getValue() + " to " + this.lastPageSpinner.getValue() + ".", + "Export Start Confirmation", + JOptionPane.OK_CANCEL_OPTION, + JOptionPane.INFORMATION_MESSAGE + ); + if (choice == JOptionPane.OK_OPTION) { + this.startExporter(); + } + }); + controlPanel.add(this.startButton); + this.stopButton = new JButton("Stop"); + this.stopButton.setVisible(false); + this.stopButton.setEnabled(false); + this.stopButton.addActionListener(e -> this.stopExporter()); + controlPanel.add(this.stopButton); + mainPanel.add(controlPanel, BorderLayout.SOUTH); + + return mainPanel; + } + + private void startExporter() { + final int firstPage = (int) this.firstPageSpinner.getValue(); + final int lastPage = (int) this.lastPageSpinner.getValue(); + final Book pagesRange = this.book.getPageRange(firstPage - 1, lastPage - firstPage + 1); + + this.startButton.setEnabled(false); + this.startButton.setVisible(false); + this.stopButton.setEnabled(true); + this.stopButton.setVisible(true); + this.showCardByName(STATUS_CARD); + this.exporterRunnable = new BookExporter(pagesRange, this.autoCheckbox.isSelected()); + this.exporterThread = new Thread(this.exporterRunnable); + this.exporterThread.start(); + } + + private void stopExporter() { + this.exporterRunnable.setRunning(false); + try { + this.exporterThread.join(); + } catch (InterruptedException interruptedException) { + interruptedException.printStackTrace(); + } + this.showCardByName(SETUP_CARD); + this.stopButton.setEnabled(false); + this.stopButton.setVisible(false); + this.startButton.setEnabled(true); + this.startButton.setVisible(true); + } + + private void showCardByName(String name) { + CardLayout cl = (CardLayout) this.centerCardPanel.getLayout(); + cl.show(this.centerCardPanel, name); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index fd49145..f68ff1b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,6 +3,11 @@ frame.title=Block Book Binder frame.default_width=800 frame.default_height=600 +export_dialog.min_width=400 +export_dialog.min_height=300 +export_dialog.beep_sound=sound/andrew_beep.wav +export_dialog.beginning_export=sound/beginning_export.wav + # Settings for Minecraft book interaction. book.max_pages=100 book.page_max_lines=14 diff --git a/src/main/resources/sound/andrew_beep.wav b/src/main/resources/sound/andrew_beep.wav new file mode 100644 index 0000000..d425af5 Binary files /dev/null and b/src/main/resources/sound/andrew_beep.wav differ diff --git a/src/main/resources/sound/beginning_export.wav b/src/main/resources/sound/beginning_export.wav new file mode 100644 index 0000000..e44e7cd Binary files /dev/null and b/src/main/resources/sound/beginning_export.wav differ