diff --git a/src/main/java/com/codedead/opal/controller/MainWindowController.java b/src/main/java/com/codedead/opal/controller/MainWindowController.java index 4f4f547..eab2f25 100644 --- a/src/main/java/com/codedead/opal/controller/MainWindowController.java +++ b/src/main/java/com/codedead/opal/controller/MainWindowController.java @@ -39,6 +39,7 @@ import java.nio.file.Path; import java.security.GeneralSecurityException; import java.util.*; +import java.util.concurrent.TimeUnit; import static com.codedead.opal.utils.SharedVariables.DEFAULT_LOCALE; @@ -47,6 +48,8 @@ public final class MainWindowController implements IAudioTimer, TrayIconListener @FXML private SoundPane snpFastMetronome; @FXML + private SoundPane snpFastestMetronome; + @FXML private SoundPane snpSlowMetronome; @FXML private SoundPane snpTrain; @@ -354,6 +357,7 @@ public void setControllers(final SettingsController settingsController, final Up snpTrain.getNameLabel().textProperty().bind(resourceFactory.getStringBinding("Train")); snpSlowMetronome.getNameLabel().textProperty().bind(resourceFactory.getStringBinding("SlowMetronome")); snpFastMetronome.getNameLabel().textProperty().bind(resourceFactory.getStringBinding("FastMetronome")); + snpFastestMetronome.getNameLabel().textProperty().bind(resourceFactory.getStringBinding("FastestMetronome")); } /** @@ -477,6 +481,18 @@ private List getAllSoundPanes() { return elements; } + /** + * Get all {@link SoundPane} objects, including custom sounds + * + * @return The {@link List} of all {@link SoundPane} objects + */ + private List getAllSoundPanesIncludingCustom() { + final List elements = new ArrayList<>(getAllSoundPanes()); + elements.addAll(getSoundPanes(grpCustom)); + + return elements; + } + /** * Get all {@link SoundPane} objects from a {@link GridPane} object * @@ -828,13 +844,17 @@ private void saveSoundPresetAction() { @FXML private void playPauseAction() { logger.info("Play / pause all media"); - try { - for (final SoundPane soundPane : getAllSoundPanes()) { - soundPane.playPause(); - } - for (final SoundPane soundPane : getSoundPanes(grpCustom)) { - soundPane.playPause(); + final List soundPanes = getAllSoundPanesIncludingCustom(); + final boolean pauseAll = soundPanes.stream().anyMatch(SoundPane::isPlaying); + + try { + for (final SoundPane soundPane : soundPanes) { + if (pauseAll) { + soundPane.pause(); + } else if (soundPane.getSlider().getValue() > 0) { + soundPane.play(); + } } } catch (final MediaPlayerException ex) { logger.error("Unable to play / pause MediaPlayer", ex); @@ -1145,18 +1165,18 @@ public void run() { }; countDownTask = new TimerTask() { - final long seconds = delay / 1000; - int i = 0; + private final long startNanos = System.nanoTime(); @Override public void run() { - i++; - long timeLeft = (seconds - (i % seconds)); + final long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + final long remainingMillis = Math.max(0, delay - elapsedMillis); + final long timeLeft = remainingMillis / 1000; // Calculate hours, minutes and seconds - long hours = timeLeft / 3600; - long minutes = (timeLeft % 3600) / 60; - long seconds = timeLeft % 60; + final long hours = timeLeft / 3600; + final long minutes = (timeLeft % 3600) / 60; + final long seconds = timeLeft % 60; // Format the values to HH:MM:SS with leading zeros if necessary final String timeLeftFormatted = String.format("%02d:%02d:%02d", hours, minutes, seconds); diff --git a/src/main/java/com/codedead/opal/controller/SettingsController.java b/src/main/java/com/codedead/opal/controller/SettingsController.java index cd1da93..1d0f352 100644 --- a/src/main/java/com/codedead/opal/controller/SettingsController.java +++ b/src/main/java/com/codedead/opal/controller/SettingsController.java @@ -141,10 +141,25 @@ public void setProperties(final Properties properties) { * @throws IOException When the Properties object could not be stored */ public void saveProperties() throws IOException { + saveProperties(properties); + } + + /** + * Save the provided properties and replace the in-memory properties once the write succeeds + * + * @param newProperties The properties that should be stored + * @throws IOException When the Properties object could not be stored + */ + public void saveProperties(final Properties newProperties) throws IOException { + if (newProperties == null) + throw new NullPointerException("Properties cannot be null!"); + logger.info("Storing the Properties object"); try (final FileOutputStream fos = new FileOutputStream(getPropertiesFileLocation())) { - properties.store(fos, null); + newProperties.store(fos, null); } + + properties = newProperties; } /** diff --git a/src/main/java/com/codedead/opal/controller/SettingsWindowController.java b/src/main/java/com/codedead/opal/controller/SettingsWindowController.java index dfc2e9f..89a2bab 100644 --- a/src/main/java/com/codedead/opal/controller/SettingsWindowController.java +++ b/src/main/java/com/codedead/opal/controller/SettingsWindowController.java @@ -25,9 +25,12 @@ import org.apache.logging.log4j.core.config.Configurator; import java.io.IOException; +import java.io.Serial; import java.security.GeneralSecurityException; import java.util.Locale; +import java.util.MissingResourceException; import java.util.Objects; +import java.util.Properties; import java.util.ResourceBundle; import java.util.concurrent.TimeUnit; @@ -268,79 +271,231 @@ private void resetSettingsAction() { private void saveSettingsAction() { logger.info("Attempting to save all settings"); + final SettingsDraft draft; + final ResourceBundle newTranslationBundle; + try { + draft = readDraft(); + final Locale locale = LanguageController.getLocaleFromLanguageTag(draft.localeTag()); + newTranslationBundle = ResourceBundle.getBundle("translations.OpalApplication", locale); + } catch (final SettingsValidationException ex) { + logger.warn("Settings validation failed: {}", ex.getMessageKey()); + restoreSavedTheme(); + showSaveError(translationBundle.getString(ex.getMessageKey()), ex.getCause()); + return; + } catch (final MissingResourceException ex) { + restoreSavedTheme(); + showSaveError(ex.toString(), ex); + return; + } + + try { + settingsController.saveProperties(draft.toProperties(settingsController.getProperties())); + } catch (final IOException ex) { + restoreSavedTheme(); + showSaveError(ex.toString(), ex); + return; + } + + translationBundle = newTranslationBundle; + applySettings(draft, newTranslationBundle); + + final Stage stage = (Stage) sldAudioBalance.getScene().getWindow(); + stage.close(); + } + + /** + * Read and validate all settings without changing any application state + * + * @return The validated settings + * @throws SettingsValidationException When one of the settings is invalid + */ + private SettingsDraft readDraft() throws SettingsValidationException { final boolean updateProxyEnabled = chbUpdateProxy.isSelected(); - final String updateProxyHost = txtProxyHost.getText() == null ? "" : txtProxyHost.getText().trim(); - final String updateProxyPortValue = numProxyPort.getText() == null ? "" : numProxyPort.getText().trim(); + final String updateProxyHost = valueOrEmpty(txtProxyHost.getText()).trim(); + final String updateProxyPortValue = valueOrEmpty(numProxyPort.getText()).trim(); final boolean updateProxyAuthEnabled = chbProxyAuthentication.isSelected(); - final String updateProxyUsername = txtProxyUsername.getText() == null ? "" : txtProxyUsername.getText().trim(); - final String updateProxyPassword = pfProxyPassword.getText() == null ? "" : pfProxyPassword.getText(); - int updateProxyPort = 8080; + final String updateProxyUsername = valueOrEmpty(txtProxyUsername.getText()).trim(); + final String updateProxyPassword = valueOrEmpty(pfProxyPassword.getText()); - if (!updateProxyPortValue.isBlank()) { - try { - updateProxyPort = Integer.parseInt(updateProxyPortValue); - } catch (final NumberFormatException ex) { - if (updateProxyEnabled) { - logger.warn("Invalid proxy port: {}", updateProxyPortValue); - FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), translationBundle.getString("InvalidProxySettings"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); - return; - } - } + final int updateProxyPort = parseProxyPort(updateProxyPortValue, updateProxyEnabled); + + if (updateProxyEnabled && updateProxyHost.isBlank()) { + throw new SettingsValidationException("InvalidProxySettings"); } - if (updateProxyEnabled && (updateProxyHost.isBlank() || updateProxyPort < 1 || updateProxyPort > 65535)) { - logger.warn("Invalid proxy settings: {}:{}", updateProxyHost, updateProxyPort); - FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), translationBundle.getString("InvalidProxySettings"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); - return; + if (updateProxyEnabled && updateProxyAuthEnabled + && (updateProxyUsername.isBlank() || updateProxyPassword.isEmpty())) { + throw new SettingsValidationException("InvalidProxyAuthentication"); } - if (updateProxyEnabled && updateProxyAuthEnabled && (updateProxyUsername.isBlank() || updateProxyPassword.isEmpty())) { - logger.warn("Invalid proxy authentication settings"); - FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), translationBundle.getString("InvalidProxyAuthentication"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); - return; + final long delay = parseTimerDelay(numDelay.getText()); + final int delayType = cboDelayType.getSelectionModel().getSelectedIndex(); + + if (delayType < 0 || delayType > 2) { + throw new SettingsValidationException("InvalidTimerDelay"); + } + + final long timerDelay = switch (delayType) { + case 0 -> TimeUnit.SECONDS.toMillis(delay); + case 1 -> TimeUnit.MINUTES.toMillis(delay); + case 2 -> TimeUnit.HOURS.toMillis(delay); + default -> throw new SettingsValidationException("InvalidTimerDelay"); + }; + + final String languageTag = LanguageController.getLocaleFromLanguageIndex(cboLanguage.getSelectionModel().getSelectedIndex()); + final String theme = cboTheme.getSelectionModel().getSelectedItem(); + final String logLevel = cboLogLevel.getValue(); + + if (theme == null || logLevel == null) { + throw new SettingsValidationException("SaveSettingsError"); + } + + final double audioBalance = sldAudioBalance.getValue(); + if (!Double.isFinite(audioBalance) || audioBalance < -1.0 || audioBalance > 1.0) { + throw new SettingsValidationException("SaveSettingsError"); } final String storedProxyPassword; try { storedProxyPassword = passwordCipher.encrypt(updateProxyPassword); } catch (final IOException | GeneralSecurityException ex) { - logger.error("Unable to encrypt the proxy password", ex); - FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), ex.toString(), getClass().getResourceAsStream(SharedVariables.ICON_URL)); - return; + throw new SettingsValidationException("SaveSettingsError", ex); } - settingsController.getProperties().setProperty("autoUpdate", Boolean.toString(chbAutoUpdate.isSelected())); - settingsController.getProperties().setProperty("updateProxyEnabled", Boolean.toString(updateProxyEnabled)); - settingsController.getProperties().setProperty("updateProxyHost", updateProxyHost); - settingsController.getProperties().setProperty("updateProxyPort", String.valueOf(updateProxyPort)); - settingsController.getProperties().setProperty("updateProxyAuthEnabled", Boolean.toString(updateProxyAuthEnabled)); - settingsController.getProperties().setProperty("updateProxyUsername", updateProxyUsername); - settingsController.getProperties().setProperty("updateProxyPassword", storedProxyPassword); - settingsController.getProperties().setProperty("mediaButtons", Boolean.toString(chbMediaButtons.isSelected())); - settingsController.getProperties().setProperty("dragDrop", Boolean.toString(chbDragDrop.isSelected())); - settingsController.getProperties().setProperty("trayIcon", Boolean.toString(chbTrayIcon.isSelected())); + return new SettingsDraft( + chbAutoUpdate.isSelected(), + updateProxyEnabled, + updateProxyHost, + updateProxyPort, + updateProxyAuthEnabled, + updateProxyUsername, + storedProxyPassword, + chbMediaButtons.isSelected(), + chbDragDrop.isSelected(), + chbTrayIcon.isSelected(), + languageTag, + theme, + logLevel, + timerDelay, + delayType, + chbTimerApplicationShutdown.isSelected(), + chbTimerComputerShutdown.isSelected(), + audioBalance + ); + } - final String languageTag = LanguageController.getLocaleFromLanguageIndex(cboLanguage.getSelectionModel().getSelectedIndex()); - final Locale locale = LanguageController.getLocaleFromLanguageTag(languageTag); - final ResourceBundle newTranslationBundle = ResourceBundle.getBundle("translations.OpalApplication", locale); + /** + * Parse a proxy port + * + * @param value The value of the proxy port field + * @param proxyEnabled True if the proxy is enabled + * @return The proxy port, or the default port if the value is empty + * @throws SettingsValidationException When the port is invalid and the proxy is enabled + */ + private int parseProxyPort(final String value, final boolean proxyEnabled) throws SettingsValidationException { + if (value.isBlank()) + return 8080; + + try { + final int port = Integer.parseInt(value); + + if (proxyEnabled && (port < 1 || port > 65535)) { + throw new SettingsValidationException("InvalidProxySettings"); + } + + return port; + } catch (final NumberFormatException ex) { + if (proxyEnabled) { + throw new SettingsValidationException("InvalidProxySettings", ex); + } + + return 8080; + } + } + + /** + * Parse the timer delay + * + * @param value The value of the timer delay field + * @return The timer delay + * @throws SettingsValidationException When the delay is missing, invalid or smaller than 1 + */ + private long parseTimerDelay(final String value) throws SettingsValidationException { + if (value == null || value.isBlank()) { + throw new SettingsValidationException("InvalidTimerDelay"); + } + + try { + final long delay = Long.parseLong(value.trim()); + + if (delay < 1) { + throw new SettingsValidationException("TimerDelayTooSmall"); + } + + return delay; + } catch (final NumberFormatException ex) { + throw new SettingsValidationException("InvalidTimerDelay", ex); + } + } + + /** + * Display an error that occurred while saving settings + * + * @param message The message that should be displayed + * @param cause The cause of the error, if any + */ + private void showSaveError(final String message, final Throwable cause) { + if (cause != null) { + logger.error("Unable to save settings", cause); + } - this.translationBundle = newTranslationBundle; + FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), message, getClass().getResourceAsStream(SharedVariables.ICON_URL)); + } + + /** + * Restore the theme that is currently stored in the settings + */ + private void restoreSavedTheme() { + final String savedTheme = settingsController.getProperties().getProperty("theme", "light"); + final int savedThemeIndex = ThemeController.getThemeIndex(savedTheme); + + if (cboTheme.getSelectionModel().getSelectedIndex() != savedThemeIndex) { + cboTheme.getSelectionModel().select(savedThemeIndex); + } + + ThemeController.setTheme(savedTheme); + } + + /** + * Apply the validated settings to the running application + * + * @param draft The validated settings + * @param newTranslationBundle The translation bundle for the selected language + */ + private void applySettings(final SettingsDraft draft, final ResourceBundle newTranslationBundle) { mainWindowController.updateResourceBundle(newTranslationBundle); + mainWindowController.loadMediaButtonVisibility(draft.mediaButtons()); + mainWindowController.setAudioBalance(draft.audioBalance()); - settingsController.getProperties().setProperty("locale", languageTag); + ThemeController.setTheme(draft.theme()); + Configurator.setAllLevels(LogManager.getRootLogger().getName(), toLogLevel(draft.logLevel())); - mainWindowController.loadMediaButtonVisibility(chbMediaButtons.isSelected()); try { - mainWindowController.setTrayIconEnabled(chbTrayIcon.isSelected()); + mainWindowController.setTrayIconEnabled(draft.trayIcon()); } catch (final IOException ex) { logger.error("Unable to create tray icon", ex); FxUtils.showErrorAlert(newTranslationBundle.getString("TrayIconError"), ex.toString(), getClass().getResourceAsStream(SharedVariables.ICON_URL)); } + } - settingsController.getProperties().setProperty("theme", cboTheme.getSelectionModel().getSelectedItem()); - settingsController.getProperties().setProperty("loglevel", cboLogLevel.getValue()); - - final Level level = switch (cboLogLevel.getValue()) { + /** + * Convert a log level name to a {@link Level} object + * + * @param logLevel The name of the log level + * @return The {@link Level} object + */ + private static Level toLogLevel(final String logLevel) { + return switch (logLevel) { case "OFF" -> Level.OFF; case "FATAL" -> Level.FATAL; case "ERROR" -> Level.ERROR; @@ -350,39 +505,113 @@ private void saveSettingsAction() { case "ALL" -> Level.ALL; default -> Level.INFO; }; + } - final int delayType = cboDelayType.getSelectionModel().getSelectedIndex(); - final long delay = Long.parseLong(numDelay.getText()); + /** + * Get a string value or an empty string when the value is null + * + * @param value The value + * @return The value or an empty string + */ + private static String valueOrEmpty(final String value) { + return value == null ? "" : value; + } - if (delay < 1) { - FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), translationBundle.getString("TimerDelayTooSmall"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); - return; + /** + * A validated snapshot of all settings + */ + private record SettingsDraft( + boolean autoUpdate, + boolean updateProxyEnabled, + String updateProxyHost, + int updateProxyPort, + boolean updateProxyAuthEnabled, + String updateProxyUsername, + String encryptedProxyPassword, + boolean mediaButtons, + boolean dragDrop, + boolean trayIcon, + String localeTag, + String theme, + String logLevel, + long timerDelay, + int timerDelayType, + boolean timerApplicationShutdown, + boolean timerComputerShutdown, + double audioBalance + ) { + + /** + * Create a new {@link Properties} object based on the current settings and this draft + * + * @param base The current properties + * @return The new properties + */ + private Properties toProperties(final Properties base) { + final Properties properties = new Properties(); + properties.putAll(base); + + properties.setProperty("autoUpdate", Boolean.toString(autoUpdate)); + properties.setProperty("updateProxyEnabled", Boolean.toString(updateProxyEnabled)); + properties.setProperty("updateProxyHost", updateProxyHost); + properties.setProperty("updateProxyPort", Integer.toString(updateProxyPort)); + properties.setProperty("updateProxyAuthEnabled", Boolean.toString(updateProxyAuthEnabled)); + properties.setProperty("updateProxyUsername", updateProxyUsername); + properties.setProperty("updateProxyPassword", encryptedProxyPassword); + properties.setProperty("mediaButtons", Boolean.toString(mediaButtons)); + properties.setProperty("dragDrop", Boolean.toString(dragDrop)); + properties.setProperty("trayIcon", Boolean.toString(trayIcon)); + properties.setProperty("locale", localeTag); + properties.setProperty("theme", theme); + properties.setProperty("loglevel", logLevel); + properties.setProperty("timerDelay", Long.toString(timerDelay)); + properties.setProperty("timerDelayType", Integer.toString(timerDelayType)); + properties.setProperty("timerApplicationShutdown", Boolean.toString(timerApplicationShutdown)); + properties.setProperty("timerComputerShutdown", Boolean.toString(timerComputerShutdown)); + properties.setProperty("audioBalance", Double.toString(audioBalance)); + + return properties; } + } - final long correctDelay = switch (delayType) { - case 0 -> TimeUnit.SECONDS.toMillis(delay); - case 1 -> TimeUnit.MINUTES.toMillis(delay); - case 2 -> TimeUnit.HOURS.toMillis(delay); - default -> throw new IllegalStateException("Unexpected value: " + delayType); - }; + /** + * Exception that is thrown when a setting is invalid + */ + private static final class SettingsValidationException extends Exception { - settingsController.getProperties().setProperty("timerDelay", String.valueOf(correctDelay)); - settingsController.getProperties().setProperty("timerDelayType", String.valueOf(delayType)); - settingsController.getProperties().setProperty("timerApplicationShutdown", String.valueOf(chbTimerApplicationShutdown.isSelected())); - settingsController.getProperties().setProperty("timerComputerShutdown", String.valueOf(chbTimerComputerShutdown.isSelected())); + @Serial + private static final long serialVersionUID = 1L; - settingsController.getProperties().setProperty("audioBalance", String.valueOf(sldAudioBalance.getValue())); - mainWindowController.setAudioBalance(sldAudioBalance.getValue()); + private final String messageKey; - Configurator.setAllLevels(LogManager.getRootLogger().getName(), level); - try { - settingsController.saveProperties(); + /** + * Initialize a new SettingsValidationException + * + * @param messageKey The translation key of the error message + */ + private SettingsValidationException(final String messageKey) { + super(messageKey); + this.messageKey = messageKey; + } - Stage stage = (Stage) sldAudioBalance.getScene().getWindow(); - stage.close(); - } catch (final IOException ex) { - logger.error("Unable to save all settings", ex); - FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), ex.toString(), getClass().getResourceAsStream(SharedVariables.ICON_URL)); + /** + * Initialize a new SettingsValidationException + * + * @param messageKey The translation key of the error message + * @param cause The cause of the exception + */ + private SettingsValidationException(final String messageKey, final Throwable cause) { + super(messageKey, cause); + this.messageKey = messageKey; + } + + /** + * Get the translation key of the error message + * + * @return The translation key + */ + private String getMessageKey() { + return messageKey; } } diff --git a/src/main/java/com/codedead/opal/domain/SoundPane.java b/src/main/java/com/codedead/opal/domain/SoundPane.java index bd985f0..b8d9870 100644 --- a/src/main/java/com/codedead/opal/domain/SoundPane.java +++ b/src/main/java/com/codedead/opal/domain/SoundPane.java @@ -296,13 +296,29 @@ public void pause() { */ @FXML public void playPause() throws MediaPlayerException { - if (mediaPlayer != null && mediaPlayer.getStatus() == MediaPlayer.Status.PLAYING) { + if (sldVolume.getValue() <= 0) { + // The volume listener starts playback when the slider moves above zero + sldVolume.setValue(1.0); + } else if (isPlaying()) { pause(); - } else if (mediaPlayer != null && mediaPlayer.getVolume() != 0) { + } else { play(); } } + /** + * Check whether the {@link MediaPlayer} object is currently playing + * + * @return True if the media is playing or stalled, otherwise false + */ + public boolean isPlaying() { + if (mediaPlayer == null) + return false; + + final MediaPlayer.Status status = mediaPlayer.getStatus(); + return status == MediaPlayer.Status.PLAYING || status == MediaPlayer.Status.STALLED; + } + /** * Dispose of the {@link MediaPlayer} object and all bindings */ diff --git a/src/main/resources/audio/metronomefastest.mp3 b/src/main/resources/audio/metronomefastest.mp3 new file mode 100644 index 0000000..751adc9 Binary files /dev/null and b/src/main/resources/audio/metronomefastest.mp3 differ diff --git a/src/main/resources/translations/OpalApplication.properties b/src/main/resources/translations/OpalApplication.properties index 4e43d4e..867c0d0 100644 --- a/src/main/resources/translations/OpalApplication.properties +++ b/src/main/resources/translations/OpalApplication.properties @@ -57,6 +57,7 @@ Seconds=Second(s) Minutes=Minute(s) Hours=Hour(s) TimerDelayTooSmall=Timer delay cannot be smaller than 1! +InvalidTimerDelay=Please enter a valid timer delay! Fantasy=Fantasy Fan=Fan TimerApplicationShutdown=Exit Opal @@ -94,6 +95,7 @@ Seagulls=Seagulls BellTower=Bell tower SlowMetronome=Metronome (slow) FastMetronome=Metronome (fast) +FastestMetronome=Metronome (fastest) PlayPause=Play / Pause PlayPauseError=Unable to play / pause! Dolphins=Dolphins diff --git a/src/main/resources/translations/OpalApplication_de_DE.properties b/src/main/resources/translations/OpalApplication_de_DE.properties index d46175a..c68dcc9 100644 --- a/src/main/resources/translations/OpalApplication_de_DE.properties +++ b/src/main/resources/translations/OpalApplication_de_DE.properties @@ -57,6 +57,7 @@ Seconds=Sekunde(n) Minutes=Minute(n) Hours=Stunde(n) TimerDelayTooSmall=Verzögerung darf nicht kleiner als 1 sein! +InvalidTimerDelay=Bitte geben Sie eine gültige Timer-Verzögerung ein! Fantasy=Fantasie Fan=Lüfter TimerApplicationShutdown=Opal beenden @@ -94,6 +95,7 @@ Seagulls=Möwen BellTower=Glockenturm SlowMetronome=Langsames Metronom FastMetronome=Schnelles Metronom +FastestMetronome=Schnellstes Metronom PlayPause=Abspielen / Pause PlayPauseError=Abspielen / Pause konnte nicht ausgeführt werden! Dolphins=Delfine diff --git a/src/main/resources/translations/OpalApplication_en_US.properties b/src/main/resources/translations/OpalApplication_en_US.properties index 4e43d4e..867c0d0 100644 --- a/src/main/resources/translations/OpalApplication_en_US.properties +++ b/src/main/resources/translations/OpalApplication_en_US.properties @@ -57,6 +57,7 @@ Seconds=Second(s) Minutes=Minute(s) Hours=Hour(s) TimerDelayTooSmall=Timer delay cannot be smaller than 1! +InvalidTimerDelay=Please enter a valid timer delay! Fantasy=Fantasy Fan=Fan TimerApplicationShutdown=Exit Opal @@ -94,6 +95,7 @@ Seagulls=Seagulls BellTower=Bell tower SlowMetronome=Metronome (slow) FastMetronome=Metronome (fast) +FastestMetronome=Metronome (fastest) PlayPause=Play / Pause PlayPauseError=Unable to play / pause! Dolphins=Dolphins diff --git a/src/main/resources/translations/OpalApplication_es_ES.properties b/src/main/resources/translations/OpalApplication_es_ES.properties index 14e0bfd..770e722 100644 --- a/src/main/resources/translations/OpalApplication_es_ES.properties +++ b/src/main/resources/translations/OpalApplication_es_ES.properties @@ -57,6 +57,7 @@ Seconds=Segundo(s) Minutes=Minuto(s) Hours=Hora(s) TimerDelayTooSmall=¡El retraso del temporizador no puede ser menor que 1! +InvalidTimerDelay=¡Introduce un retraso de temporizador válido! Fantasy=Fantasía Fan=Ventilador TimerApplicationShutdown=Salir de Opal @@ -94,6 +95,7 @@ Seagulls=Gaviotas BellTower=Campanario SlowMetronome=Metrónomo lento FastMetronome=Metrónomo rápido +FastestMetronome=Metrónomo muy rápido PlayPause=Reproducir / Pausa PlayPauseError=¡No se puede reproducir / pausar el sonido! Dolphins=Delfines diff --git a/src/main/resources/translations/OpalApplication_fr_FR.properties b/src/main/resources/translations/OpalApplication_fr_FR.properties index f08067f..725ae0e 100644 --- a/src/main/resources/translations/OpalApplication_fr_FR.properties +++ b/src/main/resources/translations/OpalApplication_fr_FR.properties @@ -57,6 +57,7 @@ Seconds=Second(s) Minutes=Minute(s) Hours=Heure(s) TimerDelayTooSmall=Le délai de la minuterie ne peut pas être inférieur à 1! +InvalidTimerDelay=Veuillez saisir un délai de minuterie valide ! Fantasy=Fantaisie Fan=Ventilateur TimerApplicationShutdown=Fermer Opal @@ -94,6 +95,7 @@ Seagulls=Mouettes BellTower=Clocher SlowMetronome=Métronome lent FastMetronome=Métronome rapide +FastestMetronome=Métronome très rapide PlayPause=Lecture / Pause PlayPauseError=Impossible de lire / mettre en pause! Dolphins=Dauphins diff --git a/src/main/resources/translations/OpalApplication_hi_IN.properties b/src/main/resources/translations/OpalApplication_hi_IN.properties index 39ffb20..c8eae90 100644 --- a/src/main/resources/translations/OpalApplication_hi_IN.properties +++ b/src/main/resources/translations/OpalApplication_hi_IN.properties @@ -57,6 +57,7 @@ Seconds=सेकंड Minutes=मिनट Hours=घंटे TimerDelayTooSmall=टाइमर विलंब 1 से कम नहीं हो सकता! +InvalidTimerDelay=कृपया एक मान्य टाइमर विलंब दर्ज करें! Fantasy=कल्पना Fan=पंखा TimerApplicationShutdown=Opal से बाहर निकलें @@ -94,6 +95,7 @@ Seagulls=सीगल BellTower=घंटी मीनार SlowMetronome=मेट्रोनोम (धीमा) FastMetronome=मेट्रोनोम (तेज़) +FastestMetronome=मेट्रोनोम (सबसे तेज़) PlayPause=चालू करे रोके PlayPauseError=चलाने/रोकने में असमर्थ! Dolphins=डाल्फिन diff --git a/src/main/resources/translations/OpalApplication_ja_JP.properties b/src/main/resources/translations/OpalApplication_ja_JP.properties index eaacb7c..d603769 100644 --- a/src/main/resources/translations/OpalApplication_ja_JP.properties +++ b/src/main/resources/translations/OpalApplication_ja_JP.properties @@ -57,6 +57,7 @@ Seconds=秒 Minutes=分 Hours=時間 TimerDelayTooSmall=タイマー遅延は 1 より小さくすることはできません! +InvalidTimerDelay=有効なタイマー遅延を入力してください。 Fantasy=ファンタジー Fan=ファン TimerApplicationShutdown=Opal を終了 @@ -94,6 +95,7 @@ Seagulls=カモメ BellTower=ベルタワー SlowMetronome=遅いメトロノーム FastMetronome=速いメトロノーム +FastestMetronome=最速メトロノーム PlayPause=再生/一時停止 PlayPauseError=再生/一時停止できません! Dolphins=イルカ diff --git a/src/main/resources/translations/OpalApplication_nl_NL.properties b/src/main/resources/translations/OpalApplication_nl_NL.properties index 7def498..5119ca9 100644 --- a/src/main/resources/translations/OpalApplication_nl_NL.properties +++ b/src/main/resources/translations/OpalApplication_nl_NL.properties @@ -57,6 +57,7 @@ Seconds=Second(en) Minutes=Minuut/Minuten Hours=Uur/Uren TimerDelayTooSmall=Tijdvertraging kan niet kleiner zijn dan 1! +InvalidTimerDelay=Voer een geldige timervertraging in! Fantasy=Fantasie Fan=Ventilator TimerApplicationShutdown=Opal afsluiten @@ -94,6 +95,7 @@ Seagulls=Meeuwen BellTower=Klokkentoren SlowMetronome=Metronoom (langzaam) FastMetronome=Metronoom (snel) +FastestMetronome=Metronoom (snelst) PlayPause=Afspelen / Pauzeren PlayPauseError=Kan niet afspelen / pauzeren! Dolphins=Dolfijnen diff --git a/src/main/resources/translations/OpalApplication_ru_RU.properties b/src/main/resources/translations/OpalApplication_ru_RU.properties index e971695..64b5b52 100644 --- a/src/main/resources/translations/OpalApplication_ru_RU.properties +++ b/src/main/resources/translations/OpalApplication_ru_RU.properties @@ -57,6 +57,7 @@ Seconds=Секунды Minutes=Минута(ы) Hours=Часы TimerDelayTooSmall=Задержка таймера не может быть меньше 1! +InvalidTimerDelay=Введите допустимую задержку таймера! Fantasy=Фантазия Fan=Вентилятор TimerApplicationShutdown=Выход Opal @@ -94,6 +95,7 @@ Seagulls=Чайки BellTower=Колокольня SlowMetronome=Медленный метроном FastMetronome=Быстрый метроном +FastestMetronome=Самый быстрый метроном PlayPause=Воспроизведение / Пауза PlayPauseError=Невозможно воспроизвести / приостановить воспроизведение! Dolphins=Дельфины diff --git a/src/main/resources/translations/OpalApplication_tr_TR.properties b/src/main/resources/translations/OpalApplication_tr_TR.properties index 18cff52..6987703 100644 --- a/src/main/resources/translations/OpalApplication_tr_TR.properties +++ b/src/main/resources/translations/OpalApplication_tr_TR.properties @@ -57,6 +57,7 @@ Seconds=Saniye(ler) Minutes=Dakika(lar) Hours=Saat(ler) TimerDelayTooSmall=Zamanlayıcı gecikmesi 1'den küçük olamaz! +InvalidTimerDelay=Lütfen geçerli bir zamanlayıcı gecikmesi girin! Fantasy=Fantezi Fan=Fan TimerApplicationShutdown=Opal'dan Çık @@ -94,6 +95,7 @@ Seagulls=Martılar BellTower=Çan kulesi SlowMetronome=Yavaş metronom FastMetronome=Hızlı metronom +FastestMetronome=En hızlı metronom PlayPause=Oynat / Duraklat PlayPauseError=Oynatma / duraklatma başarısız! Dolphins=Yunuslar diff --git a/src/main/resources/translations/OpalApplication_uk_UA.properties b/src/main/resources/translations/OpalApplication_uk_UA.properties index 25e757d..61200c2 100644 --- a/src/main/resources/translations/OpalApplication_uk_UA.properties +++ b/src/main/resources/translations/OpalApplication_uk_UA.properties @@ -57,6 +57,7 @@ Seconds=Секунда (и) Minutes=Хвилин(и) Hours=Година(и) TimerDelayTooSmall=Затримка таймера не може бути менше 1! +InvalidTimerDelay=Введіть припустиму затримку таймера! Fantasy=Фантазія Fan=Вентилятор TimerApplicationShutdown=Вихід з Opal @@ -94,6 +95,7 @@ Seagulls=Чайки BellTower=Дзвіниця SlowMetronome=Метроном (повільний) FastMetronome=Метроном (швидкий) +FastestMetronome=Найшвидший метроном PlayPause=Відтворення / Пауза PlayPauseError=Неможливо відтворити/призупинити! Dolphins=Дельфіни diff --git a/src/main/resources/translations/OpalApplication_zh_CN.properties b/src/main/resources/translations/OpalApplication_zh_CN.properties index e9dbff6..35efe79 100644 --- a/src/main/resources/translations/OpalApplication_zh_CN.properties +++ b/src/main/resources/translations/OpalApplication_zh_CN.properties @@ -57,6 +57,7 @@ Seconds=秒 Minutes=分钟 Hours=小时 TimerDelayTooSmall=定时器延迟不能小于1! +InvalidTimerDelay=请输入有效的定时器延迟! Fantasy=梦幻 Fan=风扇 TimerApplicationShutdown=退出Opal @@ -94,6 +95,7 @@ Seagulls=海鸥 BellTower=钟楼 SlowMetronome=节拍器(慢) FastMetronome=节拍器(快) +FastestMetronome=节拍器(最快) PlayPause=播放/暂停 PlayPauseError=无法播放/暂停! Dolphins=海豚 diff --git a/src/main/resources/windows/MainWindow.fxml b/src/main/resources/windows/MainWindow.fxml index 9b04f55..64fd3ba 100644 --- a/src/main/resources/windows/MainWindow.fxml +++ b/src/main/resources/windows/MainWindow.fxml @@ -42,7 +42,8 @@ - + @@ -395,6 +396,7 @@ + @@ -431,6 +433,9 @@ + diff --git a/src/test/java/com/codedead/opal/controller/SettingsControllerTest.java b/src/test/java/com/codedead/opal/controller/SettingsControllerTest.java new file mode 100644 index 0000000..170c4ad --- /dev/null +++ b/src/test/java/com/codedead/opal/controller/SettingsControllerTest.java @@ -0,0 +1,59 @@ +package com.codedead.opal.controller; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SettingsControllerTest { + + @TempDir + private Path temporaryDirectory; + + @Test + void storesNewPropertiesAndReplacesTheLiveProperties() throws IOException { + final Path propertiesFile = temporaryDirectory.resolve("opal.properties"); + Files.writeString(propertiesFile, "locale=de-DE\ntheme=dark\n"); + + final SettingsController settingsController = new SettingsController(propertiesFile.toString(), "default.properties"); + final Properties updatedProperties = new Properties(); + updatedProperties.putAll(settingsController.getProperties()); + updatedProperties.setProperty("theme", "light"); + + settingsController.saveProperties(updatedProperties); + + assertEquals("light", settingsController.getProperties().getProperty("theme")); + assertEquals("de-DE", settingsController.getProperties().getProperty("locale")); + + final Properties storedProperties = settingsController.readPropertiesFile(); + assertEquals("light", storedProperties.getProperty("theme")); + assertEquals("de-DE", storedProperties.getProperty("locale")); + } + + @Test + void keepsTheLivePropertiesWhenStoringFails() throws IOException { + final Path propertiesFile = temporaryDirectory.resolve("opal.properties"); + Files.writeString(propertiesFile, "locale=en-US\n"); + + final SettingsController settingsController = new SettingsController(propertiesFile.toString(), "default.properties"); + final Properties originalProperties = settingsController.getProperties(); + + Files.delete(propertiesFile); + Files.createDirectory(propertiesFile); + + final Properties updatedProperties = new Properties(); + updatedProperties.putAll(originalProperties); + updatedProperties.setProperty("theme", "dark"); + + assertThrows(IOException.class, () -> settingsController.saveProperties(updatedProperties)); + assertSame(originalProperties, settingsController.getProperties()); + assertEquals("en-US", settingsController.getProperties().getProperty("locale")); + } +}