From 4745cc1cc98955e7bc0af2139caa7b76c6eba907 Mon Sep 17 00:00:00 2001 From: Argent77 <4519923+Argent77@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:49:50 +0100 Subject: [PATCH] New audio player and sound panel classes for improved sound playback - SingleAudioPlayer class provides more responsive audio playback. - SoundPanel class provides a customizable UI control panel for audio playback. - Updated SoundResource class and ResourceRef datatype to utilize new classes. --- src/org/infinity/datatype/ResourceRef.java | 95 +- .../exceptions/ResourceException.java | 43 + .../exceptions/ResourceNotFoundException.java | 43 + src/org/infinity/gui/ChildFrame.java | 52 +- src/org/infinity/gui/SoundPanel.java | 1190 +++++++++++++++++ src/org/infinity/gui/StructViewer.java | 14 + .../infinity/resource/sound/AudioPlayer.java | 4 + .../resource/sound/AudioStateEvent.java | 65 + .../resource/sound/AudioStateListener.java | 20 + .../resource/sound/SingleAudioPlayer.java | 404 ++++++ .../resource/sound/SoundResource.java | 362 +---- 11 files changed, 1888 insertions(+), 404 deletions(-) create mode 100644 src/org/infinity/exceptions/ResourceException.java create mode 100644 src/org/infinity/exceptions/ResourceNotFoundException.java create mode 100644 src/org/infinity/gui/SoundPanel.java create mode 100644 src/org/infinity/resource/sound/AudioStateEvent.java create mode 100644 src/org/infinity/resource/sound/AudioStateListener.java create mode 100644 src/org/infinity/resource/sound/SingleAudioPlayer.java diff --git a/src/org/infinity/datatype/ResourceRef.java b/src/org/infinity/datatype/ResourceRef.java index 78ca4eaae..e5df9c8ff 100644 --- a/src/org/infinity/datatype/ResourceRef.java +++ b/src/org/infinity/datatype/ResourceRef.java @@ -13,7 +13,6 @@ import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.beans.PropertyChangeEvent; -import java.beans.PropertyChangeListener; import java.io.IOException; import java.io.OutputStream; import java.nio.ByteBuffer; @@ -33,6 +32,7 @@ import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; +import org.infinity.gui.SoundPanel; import org.infinity.gui.StructViewer; import org.infinity.gui.TextListPanel; import org.infinity.gui.ViewFrame; @@ -44,7 +44,6 @@ import org.infinity.resource.Resource; import org.infinity.resource.ResourceFactory; import org.infinity.resource.key.ResourceEntry; -import org.infinity.resource.sound.SoundResource; import org.infinity.util.Logger; import org.infinity.util.Misc; import org.infinity.util.io.StreamUtils; @@ -62,7 +61,7 @@ * */ public class ResourceRef extends Datatype - implements Editable, IsTextual, IsReference, ActionListener, ListSelectionListener, PropertyChangeListener { + implements Editable, IsTextual, IsReference, Closeable, ActionListener, ListSelectionListener { private static final Comparator IGNORE_CASE_EXT_COMPARATOR = new IgnoreCaseExtComparator(); /** List of resource types that are can be used to display associated icons. */ @@ -86,11 +85,8 @@ public class ResourceRef extends Datatype /** Button that used to open editor of current selected element in the list. */ private JButton bView; - /** Button that used to play sound of current selected element in the list. */ - private JButton bPlay; - - /** Button that used to stop sound playback of current selected element in the list. */ - private JButton bStop; + /** Handles playback of sound resources. */ + private SoundPanel soundPanel; /** * GUI component that lists all available resources that can be set to this resource reference and have edit field for @@ -132,30 +128,6 @@ public void actionPerformed(ActionEvent event) { if (isEditable(selected)) { new ViewFrame(list.getTopLevelAncestor(), ResourceFactory.getResource(selected.entry)); } - } else if (event.getSource() == bPlay) { - final ResourceRefEntry selected = list.getSelectedValue(); - if (isSound(selected)) { - // prevent overlapping sound playback - closeResource(currentResource); - SoundResource res = (SoundResource) ResourceFactory.getResource(selected.entry); - res.playSound(this); - currentResource = res; - } - } else if (event.getSource() == bStop) { - if (currentResource instanceof SoundResource) { - ((SoundResource) currentResource).stopSound(this); - } - } - } - - @Override - public void propertyChange(PropertyChangeEvent evt) { - if (SoundResource.PROPERTY_NAME_PLAYBACK.equals(evt.getPropertyName())) { - final boolean value = (Boolean)evt.getNewValue(); - updatePlayback(value); - if (!value) { - ((SoundResource) evt.getSource()).removePropertyChangeListener(this); - } } } @@ -223,13 +195,9 @@ public void mouseClicked(MouseEvent event) { bView = new JButton("View/Edit", Icons.ICON_ZOOM_16.getIcon()); bView.addActionListener(this); - bPlay = new JButton(Icons.ICON_PLAY_16.getIcon()); - bPlay.setToolTipText("Play sound"); - bPlay.addActionListener(this); - bStop = new JButton(Icons.ICON_STOP_16.getIcon()); - bStop.setToolTipText("Stop playback"); - bStop.addActionListener(this); - bStop.setEnabled(false); + soundPanel = new SoundPanel(SoundPanel.Option.TIME_LABEL, SoundPanel.Option.PROGRESS_BAR); + soundPanel.setDisplayFormat(SoundPanel.DisplayFormat.ELAPSED_TOTAL_PRECISE); + soundPanel.setVisible(ResourceEntry.isSound(types)); list.addListSelectionListener(this); setResourceEntryUpdated(list.getSelectedValue()); @@ -237,15 +205,6 @@ public void mouseClicked(MouseEvent event) { GridBagConstraints gbc = null; JPanel panel = new JPanel(new GridBagLayout()); - final JPanel soundPanel = new JPanel(new GridBagLayout()); - gbc = ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, - new Insets(0, 0, 0, 0), 0, 0); - soundPanel.add(bPlay, gbc); - gbc = ViewerUtil.setGBC(gbc, 1, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, - new Insets(0, 8, 0, 0), 0, 0); - soundPanel.add(bStop, gbc); - soundPanel.setVisible(ResourceEntry.isSound(types)); - gbc = ViewerUtil.setGBC(gbc, 0, 0, 1, 5, 1.0, 1.0, GridBagConstraints.FIRST_LINE_START, GridBagConstraints.BOTH, new Insets(0, 0, 0, 0), 0, 0); panel.add(list, gbc); @@ -258,13 +217,13 @@ public void mouseClicked(MouseEvent event) { panel.add(spacerTop, gbc); gbc = ViewerUtil.setGBC(gbc, 1, 1, 1, 1, 0.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.HORIZONTAL, - new Insets(3, 6, 3, 0), 0, 0); + new Insets(3, 6, 3, 6), 0, 0); panel.add(bUpdate, gbc); gbc = ViewerUtil.setGBC(gbc, 1, 2, 1, 1, 0.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.HORIZONTAL, - new Insets(3, 6, 3, 0), 0, 0); + new Insets(3, 6, 3, 6), 0, 0); panel.add(bView, gbc); gbc = ViewerUtil.setGBC(gbc, 1, 3, 1, 1, 0.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.HORIZONTAL, - new Insets(24, 6, 3, 0), 0, 0); + new Insets(24, 6, 3, 6), 0, 0); panel.add(soundPanel, gbc); // spacer keeps controls in the center @@ -414,6 +373,13 @@ public String getResourceName() { return resname; } + @Override + public void close() throws Exception { + if (soundPanel != null) { + soundPanel.setPlaying(false); + } + } + public boolean isEmpty() { return (resname.equals(NONE.name));// FIXME: use null instead of NONE.name } @@ -453,16 +419,30 @@ private void setResourceEntryUpdated(ResourceRefEntry entry) { closeResource(currentResource); if (entry != null) { bView.setEnabled(isEditable(entry)); - bPlay.setEnabled(isSound(entry)); - bStop.setEnabled(false); + if (soundPanel != null && soundPanel.isVisible()) { + try { + soundPanel.loadSound(entry.getEntry()); + soundPanel.setEnabled(isSound(entry)); + } catch (NullPointerException e) { + // expected + soundPanel.setEnabled(false); + } catch (Exception e) { + Logger.error(e); + soundPanel.setEnabled(false); + } + } } else { bView.setEnabled(false); - bPlay.setEnabled(false); - bStop.setEnabled(false); + if (soundPanel != null && soundPanel.isVisible()) { + soundPanel.setEnabled(false); + } } } private void closeResource(Resource resource) { + if (soundPanel != null) { + soundPanel.unload(); + } if (resource instanceof Closeable) { try { ((Closeable) resource).close(); @@ -492,11 +472,6 @@ private void setValue(String newValue) { } } - private void updatePlayback(boolean isPlaying) { - bPlay.setEnabled(!isPlaying); - bStop.setEnabled(isPlaying); - } - // -------------------------- INNER CLASSES -------------------------- /** Class that represents resource reference in the list of choice. */ public static final class ResourceRefEntry { diff --git a/src/org/infinity/exceptions/ResourceException.java b/src/org/infinity/exceptions/ResourceException.java new file mode 100644 index 000000000..91c5396f8 --- /dev/null +++ b/src/org/infinity/exceptions/ResourceException.java @@ -0,0 +1,43 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.exceptions; + +/** + * A generic exception that is thrown if a resource-related problem occurs. + */ +public class ResourceException extends Exception { + /** Constructs an {@code ResourceException} with no detail message. */ + public ResourceException() { + super(); + } + + /** + * Constructs an {@code ResourceException} with the specified detail message. + * + * @param message the detail message. + */ + public ResourceException(String message) { + super(message); + } + + /** + * Constructs an {@code ResourceException} with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause for this exception to be thrown. + */ + public ResourceException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs an {@code ResourceException} with a cause but no detail message. + * + * @param cause the cause for this exception to be thrown. + */ + public ResourceException(Throwable cause) { + super(cause); + } +} diff --git a/src/org/infinity/exceptions/ResourceNotFoundException.java b/src/org/infinity/exceptions/ResourceNotFoundException.java new file mode 100644 index 000000000..06513995b --- /dev/null +++ b/src/org/infinity/exceptions/ResourceNotFoundException.java @@ -0,0 +1,43 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.exceptions; + +/** + * Thrown if a game resource could not be found. + */ +public class ResourceNotFoundException extends ResourceException { + /** Constructs an {@code ResourceNotFoundException} with no detail message. */ + public ResourceNotFoundException() { + super(); + } + + /** + * Constructs an {@code ResourceNotFoundException} with the specified detail message. + * + * @param message the detail message. + */ + public ResourceNotFoundException(String message) { + super(message); + } + + /** + * Constructs an {@code ResourceNotFoundException} with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause for this exception to be thrown. + */ + public ResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs an {@code ResourceNotFoundException} with a cause but no detail message. + * + * @param cause the cause for this exception to be thrown. + */ + public ResourceNotFoundException(Throwable cause) { + super(cause); + } +} diff --git a/src/org/infinity/gui/ChildFrame.java b/src/org/infinity/gui/ChildFrame.java index 0a3d218c2..3dbf08bcf 100644 --- a/src/org/infinity/gui/ChildFrame.java +++ b/src/org/infinity/gui/ChildFrame.java @@ -232,36 +232,52 @@ public ChildFrame(String title, boolean closeOnInvisible, boolean closeOnReset) pane.getActionMap().put(pane, new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { - if (ChildFrame.this.closeOnInvisible) { - try { + try { + if (ChildFrame.this.closeOnInvisible) { if (!ChildFrame.this.windowClosing(false)) { return; } - } catch (AbortException e2) { - Logger.debug(e2); - return; - } catch (Exception e2) { - Logger.error(e2); - return; + } else { + if (!ChildFrame.this.windowHiding(false)) { + return; + } } + } catch (AbortException e2) { + Logger.debug(e2); + return; + } catch (Exception e2) { + Logger.error(e2); + return; + } + + if (ChildFrame.this.closeOnInvisible) { WINDOWS.remove(ChildFrame.this); } + ChildFrame.this.setVisible(false); } }); addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { - if (ChildFrame.this.closeOnInvisible) { - try { + try { + if (ChildFrame.this.closeOnInvisible) { if (!ChildFrame.this.windowClosing(false)) { return; } - } catch (Exception e2) { - throw new IllegalAccessError(); // ToDo: This is just too ugly + } else { + if (!ChildFrame.this.windowHiding(false)) { + return; + } } + } catch (Exception e2) { + throw new IllegalAccessError(); // ToDo: This is just too ugly + } + + if (ChildFrame.this.closeOnInvisible) { WINDOWS.remove(ChildFrame.this); } + ChildFrame.this.setVisible(false); } }); @@ -318,6 +334,18 @@ protected boolean windowClosing(boolean forced) throws Exception { return true; } + /** + * This method is called whenever the dialog is about to be hidden without being removed from memory. + * + * @param forced If {@code false}, the return value will be honored. If {@code true}, the return value will be + * disregarded. + * @return If {@code true}, the hiding procedure continues. If {@code false}, the hiding procedure will be cancelled. + * @throws Exception + */ + protected boolean windowHiding(boolean forced) throws Exception { + return true; + } + /** * Returns the size of the last created child frame. Falls back to the default size if information is not available. * diff --git a/src/org/infinity/gui/SoundPanel.java b/src/org/infinity/gui/SoundPanel.java new file mode 100644 index 000000000..7fa151be7 --- /dev/null +++ b/src/org/infinity/gui/SoundPanel.java @@ -0,0 +1,1190 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.gui; + +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.beans.PropertyChangeEvent; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Hashtable; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.UnsupportedAudioFileException; +import javax.swing.BoundedRangeModel; +import javax.swing.DefaultBoundedRangeModel; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JSlider; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; + +import org.infinity.exceptions.ResourceNotFoundException; +import org.infinity.icon.Icons; +import org.infinity.resource.Closeable; +import org.infinity.resource.key.ResourceEntry; +import org.infinity.resource.sound.AudioBuffer; +import org.infinity.resource.sound.AudioFactory; +import org.infinity.resource.sound.AudioStateEvent; +import org.infinity.resource.sound.AudioStateListener; +import org.infinity.resource.sound.SingleAudioPlayer; +import org.infinity.resource.sound.WavBuffer; +import org.infinity.util.Threading; +import org.tinylog.Logger; + +/** + * A customizable panel with controls for playing back individual sound clips. + *

+ * Playback state of sounds can be tracked by {@link AudioStateEvent}s. + *

+ */ +public class SoundPanel extends JPanel implements Closeable { + /** Optional UI controls for display. */ + public enum Option { + /** + * Specifies a label that displays the current playback time. The control is shown below the playback controls and + * progress bar if visible. + *

+ * The {@link DisplayFormat} enum can be used to further customize the display. + *

+ */ + TIME_LABEL, + /** + * Specifies a checkbox for enabling or disabling looped playback. The control is shown on the right side of the + * playback controls. + */ + LOOP_CHECKBOX, + /** + * Specifies a slider that shows the playback current progress and allows the user to jump to specific positions + * within the audio clip. The control is shown directly below the playback controls. + */ + PROGRESS_BAR, + /** + * Specifies that the progress bar should display labels for every major tick (e.g. every 10 or 30 seconds). + *

This option is only effective if {@link #PROGRESS_BAR} is enabled as well.

+ */ + PROGRESS_BAR_LABELS, + } + + /** List of available formatted strings for displaying playback time. */ + public enum DisplayFormat { + /** Display of elapsed time (minutes and seconds): {@code mm:ss}. */ + ELAPSED("%1$02d:%2$02d"), + + /** Display of elapsed time (minutes, seconds, deciseconds): {@code mm:ss,x}. */ + ELAPSED_PRECISE("%1$02d:%2$02d,%3$d"), + + /** Display of elapsed and total time (minutes and seconds): {@code mm:ss / mm:ss}. */ + ELAPSED_TOTAL("%1$02d:%2$02d / %4$02d:%5$02d"), + + /** Display of elapsed and total time (minutes, seconds, and deciseconds): {@code mm:ss,x / mm:ss,x}. */ + ELAPSED_TOTAL_PRECISE("%1$02d:%2$02d,%3$d / %4$02d:%5$02d,%6$d"), + ; + + /** + * Format string supports the following positional placeholders: + * + */ + private final String fmt; + + private DisplayFormat(String fmt) { + this.fmt = fmt; + } + + /** + * Returns the format string associated with the enum value. + * + * @return A format string. + */ + public String getFormatString() { + return fmt; + } + + /** + * Returns a formatted string based on the specified parameters. + * + * @param elapsed The elapsed playback time, in milliseconds. + * @param total The total playback time, in milliseconds. + * @return Formatted string based on {@link #getFormatString()}.. + */ + public String toString(long elapsed, long total) { + if (fmt.isEmpty()) { + // shortcut + return ""; + } else { + final int elapsedMinutes = (int) (elapsed / 60_000L); + final int elapsedSeconds = (int) ((elapsed / 1000L) % 60L); + final int elapsedFractional = (int) ((elapsed / 100L) % 10L); + final int totalMinutes = (int) (total / 60_000L); + final int totalSeconds = (int) ((total / 1000L) % 60L); + final int totalFractional = (int) ((total / 100L) % 10L); + return String.format(fmt, elapsedMinutes, elapsedSeconds, elapsedFractional, totalMinutes, totalSeconds, + totalFractional); + } + } + } + + /** + * Property name for {@link PropertyChangeEvent} calls. + * Associated parameter is of type {@link Boolean} and indicates the playback state (playing/stopped). + */ + public static final String PROPERTY_NAME_PLAYING = "soundPanelPlaying"; + + /** + * Property name for {@link PropertyChangeEvent} calls. + * Associated parameter is of type {@link Boolean} and indicates the paused state (paused/playing). + */ + public static final String PROPERTY_NAME_PAUSED = "soundPanelPaused"; + + private static final String CMD_PLAY = "play"; + private static final String CMD_STOP = "stop"; + private static final String CMD_LOOP = "loop"; + + private static final ImageIcon ICON_PLAY = Icons.ICON_PLAY_16.getIcon(); + private static final ImageIcon ICON_PAUSE = Icons.ICON_PAUSE_16.getIcon(); + private static final ImageIcon ICON_STOP = Icons.ICON_STOP_16.getIcon(); + + private static boolean looped = false; + + private final List stateListeners = new ArrayList<>(); + + private final Runner runner; + private final Listeners listener = new Listeners(); + + private JButton playButton; + private JButton stopButton; + private JLabel displayLabel; + private JCheckBox loopCheckBox; + private FixedSlider progressSlider; + + private ResourceEntry soundEntry; + private DisplayFormat displayFormat; + private AudioBuffer audioBuffer; + + private boolean closed; + + private boolean progressAdjusting; + private boolean showProgressLabels; + + /** + * Creates a new sound panel. Initializes it with {@link DisplayFormat#ELAPSED_TOTAL} to display time but does not + * open a sound resource. + * + * @param options A set of {@link Option} values that controls visibility of optional control elements. + */ + public SoundPanel(Option... options) { + super(new GridBagLayout()); + init(options); + setDisplayFormat(DisplayFormat.ELAPSED_TOTAL); + runner = new Runner(); + } + + /** + * Creates a new sound panel, opens the specified sound resource and initializes it with + * {@link DisplayFormat#ELAPSED_TOTAL} to display time. + * + * @param entry {@link ResourceEntry} of the sound resource. + * @param options A set of {@link Option} values that controls visibility of optional control elements. + * @throws ResourceNotFoundException if the resource referenced by the {@code ResourceEntry} parameter does not exist. + * @throws NullPointerException if {@code entry} is {@code null}. + */ + public SoundPanel(ResourceEntry entry, Option... options) throws ResourceNotFoundException { + this(entry, DisplayFormat.ELAPSED_TOTAL, false, options); + } + + /** + * Creates a new sound panel and opens the specified sound resource. + * + * @param entry {@link ResourceEntry} of the sound resource. + * @param format {@link DisplayFormat} enum with the format description. {@code null} resolves to + * {@link DisplayFormat#NONE}. + * @param options A set of {@link Option} values that controls visibility of optional control elements. + * @throws ResourceNotFoundException if the resource referenced by the {@code ResourceEntry} parameter does not exist. + * @throws NullPointerException if {@code entry} is {@code null}. + */ + public SoundPanel(ResourceEntry entry, DisplayFormat format, Option... options) throws ResourceNotFoundException { + this(entry, format, false, options); + } + + /** + * Creates a new sound panel and opens the specified sound resource. + * + * @param entry {@link ResourceEntry} of the sound resource. + * @param format {@link DisplayFormat} enum with the format description. {@code null} resolves to + * {@link DisplayFormat#NONE}. + * @param playback Specifies whether sound playback should start automatically. + * @param options A set of {@link Option} values that controls visibility of optional control elements. + * @throws ResourceNotFoundException if the resource referenced by the {@code ResourceEntry} parameter does not exist. + * @throws NullPointerException if {@code entry} is {@code null}. + */ + public SoundPanel(ResourceEntry entry, DisplayFormat format, boolean playback, Option... options) + throws ResourceNotFoundException { + super(new GridBagLayout()); + init(options); + setDisplayFormat(format); + runner = new Runner(); + loadSound(entry); + if (playback) { + setPlaying(true); + } + } + + /** + * Stops playback of the old sound resource, if any, and loads the specified sound resource. + * + * @param entry {@link ResourceEntry} of the sound resource to load. + * @throws ResourceNotFoundException if the resource referenced by the {@code ResourceEntry} parameter does not exist. + * @throws NullPointerException if {@code entry} is {@code null}. + */ + public void loadSound(ResourceEntry entry) throws ResourceNotFoundException { + loadSound(entry, null); + } + + /** + * Stops playback of the old sound resource, if any, and loads the specified sound resource. + * + * @param entry {@link ResourceEntry} of the sound resource to load. + * @param onCompleted An optional {@link Consumer} operation that is executed when the resource has been loaded. The + * boolean parameter signals success ({@code true}) or failure ({@code false}) of the load + * operation. + * @throws ResourceNotFoundException if the resource referenced by the {@code ResourceEntry} parameter does not exist. + * @throws NullPointerException if {@code entry} is {@code null}. + */ + public void loadSound(ResourceEntry entry, Consumer onCompleted) throws ResourceNotFoundException { + if (isClosed()) { + return; + } + + if (entry == null) { + throw new NullPointerException("entry is null"); + } + + if (entry.getActualPath() != null && !Files.isRegularFile(entry.getActualPath())) { + throw new ResourceNotFoundException("Not found: " + entry); + } + + unload(); + + // sound resource is loaded asynchronously + setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); + + final Supplier operation = () -> { + try { + AudioBuffer.AudioOverride override = null; + AudioBuffer buffer = null; + + // ignore # channels in ACM headers + if (entry.getExtension().equalsIgnoreCase("ACM")) { + override = AudioBuffer.AudioOverride.overrideChannels(2); + } + + buffer = AudioFactory.getAudioBuffer(entry, override); + if (buffer != null) { + soundEntry = entry; + runner.setAudio(buffer); + runner.setLooped(isLooped()); + audioBuffer = buffer; + initSliderRange(); + } else { + throw new Exception("Audio format could not be determined."); + } + } catch (Throwable e) { + setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); + return e; + } + return null; + }; + + final Consumer finalize = ex -> { + setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); + if (onCompleted != null) { + onCompleted.accept(ex == null); + } + if (ex != null) { + // operation failed to complete + Logger.error(ex); + Threading.invokeInEventThread(() -> JOptionPane.showMessageDialog(SoundPanel.this.getTopLevelAncestor(), + "Could not load sound resource: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE)); + } + }; + + CompletableFuture.supplyAsync(operation).thenAccept(finalize); + } + + /** Stops playback and releases the current sound resource, if any. */ + public void unload() { + if (isClosed()) { + return; + } + + setPlaying(false); + soundEntry = null; + audioBuffer = null; + try { + runner.setAudio(null); + } catch (Exception e) { + } + } + + /** + * Returns the currently loaded sound resource. + * + * @return {@link ResourceEntry} of the current sound resource. Returns {@code null} if no sound resource is loaded. + */ + public ResourceEntry getSoundResource() { + return soundEntry; + } + + /** + * Provides access to basic properties of the currently loaded audio resource. + * + * @return {@link AudioBuffer} instance of the currently loaded audio resource. Returns {@code null} if no audio + * resource is loaded. + */ + public AudioBuffer getAudioBuffer() { + return audioBuffer; + } + + /** + * Returns the current display format of playback time. + * + * @return a {@link DisplayFormat} object describing the current playback time display. + */ + public DisplayFormat getDisplayFormat() { + return displayFormat; + } + + /** + * Convenience method that returns whether the currently loaded sound resource contains uncompressed WAV audio data. + * + * @return {@code true} if a sound resource is loaded and contains uncompressed WAV audio data, {@code false} + * otherwise. + */ + public boolean isWavFile() { + return audioBuffer instanceof WavBuffer; + } + + /** + * Returns {@code true} if the control designated by the specified {@link Option} enum is visible. + * + * @param option {@link Option} enum to check. + * @return {@code true} if the associated control is visible, {@code false} otherwise. + * @throws NullPointerException if {@code option} is {@code null}. + */ + public boolean isOptionEnabled(Option option) { + if (option == null) { + throw new NullPointerException("option is null"); + } + + switch (option) { + case LOOP_CHECKBOX: + return loopCheckBox.isVisible(); + case PROGRESS_BAR: + return progressSlider.isVisible(); + case PROGRESS_BAR_LABELS: + return showProgressLabels; + case TIME_LABEL: + return displayLabel.isVisible(); + } + return false; + } + + /** + * Specifies the display format for playback time of the sound resource. + * + * @param format {@link DisplayFormat} enum with the format description. {@code null} resolves to + * {@link DisplayFormat#ELAPSED_TOTAL}. + */ + public void setDisplayFormat(DisplayFormat format) { + displayFormat = (format != null) ? format : DisplayFormat.ELAPSED_TOTAL; + } + + /** Returns whether loop mode is enabled. */ + public boolean isLooped() { + return loopCheckBox.isSelected(); + } + + /** Enables or disable loop mode. */ + public void setLooped(boolean loop) { + if (isClosed()) { + return; + } + + if (loopCheckBox.isSelected() != loop) { + loopCheckBox.setSelected(loop); + } + looped = loop; + runner.setLooped(loop); + } + + /** + * Returns whether playback is currently active. Pausing playback does not affect the result. + * + * @return {@code true} if sound is currently played back or paused, {@code false} otherwise. + */ + public boolean isPlaying() { + if (isClosed()) { + return false; + } + + return runner.isPlaying(); + } + + /** + * Sets the playback state of the current sound resource. The paused state does not affect the playback state. + * + * @param play Specify {@code true} to start playback or {@code false} to stop playback. + */ + public void setPlaying(boolean play) { + if (isClosed()) { + return; + } + + if (runner.isPlaying() == play) { + return; + } + + runner.setPlaying(play); + } + + /** + * Returns whether playback is currently paused. + * + * @return {@code true} only if playback is active but paused, {@code false} otherwise. + */ + public boolean isPaused() { + if (isClosed()) { + return false; + } + + return runner.isPaused(); + } + + /** + * Pauses playback. Does nothing if playback currently not active. + * + * @param pause Specify {@code true} to pause current playback. Specify {@code false} to continue playback. + */ + public void setPaused(boolean pause) { + if (isClosed()) { + return; + } + + if (!runner.isPlaying() || runner.isPaused() == pause) { + return; + } + + runner.setPaused(pause); + } + + /** + * Returns whether this sound panel has been closed. A closed panel does not load or play sound files anymore. + * + * @return {@code true} if the sound panel has been closed, {@code false} otherwise. + */ + public boolean isClosed() { + return closed; + } + + /** + * Stops playback and releases any resources. After calling this method it is not possible to load or play sound + * resources anymore. + */ + @Override + public void close() throws Exception { + unload(); + runner.terminate(); + closed = true; + } + + @Override + public void setEnabled(boolean enabled) { + if (enabled) { + updateControls(); + } else { + playButton.setEnabled(false); + stopButton.setEnabled(false); + progressSlider.setEnabled(false); + } + displayLabel.setEnabled(enabled); + loopCheckBox.setEnabled(enabled); + super.setEnabled(enabled); + } + + /** Adds a {@link AudioStateListener} to the sound panel instance. */ + public void addAudioStateListener(AudioStateListener l) { + if (l != null) { + listenerList.add(AudioStateListener.class, l); + } + } + + /** Returns all registered {@link AudioStateListener}s for this audio player instance. */ + public AudioStateListener[] getAudioStateListeners() { + return listenerList.getListeners(AudioStateListener.class); + } + + /** Removes a {@link AudioStateListener} from the audio player instance. */ + public void removeAudioStateListener(AudioStateListener l) { + if (l != null) { + listenerList.remove(AudioStateListener.class, l); + } + } + + /** Fires a {@link AudioStateListener} of the specified name to all registered listeners. */ + private void fireAudioStateEvent(AudioStateEvent.State state, Object value) { + // collecting + stateListeners.clear(); + final Object[] entries = getAudioStateListeners(); + AudioStateEvent evt = null; + for (int i = entries.length - 2; i >= 0; i -= 2) { + if (entries[i] == AudioStateListener.class) { + // event object is lazily created + if (evt == null) { + evt = new AudioStateEvent(this, state, value); + } + stateListeners.add((AudioStateListener) entries[i + 1]); + } + } + + // executing + if (!stateListeners.isEmpty()) { + final AudioStateEvent event = evt; + SwingUtilities.invokeLater(() -> stateListeners.forEach(l -> l.audioStateChanged(event))); + } + } + + /** Fires a {@link AudioStateEvent} when a sound resource has been opened and is ready for playback. */ + private void fireSoundOpened() { + final String value = (getSoundResource() != null) ? getSoundResource().getResourceName() : null; + fireAudioStateEvent(AudioStateEvent.State.OPEN, value); + } + + /** Fires a {@link AudioStateEvent} when the current sound resource has been closed. */ + private void fireSoundClosed() { + final String value = (getSoundResource() != null) ? getSoundResource().getResourceName() : null; + fireAudioStateEvent(AudioStateEvent.State.CLOSE, value); + } + + /** Fires a {@link AudioStateEvent} when starting playback. */ + private void fireSoundStarted() { + fireAudioStateEvent(AudioStateEvent.State.START, null); + } + + /** Fires a {@link AudioStateEvent} when stopping playback. */ + private void fireSoundStopped() { + fireAudioStateEvent(AudioStateEvent.State.STOP, null); + } + + /** Fires a {@link AudioStateEvent} when entering the paused state. */ + private void fireSoundPaused() { + fireAudioStateEvent(AudioStateEvent.State.PAUSE, Long.valueOf(runner.getElapsedTime())); + } + + /** Fires a {@link AudioStateEvent} when resuming from the paused state. */ + private void fireSoundResumed() { + fireAudioStateEvent(AudioStateEvent.State.RESUME, Long.valueOf(runner.getElapsedTime())); + } + + private void updateLabel() { + final long elapsedTime = runner.getElapsedTime(); + final long totalTime = runner.getTotalLength(); + final String displayString = getDisplayFormat().toString(elapsedTime, totalTime); + displayLabel.setText(displayString); + + final AdjustingBoundedRangeModel model = (AdjustingBoundedRangeModel)progressSlider.getModel(); + model.setDirectValue((int)elapsedTime); + } + + /** Updates playback UI controls to reflect the current state. */ + private void updateControls() { + if (runner.isAvailable()) { + if (runner.isPlaying()) { + if (runner.isPaused()) { + playButton.setIcon(ICON_PAUSE); + } else { + playButton.setIcon(ICON_PLAY); + } + playButton.setEnabled(true); + stopButton.setEnabled(true); + progressSlider.setEnabled(true); + } else { + playButton.setIcon(ICON_PLAY); + playButton.setEnabled(true); + stopButton.setEnabled(false); + progressSlider.setEnabled(false); + } + } else { + playButton.setIcon(ICON_PLAY); + playButton.setEnabled(false); + stopButton.setEnabled(false); + progressSlider.setEnabled(false); + } + } + + /** Resets slider settings of the progress bar. */ + private void initSliderRange() { + if (runner.isAvailable()) { + final int duration = (int) runner.getTotalLength(); + progressSlider.setMaximum(duration); + if (duration < 45_000) { + // major: per ten seconds, minor: per second + progressSlider.setMajorTickSpacing(10_000); + progressSlider.setMinorTickSpacing(1_000); + } else { + // major: per minute, minor: per ten seconds + progressSlider.setMajorTickSpacing(30_000); + progressSlider.setMinorTickSpacing(5_000); + } + initSliderTickLabels(); + } else { + progressSlider.setMaximum(1); + progressSlider.setMajorTickSpacing(1); + progressSlider.setMinorTickSpacing(1); + } + } + + /** Defines labels for the major progress bar ticks. */ + private void initSliderTickLabels() { + if (progressSlider == null || !progressSlider.isVisible() || !showProgressLabels) { + return; + } + + progressSlider.setLabelTable(null); + + final Hashtable labels = new Hashtable<>(); + final int spacing = progressSlider.getMajorTickSpacing(); + for (int pos = progressSlider.getMinimum(); pos < progressSlider.getMaximum(); pos += spacing) { + labels.put(Integer.valueOf(pos), createSliderTickLabel(pos)); + } + + // add label for end of range if suitable + final int minSpace; + if (progressSlider.getMaximum() < 10_000) { + minSpace = 0; + } else if (progressSlider.getMaximum() < 45_000) { + minSpace = spacing * 2 / 3; + } else { + minSpace = spacing / 2; + } + final int remainingSpace = (progressSlider.getMaximum() - progressSlider.getMinimum()) % spacing; + if (remainingSpace > minSpace) { + labels.put(Integer.valueOf(progressSlider.getMaximum()), createSliderTickLabel(progressSlider.getMaximum())); + } + + if (!labels.isEmpty()) { + progressSlider.setLabelTable(labels); + } + } + + /** Creates a label for a slider tick with the specified time value. */ + private static JLabel createSliderTickLabel(int timeMs) { + final int min = timeMs / 60_000; + final int sec = (timeMs / 1_000) % 60; + final JLabel label = new JLabel(String.format("%02d:%02d", min, sec)); + final Font font = label.getFont(); + label.setFont(font.deriveFont(Font.PLAIN, font.getSize2D() * 0.75f)); + return label; + } + + /** Creates the sound panel. */ + private void init(Option... options) { + closed = false; + + playButton = new JButton(ICON_PLAY); + playButton.setActionCommand(CMD_PLAY); + playButton.addActionListener(listener); + + stopButton = new JButton(ICON_STOP); + stopButton.setActionCommand(CMD_STOP); + stopButton.addActionListener(listener); + + loopCheckBox = new JCheckBox("Loop", looped); + loopCheckBox.setActionCommand(CMD_LOOP); + loopCheckBox.addActionListener(listener); + loopCheckBox.setVisible(false); + + displayLabel = new JLabel(DisplayFormat.ELAPSED_TOTAL.toString(0L, 0L), SwingConstants.LEADING); + displayLabel.setVisible(false); + + progressSlider = new FixedSlider(new AdjustingBoundedRangeModel()); + progressSlider.setOrientation(SwingConstants.HORIZONTAL); + progressSlider.setPaintTicks(true); + progressSlider.addChangeListener(listener); + progressSlider.setVisible(false); + + // making selected options visible + for (final Option option : options) { + if (option != null) { + switch (option) { + case LOOP_CHECKBOX: + loopCheckBox.setVisible(true); + break; + case PROGRESS_BAR: + progressSlider.setVisible(true); + break; + case PROGRESS_BAR_LABELS: + showProgressLabels = true; + break; + case TIME_LABEL: + displayLabel.setVisible(true); + break; + } + } + } + + progressSlider.setPaintLabels(showProgressLabels); + + // assembling panel + final GridBagConstraints gbc = new GridBagConstraints(); + final JPanel playbackPanel = new JPanel(new GridBagLayout()); + ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(0, 0, 0, 0), 0, 0); + playbackPanel.add(playButton, gbc); + ViewerUtil.setGBC(gbc, 1, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(0, 8, 0, 0), 0, 0); + playbackPanel.add(stopButton, gbc); + ViewerUtil.setGBC(gbc, 2, 0, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.NONE, + new Insets(0, 8, 0, 0), 0, 0); + playbackPanel.add(loopCheckBox, gbc); + + ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 1.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(0, 0, 0, 0), 0, 0); + add(playbackPanel, gbc); + + ViewerUtil.setGBC(gbc, 0, 1, 1, 1, 1.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.HORIZONTAL, + new Insets(8, 0, 0, 0), 0, 0); + add(progressSlider, gbc); + + ViewerUtil.setGBC(gbc, 0, 2, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, + new Insets(8, 0, 0, 0), 0, 0); + add(displayLabel, gbc); + + // cosmetic adjustment + if (progressSlider.isVisible() && !loopCheckBox.isVisible()) { + final int sliderWidth = playbackPanel.getPreferredSize().width; + final int sliderHeight = progressSlider.getPreferredSize().height; + progressSlider.setPreferredSize(new Dimension(sliderWidth, sliderHeight)); + } + if (displayLabel.isVisible() && !loopCheckBox.isVisible()) { + displayLabel.setHorizontalAlignment(SwingConstants.CENTER); + } + } + + // -------------------------- INNER CLASSES -------------------------- + + /** Listeners for the SoundPanel class. */ + private class Listeners implements ActionListener, ChangeListener { + public Listeners() { + } + + @Override + public void actionPerformed(ActionEvent e) { + switch (e.getActionCommand()) { + case CMD_PLAY: + if (SoundPanel.this.isPlaying()) { + SoundPanel.this.setPaused(!SoundPanel.this.isPaused()); + } else { + SoundPanel.this.setPlaying(true); + } + break; + case CMD_STOP: + SoundPanel.this.setPlaying(false); + break; + case CMD_LOOP: + setLooped(loopCheckBox.isSelected()); + break; + } + } + + @Override + public void stateChanged(ChangeEvent e) { + if (e.getSource() == progressSlider) { + if (progressSlider.isEnabled()) { + if (progressSlider.getValueIsAdjusting() && !progressAdjusting) { + // starting to manually drag the slider knob + progressAdjusting = true; + } else if (!progressSlider.getValueIsAdjusting() && progressAdjusting) { + // stopped dragging the slider knob + progressAdjusting = false; + final AdjustingBoundedRangeModel model = (AdjustingBoundedRangeModel)progressSlider.getModel(); + model.setValue(model.getAdjustedValue()); + runner.setSoundPosition(model.getAdjustedValue()); + } + } + } + } + } + + /** + * Background thread for the SoundPanel class that handles playback of sound clips. + */ + private class Runner implements Runnable, AudioStateListener { + private final ReentrantLock lock = new ReentrantLock(); + private final Thread thread; + + private SingleAudioPlayer player; + private boolean running; + + /** Initializes the class instance and starts a background thread. */ + public Runner() { + thread = new Thread(this); + running = true; + thread.start(); + } + + /** + * Assigns a new {@link SingleAudioPlayer} instance to the runner. The old player instance, if any, is properly closed + * before the new instance is assigned. + * + * @param newBuffer + * @throws IOException if an I/O error occurs. + * @throws UnsupportedAudioFileException if the audio data is incompatible with the player. + * @throws LineUnavailableException if the audio line is not available due to resource restrictions. + * @throws IllegalArgumentException if the audio data is invalid. + */ + public void setAudio(AudioBuffer newBuffer) throws Exception{ + lock.lock(); + try { + if (player != null) { + player.stop(); + player.close(); + player = null; + } + + if (newBuffer != null) { + player = new SingleAudioPlayer(newBuffer, this); + player.setLooped(SoundPanel.looped); + } + } finally { + lock.unlock(); + } + + signal(); + updatePanel(); + } + + /** Returns whether an audio player is currently initialized. */ + public boolean isAvailable() { + return (player != null); + } + + /** Returns whether loop mode is enabled. */ + @SuppressWarnings("unused") + public boolean isLooped() { + return (player != null) ? player.isLooped() : false; + } + + /** Enables or disable looped playback. */ + public void setLooped(boolean loop) { + if (player != null) { + player.setLooped(loop); + } + } + + /** + * Returns whether the player is playing. Paused state does not affect the result. + * Returns {@code false} if the player is not initialized. + */ + public boolean isPlaying() { + return (player != null) ? player.isPlaying() : false; + } + + /** + * Sets the playback state of the player. {@code true} starts playback, {@code false} stops playback. Paused + * state does not affect the current playing state. Does nothing if the player is not initialized. + */ + public void setPlaying(boolean play) { + if (player != null) { + if (play) { + player.play(); + } else { + player.stop(); + } + } + } + + /** + * Returns whether the player is currently in the paused state. This state does not affect the {@link #isPlaying()} + * state. Always returns {@code false} if playback is stopped or the player is not initialized. + */ + public boolean isPaused() { + return (player != null) ? player.isPaused() : false; + } + + /** + * Sets the paused state of the player. {@code true} pauses playback, {@code false} resumes playback. Does nothing + * if playback is not active or the player has not been initialized. + */ + public void setPaused(boolean pause) { + if (player != null) { + if (pause) { + player.pause(); + } else { + player.resume(); + } + } + } + + /** + * Returns the total play length of the sound clip, in milliseconds. Returns {@code 0} if the player is not + * initialized. + */ + public long getTotalLength() { + return (player != null) ? player.getTotalLength() : 0L; + } + + /** + * Returns the elapsed playback time of the sound clip, in milliseconds. Returns {@code 0} if the player is not + * initialized. + */ + public long getElapsedTime() { + return (player != null) ? player.getElapsedTime() : 0L; + } + + /** + * Sets an explicit playback position. + * + * @param position New playback position in milliseconds. Position is clamped to the available audio clip duration. + */ + public void setSoundPosition(int position) { + if (player != null) { + player.setSoundPosition(position); + } + } + + /** Signals the runner to wake up from a waiting state. */ + public void signal() { + thread.interrupt(); + } + + /** Returns whether the runner is active. */ + public boolean isRunning() { + return running; + } + + /** Signals the runner to terminate. */ + public void terminate() { + running = false; + signal(); + try { + setAudio(null); + } catch (Exception e) { + } + updatePanel(); + } + + @Override + public void run() { + while (isRunning()) { + final boolean isActive; + lock.lock(); + try { + isActive = (player != null) && player.isPlaying() && !player.isPaused(); + } finally { + lock.unlock(); + } + if (isActive) { + try { + SwingUtilities.invokeLater(SoundPanel.this::updateLabel); + Thread.sleep(100L); + } catch (InterruptedException e) { + // waking up prematurely + } + } else { + try { + Thread.sleep(Integer.MAX_VALUE); + } catch (InterruptedException e) { + // waking up from sleep + } + } + } + } + + @Override + public void audioStateChanged(AudioStateEvent event) { + switch (event.getAudioState()) { + case START: + SoundPanel.this.fireSoundStarted(); + break; + case STOP: + SoundPanel.this.fireSoundStopped(); + break; + case PAUSE: + SoundPanel.this.fireSoundPaused(); + break; + case RESUME: + SoundPanel.this.fireSoundResumed(); + break; + case OPEN: + SoundPanel.this.fireSoundOpened(); + break; + case CLOSE: + SoundPanel.this.fireSoundClosed(); + break; + default: + } + signal(); + updatePanel(); + } + + private void updatePanel() { + SwingUtilities.invokeLater(() -> { + SoundPanel.this.updateControls(); + SoundPanel.this.updateLabel(); + }); + } + } + + /** + * Extends the {@link JSlider} class to handle issues with setting the slider position if the component was disabled + * while the user was still dragging the slider knob. + */ + private static class FixedSlider extends JSlider { + @SuppressWarnings("unused") + public FixedSlider() { + super(); + } + + @SuppressWarnings("unused") + public FixedSlider(int orientation) { + super(orientation); + } + + @SuppressWarnings("unused") + public FixedSlider(int min, int max) { + super(min, max); + } + + @SuppressWarnings("unused") + public FixedSlider(int min, int max, int value) { + super(min, max, value); + } + + @SuppressWarnings("unused") + public FixedSlider(int orientation, int min, int max, int value) { + super(orientation, min, max, value); + } + + public FixedSlider(BoundedRangeModel brm) { + super(brm); + } + + @Override + public void setEnabled(boolean enabled) { + clearDragMode(); + super.setEnabled(enabled); + } + + /** + * Clears pending dragging mode that is initiated when the user clicks the slider knob and drags it around. + *

+ * Dragging mode is not properly cleared if the component is disabled while dragging is still active which results + * in an unresponsive position display when the component becomes active again, until the user intentionally clicks + * on the component again. + *

+ *

+ * This method simulates the mouse release by the user and should be called right before the component is disabled. + *

+ */ + private void clearDragMode() { + final MouseListener[] ml = getMouseListeners(); + if (ml.length > 0) { + final Point pt = getMousePosition(); + final int x = (pt != null) ? pt.x : 0; + final int y = (pt != null) ? pt.y : 0; + + final MouseEvent me = new MouseEvent(this, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(), 0, x, y, 1, + false, MouseEvent.BUTTON1); + for (final MouseListener m : ml) { + m.mouseReleased(me); + } + } + } + } + + /** + * Specialization of the {@link DefaultBoundedRangeModel} class. It decouples values set during "adjusting" and direct + * value mode. The "adjusting" value can be received from the separate getter method {@link #getAdjustedValue()}. + */ + private static class AdjustingBoundedRangeModel extends DefaultBoundedRangeModel { + private int adjustedValue; + + public AdjustingBoundedRangeModel() { + super(); + } + + @SuppressWarnings("unused") + public AdjustingBoundedRangeModel(int value, int extent, int min, int max) { + super(value, extent, min, max); + this.adjustedValue = getValue(); + } + + private int getValidatedValue(int n) { + n = Math.min(n, Integer.MAX_VALUE - getExtent()); + int newValue = Math.max(n, getMinimum()); + if (newValue + getExtent() > getMaximum()) { + newValue = getMaximum() - getExtent(); + } + return newValue; + } + + public void setDirectValue(int n) { + super.setValue(n); + } + + @Override + public void setValue(int n) { + if (getValueIsAdjusting()) { + n = Math.min(n, Integer.MAX_VALUE - getExtent()); + int newValue = Math.max(n, getMinimum()); + if (newValue + getExtent() > getMaximum()) { + newValue = getMaximum() - getExtent(); + } + adjustedValue = getValidatedValue(n); + fireStateChanged(); + } else { + super.setValue(n); + } + } + + /** + * Returns the last known value that was assigned in "adjusting" mode. + * + * @return the model's last known "adjusting" value. + */ + public int getAdjustedValue() { + return adjustedValue; + } + } +} diff --git a/src/org/infinity/gui/StructViewer.java b/src/org/infinity/gui/StructViewer.java index b634da854..1ea0c0c2a 100644 --- a/src/org/infinity/gui/StructViewer.java +++ b/src/org/infinity/gui/StructViewer.java @@ -1010,6 +1010,8 @@ public void close() { } tabbedPane = null; } + + closeEditor(); } public StructEntry getSelectedEntry() { @@ -1348,6 +1350,8 @@ private ViewFrame createViewFrame(Component parent, Viewable view) { * @throws NullPointerException if {@code editable} is {@code null} */ private void edit(Editable editable) { + closeEditor(); + // Save for handle UPDATE_VALUE events later this.editable = editable; editpanel.removeAll(); @@ -1371,6 +1375,16 @@ private void edit(Editable editable) { editable.select(); } + private void closeEditor() { + if (this.editable instanceof Closeable) { + try { + ((Closeable)this.editable).close(); + } catch (Exception e) { + Logger.error(e); + } + } + } + /** * Move viewport for this table in such way that specified entry becomes visible and selects it. * diff --git a/src/org/infinity/resource/sound/AudioPlayer.java b/src/org/infinity/resource/sound/AudioPlayer.java index f8b2846ea..83f6e7ffd 100644 --- a/src/org/infinity/resource/sound/AudioPlayer.java +++ b/src/org/infinity/resource/sound/AudioPlayer.java @@ -15,6 +15,10 @@ import org.infinity.util.Logger; +/** + * This class provides a conventional way to play back sound data. It is mostly suited for playing streamed sound data. + * For playback of single sound clips the {@link SingleAudioPlayer} class is more suited. + */ public class AudioPlayer { private final byte[] buffer = new byte[8196]; diff --git a/src/org/infinity/resource/sound/AudioStateEvent.java b/src/org/infinity/resource/sound/AudioStateEvent.java new file mode 100644 index 000000000..0469ea33b --- /dev/null +++ b/src/org/infinity/resource/sound/AudioStateEvent.java @@ -0,0 +1,65 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.resource.sound; + +import java.util.EventObject; + +/** + * An AudioStateEvent is triggered when the state of a sound clip has changed. + *

+ * States may include opening or closing a sound resource, starting or stopping playback, and pausing or resuming + * playback. + *

+ */ +public class AudioStateEvent extends EventObject { + /** Provides available audio states. */ + public enum State { + /** + * A sound resource has been successfully opened and is ready for playback. Associated value: Sound resource name + * {@link String}) if available, {@code null} otherwise. + */ + OPEN, + /** + * The current sound clip is released. Associated value: Sound resource name {@link String}) if available, + * {@code null} otherwise. + */ + CLOSE, + /** Playback of the current sound clip has started from the beginning. Associated value: {@code null} */ + START, + /** Playback of the current sound clip has stopped. Associated value: {@code null} */ + STOP, + /** Playback of the current sound clip is paused. Associated value: Elapsed time in milliseconds ({@link Long}) */ + PAUSE, + /** Paused playback is resumed. Associated value: Elapsed time in milliseconds ({@link Long}) */ + RESUME, + } + + private AudioStateEvent.State audioState; + private Object value; + + public AudioStateEvent(Object source, AudioStateEvent.State audioState, Object value) { + super(source); + this.audioState = audioState; + this.value = value; + } + + /** Returns the state that triggered this event. */ + public AudioStateEvent.State getAudioState() { + return audioState; + } + + /** Returns the value associated with the state. Value may be {@code null} if the state doesn't provide a value. */ + public Object getValue() { + return value; + } + + public String toString() { + StringBuilder sb = new StringBuilder(getClass().getName()); + sb.append("[audioState=").append(getAudioState()); + sb.append("; value=").append(getValue()); + sb.append("; source=").append(getSource()); + return sb.append("]").toString(); + } +} \ No newline at end of file diff --git a/src/org/infinity/resource/sound/AudioStateListener.java b/src/org/infinity/resource/sound/AudioStateListener.java new file mode 100644 index 000000000..67de00224 --- /dev/null +++ b/src/org/infinity/resource/sound/AudioStateListener.java @@ -0,0 +1,20 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.resource.sound; + +import java.util.EventListener; + +/** + * Instances of classes that implement the {@code AudioStateListener} interface can receive events when the state + * of the audio player has changed. + */ +public interface AudioStateListener extends EventListener { + /** + * Informs the listener that the audio state has changed. + * + * @param event a {@link AudioStateEvent} that describes the changed state. + */ + void audioStateChanged(AudioStateEvent event); +} \ No newline at end of file diff --git a/src/org/infinity/resource/sound/SingleAudioPlayer.java b/src/org/infinity/resource/sound/SingleAudioPlayer.java new file mode 100644 index 000000000..f2dfe77b4 --- /dev/null +++ b/src/org/infinity/resource/sound/SingleAudioPlayer.java @@ -0,0 +1,404 @@ +// Near Infinity - An Infinity Engine Browser and Editor +// Copyright (C) 2001 Jon Olav Hauglid +// See LICENSE.txt for license information + +package org.infinity.resource.sound; + +import java.beans.PropertyChangeEvent; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Objects; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.Clip; +import javax.sound.sampled.DataLine; +import javax.sound.sampled.Line; +import javax.sound.sampled.LineEvent; +import javax.sound.sampled.LineListener; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.UnsupportedAudioFileException; +import javax.swing.SwingUtilities; +import javax.swing.event.EventListenerList; + +import org.tinylog.Logger; + +/** + * A class for providing smooth and responsive playback of a single sound clip. + *

+ * None of the methods block execution. Playback state changes are propagated through {@link PropertyChangeEvent}s. + *

+ */ + public class SingleAudioPlayer implements AutoCloseable, LineListener { + private final EventListenerList listenerList = new EventListenerList(); + + private final AudioBuffer audioBuffer; + private final Clip audioClip; + + private AudioInputStream audioStream; + + /** Indicates whether the audio player has been closed. */ + private boolean closed; + /** Indicates whether playback of the audio player is active. Pausing playback doesn't affect this flag. */ + private boolean playing; + /** Indicates whether playback is currently paused. Changing the paused state doesn't affect playback activity. */ + private boolean paused; + /** Indicates whether playback is looped. */ + private boolean looped; + private boolean listenersEnabled; + + public SingleAudioPlayer(AudioBuffer audioBuffer) throws Exception { + this(audioBuffer, null); + } + + /** + * Creates a new audio player and initializes it with the specified audio buffer. + * + * @param audioBuffer {@link AudioBuffer} to load into the audio player. + * @throws NullPointerException if {@code audioBuffer} is {@code null}. + * @throws IOException if an I/O error occurs. + * @throws UnsupportedAudioFileException if the audio data is incompatible with the player. + * @throws LineUnavailableException if the audio line is not available due to resource restrictions. + * @throws IllegalArgumentException if the audio data is invalid. + */ + public SingleAudioPlayer(AudioBuffer audioBuffer, AudioStateListener listener) throws Exception { + this.audioBuffer = Objects.requireNonNull(audioBuffer); + addAudioStateListener(listener); + audioStream = AudioSystem.getAudioInputStream(new ByteArrayInputStream(this.audioBuffer.getAudioData())); + final AudioFormat audioFormat = audioStream.getFormat(); + final DataLine.Info audioInfo = new DataLine.Info(Clip.class, audioFormat); + audioClip = (Clip) AudioSystem.getLine(audioInfo); + setLineListenersEnabled(true); + audioClip.open(audioStream); + audioClip.setLoopPoints(0, -1); + } + + /** Returns the total length of the audio clip, in milliseconds. */ + public long getTotalLength() { + if (isClosed()) { + return 0L; + } + return audioClip.getMicrosecondLength() / 1_000L; + } + + /** Returns the elapsed playback time of the current clip, in milliseconds. */ + public long getElapsedTime() { + if (isClosed()) { + return 0L; + } + long position = audioClip.getMicrosecondPosition() % audioClip.getMicrosecondLength(); + return position / 1_000L; + } + + /** + * Sets an explicit playback position. + * + * @param position New playback position in milliseconds. Position is clamped to the available audio clip duration. + */ + public void setSoundPosition(long position) { + if (isClosed()) { + return; + } + + position = Math.max(0L, Math.min(audioClip.getMicrosecondLength(), position * 1_000L)); + + try { + setLineListenersEnabled(false); + final boolean isPlaying = isPlaying() && !isPaused(); + if (isPlaying) { + audioClip.stop(); + audioClip.flush(); + } + audioClip.setMicrosecondPosition(position); + if (isPlaying) { + audioClip.start(); + } + } finally { + setLineListenersEnabled(true); + } + } + + /** Returns whether loop mode is enabled. */ + public boolean isLooped() { + return looped; + } + + /** + * Enables or disable looped playback. + * + * @param loop Indicates whether to loop playback. + */ + public void setLooped(boolean loop) { + if (isClosed()) { + return; + } + + if (isLooped() == loop) { + return; + } + + looped = loop; + setLooped(); + } + + /** Use internally after each call {@link Clip#start()} to set up looping mode. */ + private void setLooped() { + if (isClosed()) { + return; + } + if (isPlaying()) { + audioClip.loop(isLooped() ? Clip.LOOP_CONTINUOUSLY : 0); + } + } + + /** Returns {@code true} if playback is active. Pausing and resuming playback does not affect the result. */ + public boolean isPlaying() { + return !isClosed() && playing; + } + + /** + * Starts playback of the associated audio data. Does nothing if the player is closed or already playing. + * Triggers a {@link PropertyChangeEvent} with the name {@link #PROPERTY_NAME_START}. + */ + public void play() { + if (isClosed() || isPlaying()) { + return; + } + + synchronized (audioClip) { + if (audioClip.isRunning()) { + audioClip.stop(); + audioClip.flush(); + } + playing = true; + paused = false; + audioClip.setFramePosition(0); + audioClip.start(); + setLooped(); + } + } + + /** + * Stops active playback and sets position to the start of the clip. Does nothing if the player is closed or has + * stopped playback. Triggers a {@link PropertyChangeEvent} with the name {@link #PROPERTY_NAME_STOP}. + */ + public void stop() { + if (isClosed() || !isPlaying()) { + return; + } + + synchronized (audioClip) { + final boolean isPaused = isPaused(); + playing = false; + paused = false; + if (isPaused) { + // Pause mode is technically "stop" mode, so we need to trigger a "STOP" event manually + update(new LineEvent(audioClip, LineEvent.Type.STOP, audioClip.getLongFramePosition())); + } else { + audioClip.stop(); + } + audioClip.flush(); + audioClip.setFramePosition(0); + } + } + + /** + * Returns {@code true} if playback is paused. Enabling or disabled the paused state does not affect playback + * activity. + */ + public boolean isPaused() { + return isPlaying() && paused; + } + + /** + * Pauses active playback. Does nothing if the player is closed, playback is not active, or already in the paused + * state. Triggers a {@link PropertyChangeEvent} with the name {@link #PROPERTY_NAME_PAUSE}. + */ + public void pause() { + if (isClosed()) { + return; + } + + synchronized (audioClip) { + if (isPlaying() && !isPaused()) { + paused = true; + audioClip.stop(); + firePlaybackPaused(); + } + } + } + + /** + * Resumes previously paused playback. Does nothing if the player is closed, playback is not active, or not in the + * paused state. Triggers a {@link PropertyChangeEvent} with the name {@link #PROPERTY_NAME_RESUME}. + */ + public void resume() { + if (isClosed()) { + return; + } + + synchronized (audioClip) { + if (isPlaying() && isPaused()) { + paused = false; + audioClip.start(); + setLooped(); + firePlaybackResumed(); + } + } + } + + /** Returns whether the player has been closed. A closed player does not accept any playback commands. */ + public boolean isClosed() { + return closed; + } + + /** + * Closes the player and releases any resources. Playback cannot be used anymore after calling this method. + */ + @Override + public void close() { + if (isClosed()) { + return; + } + + closed = true; + playing = false; + paused = false; + synchronized (audioClip) { + if (audioClip.isRunning()) { + audioClip.stop(); + } + audioClip.flush(); + audioClip.close(); + } + try { + audioStream.close(); + } catch (IOException e) { + Logger.warn(e); + } + audioStream = null; + + // removing listeners + final AudioStateListener[] items = getAudioStateListeners(); + for (int i = items.length - 1; i >= 0; i--) { + removeAudioStateListener(items[i]); + } + } + + @Override + public void update(LineEvent event) { + if (event.getType() == LineEvent.Type.START) { + firePlaybackStarted(); + } else if (event.getType() == LineEvent.Type.STOP) { + playing = paused; // override if paused to keep state consistent + firePlaybackStopped(); + } else if (event.getType() == LineEvent.Type.OPEN) { + firePlayerOpened(); + } else if (event.getType() == LineEvent.Type.CLOSE) { + firePlayerClosed(); + } + } + + /** Adds a {@link AudioStateListener} to the audio player instance. */ + public void addAudioStateListener(AudioStateListener l) { + if (l != null) { + listenerList.add(AudioStateListener.class, l); + } + } + + /** Returns all registered {@link AudioStateListener}s for this audio player instance. */ + public AudioStateListener[] getAudioStateListeners() { + return listenerList.getListeners(AudioStateListener.class); + } + + /** Removes a {@link AudioStateListener} from the audio player instance. */ + public void removeAudioStateListener(AudioStateListener l) { + if (l != null) { + listenerList.remove(AudioStateListener.class, l); + } + } + + /** Returns whether {@link Line}'s status changes are tracked by this class instance. */ + @SuppressWarnings("unused") + private boolean isLineListenersEnabled() { + return listenersEnabled; + } + + /** Specifies whether {@link Line}'s status changes should be tracked by this class instance. */ + private void setLineListenersEnabled(boolean enable) { + if (enable != listenersEnabled) { + if (enable) { + audioClip.addLineListener(this); + } else { + audioClip.removeLineListener(this); + } + listenersEnabled = enable; + } + } + + /** Fires a {@link AudioStateListener} of the specified name to all registered listeners. */ + private void fireAudioStateEvent(AudioStateEvent.State state, Object value) { + // collect and execute + synchronized (listenerList) { + final AudioStateListener[] listeners = listenerList.getListeners(AudioStateListener.class); + if (listeners.length > 0) { + final AudioStateEvent event = new AudioStateEvent(this, state, value); + SwingUtilities.invokeLater(() -> { + for (int i = 0; i < listeners.length; i++) + listeners[i].audioStateChanged(event); + }); + } + } +// synchronized (listeners) { +// listeners.clear(); +// final Object[] entries = listenerList.getListenerList(); +// AudioStateEvent evt = null; +// for (int i = entries.length - 2; i >= 0; i -= 2) { +// if (entries[i] == AudioStateListener.class) { +// // event object is lazily created +// if (evt == null) { +// evt = new AudioStateEvent(this, state, value); +// } +// listeners.add((AudioStateListener) entries[i + 1]); +// } +// } +// +// if (!listeners.isEmpty()) { +// final AudioStateEvent event = evt; +// SwingUtilities.invokeLater(() -> listeners.forEach(l -> l.audioStateChanged(event))); +//// Threading.invokeInEventThread(e -> listeners.forEach(l -> l.audioStateChanged(e)), evt); +// } +// } + } + + /** Fires when the current sound clip is released. */ + private void firePlayerOpened() { + fireAudioStateEvent(AudioStateEvent.State.OPEN, null); + } + + /** Fires when the current sound clip is released. */ + private void firePlayerClosed() { + fireAudioStateEvent(AudioStateEvent.State.CLOSE, null); + } + + /** Fires when playback is started. */ + private void firePlaybackStarted() { + fireAudioStateEvent(AudioStateEvent.State.START, null); + } + + /** Fires when playback is stopped. */ + private void firePlaybackStopped() { + fireAudioStateEvent(AudioStateEvent.State.STOP, null); + } + + /** Fires when playback is paused. */ + private void firePlaybackPaused() { + fireAudioStateEvent(AudioStateEvent.State.PAUSE, Long.valueOf(getElapsedTime())); + } + + /** Fires when paused playback is resumed. */ + private void firePlaybackResumed() { + fireAudioStateEvent(AudioStateEvent.State.RESUME, Long.valueOf(getElapsedTime())); + } + } \ No newline at end of file diff --git a/src/org/infinity/resource/sound/SoundResource.java b/src/org/infinity/resource/sound/SoundResource.java index 313bd60dd..be5b41a9e 100644 --- a/src/org/infinity/resource/sound/SoundResource.java +++ b/src/org/infinity/resource/sound/SoundResource.java @@ -14,28 +14,20 @@ import java.awt.event.ActionListener; import java.awt.event.ItemEvent; import java.awt.event.ItemListener; -import java.beans.PropertyChangeEvent; -import java.beans.PropertyChangeListener; import java.nio.ByteBuffer; -import java.util.HashMap; import java.util.Locale; -import java.util.Timer; -import java.util.TimerTask; import javax.swing.BorderFactory; -import javax.swing.Icon; import javax.swing.JButton; import javax.swing.JComponent; -import javax.swing.JLabel; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; -import javax.swing.SwingWorker; -import javax.swing.event.EventListenerList; import org.infinity.NearInfinity; import org.infinity.gui.ButtonPanel; import org.infinity.gui.ButtonPopupMenu; +import org.infinity.gui.SoundPanel; import org.infinity.gui.ViewerUtil; import org.infinity.icon.Icons; import org.infinity.resource.Closeable; @@ -51,67 +43,27 @@ /** * Handles all kinds of supported single track audio files. */ -public class SoundResource implements Resource, ActionListener, ItemListener, Closeable, Referenceable, Runnable { - /** Formatted string with 4 placeholders: elapsed minute, elapsed second, total minutes, total seconds */ - private static final String FMT_PLAY_TIME = "%02d:%02d / %02d:%02d"; - +public class SoundResource implements Resource, ActionListener, ItemListener, Closeable, Referenceable { private static final ButtonPanel.Control PROPERTIES = ButtonPanel.Control.CUSTOM_1; - /** Provides quick access to the "play" and "pause" image icon. */ - private static final HashMap PLAY_ICONS = new HashMap<>(); - - /** - * Property name for {@link PropertyChangeEvent} calls. - * Associated parameter is of type {@link Boolean} and indicates the playback state. - */ - public static final String PROPERTY_NAME_PLAYBACK = "playback"; - - static { - PLAY_ICONS.put(true, Icons.ICON_PLAY_16.getIcon()); - PLAY_ICONS.put(false, Icons.ICON_PAUSE_16.getIcon()); - } - - private final EventListenerList listenerList = new EventListenerList(); - private final ResourceEntry entry; private final ButtonPanel buttonPanel = new ButtonPanel(); + private final SoundPanel soundPanel = new SoundPanel(SoundPanel.Option.TIME_LABEL, SoundPanel.Option.PROGRESS_BAR, + SoundPanel.Option.PROGRESS_BAR_LABELS, SoundPanel.Option.LOOP_CHECKBOX); - private AudioPlayer player; - private AudioBuffer audioBuffer = null; - private JButton bPlay; - private JButton bStop; - private JLabel lTime; private JMenuItem miExport; private JMenuItem miConvert; private JPanel panel; - private boolean isWAV; - private boolean isReference; - private boolean isClosed; public SoundResource(ResourceEntry entry) throws Exception { this.entry = entry; - player = new AudioPlayer(); - isWAV = false; - isReference = (entry.getExtension().equalsIgnoreCase("WAV")); - isClosed = false; } // --------------------- Begin Interface ActionListener --------------------- @Override public void actionPerformed(ActionEvent event) { - if (event.getSource() == bPlay) { - if (player == null || !player.isRunning()) { - new Thread(this).start(); - } else if (player.isRunning()) { - bPlay.setIcon(PLAY_ICONS.get(!player.isPaused())); - player.setPaused(!player.isPaused()); - } - } else if (event.getSource() == bStop) { - bStop.setEnabled(false); - player.stopPlay(); - bPlay.setIcon(PLAY_ICONS.get(true)); - } else if (buttonPanel.getControlByType(ButtonPanel.Control.FIND_REFERENCES) == event.getSource()) { + if (buttonPanel.getControlByType(ButtonPanel.Control.FIND_REFERENCES) == event.getSource()) { searchReferences(panel.getTopLevelAncestor()); } else if (buttonPanel.getControlByType(PROPERTIES) == event.getSource()) { showProperties(); @@ -130,7 +82,7 @@ public void itemStateChanged(ItemEvent event) { ResourceFactory.exportResource(entry, panel.getTopLevelAncestor()); } else if (bpmExport.getSelectedItem() == miConvert) { final String fileName = StreamUtils.replaceFileExtension(entry.getResourceName(), "WAV"); - ByteBuffer buffer = StreamUtils.getByteBuffer(audioBuffer.getAudioData()); + ByteBuffer buffer = StreamUtils.getByteBuffer(soundPanel.getAudioBuffer().getAudioData()); ResourceFactory.exportResource(entry, buffer, fileName, panel.getTopLevelAncestor()); } } @@ -142,13 +94,7 @@ public void itemStateChanged(ItemEvent event) { @Override public void close() throws Exception { - setClosed(true); - if (player != null) { - player.stopPlay(); - player = null; - } - audioBuffer = null; - panel = null; + soundPanel.close(); } // --------------------- End Interface Closeable --------------------- @@ -166,7 +112,7 @@ public ResourceEntry getResourceEntry() { @Override public boolean isReferenceable() { - return isReference; + return entry.getExtension().equalsIgnoreCase("WAV"); } @Override @@ -176,75 +122,19 @@ public void searchReferences(Component parent) { // --------------------- End Interface Referenceable --------------------- - // --------------------- Begin Interface Runnable --------------------- - - @Override - public void run() { - firePlaybackStarted(); - try { - if (bPlay != null) { - bPlay.setIcon(PLAY_ICONS.get(false)); - bStop.setEnabled(true); - } - if (audioBuffer != null) { - final TimerElapsedTask timerTask = new TimerElapsedTask(250L); - try { - timerTask.start(); - player.play(audioBuffer); - } catch (Exception e) { - JOptionPane.showMessageDialog(panel, "Error during playback", "Error", JOptionPane.ERROR_MESSAGE); - Logger.error(e); - } - player.stopPlay(); - timerTask.stop(); - } - if (bPlay != null) { - bStop.setEnabled(false); - bPlay.setIcon(PLAY_ICONS.get(true)); - } - } finally { - firePlaybackStopped(); - } - } - - // --------------------- End Interface Runnable --------------------- - // --------------------- Begin Interface Viewable --------------------- @Override public JComponent makeViewer(ViewableContainer container) { - JPanel controlPanel = new JPanel(new GridBagLayout()); - - bPlay = new JButton(PLAY_ICONS.get(true)); - bPlay.addActionListener(this); - GridBagConstraints c = new GridBagConstraints(); - c = ViewerUtil.setGBC(c, 0, 0, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL, - new Insets(4, 8, 4, 0), 0, 0); - controlPanel.add(bPlay, c); - - bStop = new JButton(Icons.ICON_STOP_16.getIcon()); - bStop.addActionListener(this); - bStop.setEnabled(false); - c = ViewerUtil.setGBC(c, 1, 0, 1, 1, 0.0, 0.0, GridBagConstraints.LINE_START, GridBagConstraints.REMAINDER, - new Insets(4, 8, 4, 8), 0, 0); - controlPanel.add(bStop, c); - - lTime = new JLabel(String.format(FMT_PLAY_TIME, 0, 0, 0, 0)); - c = ViewerUtil.setGBC(c, 0, 1, 2, 1, 0.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.REMAINDER, - new Insets(4, 8, 4, 8), 0, 0); - controlPanel.add(lTime, c); - - JPanel centerPanel = new JPanel(new BorderLayout()); - centerPanel.add(controlPanel, BorderLayout.CENTER); - - if (isReference) { + if (isReferenceable()) { // only available for WAV resource types ((JButton) buttonPanel.addControl(ButtonPanel.Control.FIND_REFERENCES)).addActionListener(this); } + soundPanel.setDisplayFormat(SoundPanel.DisplayFormat.ELAPSED_TOTAL_PRECISE); miExport = new JMenuItem("original"); miConvert = new JMenuItem("as WAV"); - miConvert.setEnabled(!isWAV); + miConvert.setEnabled(!soundPanel.isWavFile()); ButtonPopupMenu bpmExport = (ButtonPopupMenu) buttonPanel.addControl(ButtonPanel.Control.EXPORT_MENU); bpmExport.setMenuItems(new JMenuItem[] { miExport, miConvert }); bpmExport.addItemListener(this); @@ -254,87 +144,23 @@ public JComponent makeViewer(ViewableContainer container) { bProperties.addActionListener(this); buttonPanel.addControl(bProperties, PROPERTIES); + // wrapper panel prevents the sound panel from auto-scaling + final JPanel soundPanelWrapper = new JPanel(new GridBagLayout()); + final GridBagConstraints gbc = new GridBagConstraints(); + ViewerUtil.setGBC(gbc, 0, 0, 1, 1, 0.0, 0.0, GridBagConstraints.CENTER, GridBagConstraints.NONE, + new Insets(16, 16, 16, 16), 0, 0); + soundPanelWrapper.add(soundPanel, gbc); + panel = new JPanel(new BorderLayout()); - panel.add(centerPanel, BorderLayout.CENTER); + panel.add(soundPanelWrapper, BorderLayout.CENTER); panel.add(buttonPanel, BorderLayout.SOUTH); - centerPanel.setBorder(BorderFactory.createLoweredBevelBorder()); + soundPanelWrapper.setBorder(BorderFactory.createLoweredBevelBorder()); loadSoundResource(); return panel; } - /** Adds a {@link PropertyChangeListener} to the sound resource. */ - public void addPropertyChangeListener(PropertyChangeListener l) { - if (l != null) { - listenerList.add(PropertyChangeListener.class, l); - } - } - - /** Returns all registered {@link PropertyChangeListener}s for this sound resource. */ - public PropertyChangeListener[] getPropertyChangeListeners() { - return listenerList.getListeners(PropertyChangeListener.class); - } - - /** Removes a {@link PropertyChangeListener} from the sound resource. */ - public void removePropertyChangeListener(PropertyChangeListener l) { - if (l != null) { - listenerList.remove(PropertyChangeListener.class, l); - } - } - - private void firePropertyChangePerformed(String name, Object oldValue, Object newValue) { - if (name != null) { - Object[] listeners = listenerList.getListenerList(); - PropertyChangeEvent e = null; - for (int i = listeners.length - 2; i >= 0; i -= 2) { - if (listeners[i] == PropertyChangeListener.class) { - // Event object is lazily created - if (e == null) { - e = new PropertyChangeEvent(this, name, oldValue, newValue); - } - ((PropertyChangeListener)listeners[i + 1]).propertyChange(e); - } - } - } - } - - private void firePlaybackStarted() { - firePropertyChangePerformed(PROPERTY_NAME_PLAYBACK, Boolean.FALSE, Boolean.TRUE); - } - - private void firePlaybackStopped() { - firePropertyChangePerformed(PROPERTY_NAME_PLAYBACK, Boolean.TRUE, Boolean.FALSE); - } - - // Updates the time label with total duration and specified elapsed time (in milliseconds). - private synchronized void updateTimeLabel(long elapsed) { - long duration = (audioBuffer != null) ? audioBuffer.getDuration() : 0L; - long em = elapsed / 1000 / 60; - long es = (elapsed / 1000) - (em * 60); - long dm = duration / 1000 / 60; - long ds = (duration / 1000) - (dm * 60); - lTime.setText(String.format(FMT_PLAY_TIME, em, es, dm, ds)); - } - - /** - * Returns a formatted representation of the total duration of the sound clip. - * - * @param exact Whether the seconds part should contain the fractional amount. - * @return A formatted string representing the sound clip duration. - */ - private String getTotalDurationString(boolean exact) { - long duration = (audioBuffer != null) ? audioBuffer.getDuration() : 0L; - long m = duration / 1000 / 60; - if (exact) { - double s = (duration / 1000.0) - (m * 60); - return String.format("%02d:%06.3f", m, s); - } else { - long s = (duration / 1000) - (m * 60); - return String.format("%02d:%02d", m, s); - } - } - // Returns the top level container associated with this viewer private Container getContainer() { if (panel != null) { @@ -346,74 +172,34 @@ private Container getContainer() { private void loadSoundResource() { setLoaded(false); - (new SwingWorker() { - @Override - public Boolean doInBackground() { - return loadAudio(); - } - }).execute(); + try { + soundPanel.loadSound(getResourceEntry(), this::setLoaded); + } catch (Exception e) { + Logger.error(e); + JOptionPane.showMessageDialog(getContainer(), e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + } } private synchronized void setLoaded(boolean b) { - if (bPlay != null) { - bPlay.setEnabled(b); - bPlay.setIcon(PLAY_ICONS.get(true)); - - updateTimeLabel(0); + if (miConvert != null) { miConvert.setEnabled(b); buttonPanel.getControlByType(PROPERTIES).setEnabled(true); } } - private synchronized void setClosed(boolean b) { - if (b != isClosed) { - isClosed = b; - } - } - - private synchronized boolean isClosed() { - return isClosed; - } - - private boolean loadAudio() { - try { - AudioBuffer.AudioOverride override = null; - AudioBuffer buffer = null; - synchronized (entry) { - // ignore # channels in ACM headers - if (entry.getExtension().equalsIgnoreCase("ACM")) { - override = AudioBuffer.AudioOverride.overrideChannels(2); - } - buffer = AudioFactory.getAudioBuffer(entry, override); - } - if (buffer != null && !isClosed()) { - synchronized (this) { - audioBuffer = buffer; - isWAV = (audioBuffer instanceof WavBuffer); - isReference = (entry.getExtension().compareToIgnoreCase("WAV") == 0); - } - setLoaded(true); - return true; - } - } catch (Exception e) { - Logger.error(e); - JOptionPane.showMessageDialog(getContainer(), e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); - } - return false; - } - /** Shows a message dialog with basic properties of the current sound resource. */ private void showProperties() { - if (audioBuffer == null) { + if (soundPanel.getAudioBuffer() == null) { return; } + final AudioBuffer audioBuffer = soundPanel.getAudioBuffer(); final String resName = entry.getResourceName().toUpperCase(Locale.ENGLISH); String format; int rate; int channels; String channelsDesc; - String duration = getTotalDurationString(true); + String duration = SoundPanel.DisplayFormat.ELAPSED_PRECISE.toString(audioBuffer.getDuration(), 0L); final String extra; if (audioBuffer instanceof OggBuffer) { format = "Ogg Vorbis"; @@ -465,92 +251,4 @@ private void showProperties() { } // --------------------- End Interface Viewable --------------------- - - // -------------------------- INNER CLASSES -------------------------- - - private class TimerElapsedTask extends TimerTask { - private final long delay; - - private Timer timer; - private boolean paused; - - /** Initializes a new timer task with the given delay, in milliseconds. */ - public TimerElapsedTask(long delay) { - this.delay = Math.max(1L, delay); - this.timer = null; - this.paused = false; - } - - /** - * Starts a new scheduled run. - */ - public void start() { - if (timer == null) { - timer = new Timer(); - timer.schedule(this, 0L, delay); - } - } - -// /** Returns whether a task has been initialized via {@link #start()}. */ -// public boolean isRunning() { -// return (timer != null); -// } - -// /** Pauses or unpauses a scheduled run. */ -// public void setPaused(boolean paused) { -// this.paused = paused; -// } - -// /** Returns whether a scheduled run is in paused state. */ -// public boolean isPaused() { -// return paused; -// } - - /** Stops a scheduled run. */ - public void stop() { - if (timer != null) { - timer.cancel(); - timer = null; - paused = false; - if (bPlay != null) { - updateTimeLabel(0L); - } - } - } - - @Override - public void run() { - if (!paused && timer != null && player != null && player.getDataLine() != null && bPlay != null) { - updateTimeLabel(player.getDataLine().getMicrosecondPosition() / 1000L); - } - } - } - - /** - * Start playback of the current sound resource. - * - * @param listener An optional {@link PropertyChangeListener} instance that is notified when playback starts or ends. - */ - public void playSound(PropertyChangeListener listener) { - if (listener != null) { - addPropertyChangeListener(listener); - } - loadAudio(); - new Thread(this).start(); - } - - /** - * Stops playback of the current sound resource. - * - * @param listener An optional {@link PropertyChangeListener} instance that is removed from the queue after playback - * has stopped. - */ - public void stopSound(PropertyChangeListener listener) { - if (player != null) { - player.stopPlay(); - } - if (listener != null) { - removePropertyChangeListener(listener); - } - } }