#2641 Enhanced radio reference integration. With a premium subscription and when you set your location on the map, the Spectral display includes radio reference layer that presents popup card in the spectrum that shows candidate systems or frequencies ordered by distance from your location. Background thread builds an offline cache of data to support this. Map plots this cache as tower pins on the map and you can hover and active site frequencies from the map. (#2642)

Updates sdrtrunk to use latest radio reference api jar.

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-11-14 09:37:29 -05:00
committed by GitHub
parent 9641d2ef59
commit 5bce541631
51 changed files with 7124 additions and 622 deletions

View File

@@ -51,6 +51,7 @@ import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.record.AudioRecordingManager;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.settings.MapViewSetting;
import io.github.dsheirer.settings.SettingsManager;
import io.github.dsheirer.source.tuner.Tuner;
import io.github.dsheirer.source.tuner.TunerEvent;
@@ -184,6 +185,15 @@ public class SDRTrunk implements Listener<TunerEvent>
mSettingsManager = new SettingsManager();
//Older versions stored the map view in the settings file. Move it to the map preferences once.
MapViewSetting legacyMapView = mSettingsManager.getLegacyMapView();
if(legacyMapView != null)
{
mUserPreferences.getMapPreference().migrateLegacyView(legacyMapView.getLatitude(),
legacyMapView.getLongitude(), legacyMapView.getZoom());
}
AliasModel aliasModel = new AliasModel();
EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences);
mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel);

View File

@@ -39,7 +39,6 @@ import javafx.scene.layout.VBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
@@ -92,8 +91,9 @@ public class AgencyEditor extends VBox
if(agencies != null && !agencies.isEmpty())
{
Collections.sort(agencies, new AgencyComparator());
//The agencies list belongs to a cached state or county info shared with other callers, so sort the items
getAgencyListView().getItems().addAll(agencies);
getAgencyListView().getItems().sort(new AgencyComparator());
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);

View File

@@ -21,6 +21,7 @@ package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.type.Category;
import io.github.dsheirer.rrapi.type.Frequency;
import io.github.dsheirer.rrapi.type.Mode;
@@ -153,19 +154,18 @@ public class AgencyFrequencyEditor extends GridPane
{
clear();
if(categories != null)
{
Collections.sort(categories, new CategoryComparator());
}
if(categories != null && !categories.isEmpty())
{
//The categories list belongs to a cached agency or county info shared with other callers, so change a copy
List<Category> sorted = new ArrayList<>(categories);
sorted.sort(new CategoryComparator());
// Only allow the combined list of all categories if there aren't that many
if (categories.size() < 10) {
categories.add(0, ALL_CATEGORIES);
if (sorted.size() < 10) {
sorted.add(0, ALL_CATEGORIES);
}
getCategoryComboBox().getItems().addAll(categories);
getCategoryComboBox().getSelectionModel().select(categories.get(0));
getCategoryComboBox().getItems().addAll(sorted);
getCategoryComboBox().getSelectionModel().select(sorted.get(0));
}
else
{
@@ -441,6 +441,17 @@ public class AgencyFrequencyEditor extends GridPane
Collections.sort(frequencies, new FrequencyComparator());
//Load the cached modes map here so that the mode column doesn't call the service on the FX
//thread. On failure, the mode column shows an error and the frequencies are still listed.
try
{
mRadioReference.getService().getModesMap();
}
catch(RadioReferenceException rre)
{
mLog.warn("Unable to retrieve radio reference modes - " + rre.getMessage());
}
Platform.runLater(() -> {
getFrequencyTableView().getItems().addAll(frequencies);
setLoading(false);
@@ -449,7 +460,10 @@ public class AgencyFrequencyEditor extends GridPane
catch(Throwable t)
{
mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", t);
Platform.runLater(() -> new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait());
Platform.runLater(() -> {
setLoading(false);
new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait();
});
}
});
}

View File

@@ -204,7 +204,7 @@ public class FrequencyEditor extends VBox
if(item != null)
{
mFrequency = (long)(item.getDownlink() * 1E6);
mFrequency = item.getDownlinkHz();
getAlphaTagTextField().setText(item.getAlphaTag());
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
getToneTextField().setText(item.getTone());
@@ -413,46 +413,7 @@ public class FrequencyEditor extends VBox
*/
private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name)
{
if(modeDecoderType.hasDecoderType())
{
Channel channel = new Channel();
channel.setSystem(system);
channel.setSite(site);
channel.setName(name);
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequency);
channel.setSourceConfiguration(sourceConfigTuner);
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
{
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
}
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
}
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
{
if(modeDecoderType == ModeDecoderType.NXDN48)
{
nxdn.setTransmissionMode(TransmissionMode.M4800);
}
else if(modeDecoderType == ModeDecoderType.NXDN96)
{
nxdn.setTransmissionMode(TransmissionMode.M9600);
}
}
channel.setDecodeConfiguration(decodeConfiguration);
return channel;
}
else
{
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
}
return null;
return RadioReferenceChannelFactory.conventional(modeDecoderType, frequency, system, site, name);
}
private TextField getSystemTextField()

View File

@@ -0,0 +1,436 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.playlist.radioreference;
import com.google.common.annotations.VisibleForTesting;
import io.github.dsheirer.preference.radioreference.RadioReferencePreference.Location;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.util.ThreadPool;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
import javafx.stage.Window;
/**
* Dialog to set the user's location, used to rank radio reference results by distance. The location can be looked
* up from a United States zip code (premium radio reference account) or entered as decimal degrees.
*/
public class LocationDialog extends Dialog<LocationDialog.Result>
{
/**
* Button to clear the location
*/
public static final ButtonType CLEAR = new ButtonType("Clear", ButtonBar.ButtonData.LEFT);
private static final String COORDINATE = "([-+]?\\d+(?:[.,]\\d+)?)\\s*°?\\s*([NSEWnsew])?";
/**
* A latitude and longitude pair, e.g. "43.0481, -76.1474" as copied from Google Maps, or "43.0481° N, 76.1474° W".
* A comma separator must be followed by whitespace, so that a decimal comma isn't taken for a separator.
*/
private static final Pattern COORDINATE_PAIR = Pattern.compile("^\\s*" + COORDINATE + "\\s*(?:,\\s+|;\\s*|\\s+)" +
COORDINATE + "\\s*$");
private static final Pattern SINGLE_COORDINATE = Pattern.compile("^\\s*" + COORDINATE + "\\s*$");
private final RadioReference mRadioReference;
private final TextField mZipcodeText = new TextField();
private final Button mLookupButton = new Button("Look Up");
private final Label mLookupStatus = new Label();
private final TextField mLatitudeText = new TextField();
private final TextField mLongitudeText = new TextField();
private final Label mValidationText = new Label();
private String mLabel;
private boolean mUpdatingFields;
/**
* Result of the dialog
* @param location to store, or null to clear the location
*/
public record Result(Location location)
{
}
/**
* Constructs an instance
* @param radioReference service, for zip code lookups
* @param current location, or null
* @param owner window
*/
public LocationDialog(RadioReference radioReference, Location current, Window owner)
{
mRadioReference = radioReference;
setTitle("sdrtrunk - Location");
setHeaderText("Your location is shown on the map and used to find nearby Radio Reference frequencies");
if(owner != null)
{
initOwner(owner);
}
getDialogPane().setContent(createContent());
getDialogPane().getButtonTypes().addAll(CLEAR, ButtonType.OK, ButtonType.CANCEL);
if(current != null)
{
mUpdatingFields = true;
mLatitudeText.setText(format(current.latitude()));
mLongitudeText.setText(format(current.longitude()));
mUpdatingFields = false;
mLabel = current.label();
}
else
{
getDialogPane().lookupButton(CLEAR).setDisable(true);
}
BooleanBinding valid = Bindings.createBooleanBinding(() -> getLocation() != null,
mLatitudeText.textProperty(), mLongitudeText.textProperty());
getDialogPane().lookupButton(ButtonType.OK).disableProperty().bind(valid.not());
updateValidationText();
setResultConverter(buttonType -> {
if(buttonType == ButtonType.OK)
{
return new Result(getLocation());
}
else if(buttonType == CLEAR)
{
return new Result(null);
}
return null;
});
}
private Node createContent()
{
GridPane gridPane = new GridPane();
gridPane.setPadding(new Insets(10, 10, 5, 10));
gridPane.setHgap(5.0);
gridPane.setVgap(5.0);
Label zipcodeLabel = new Label("Zip Code:");
zipcodeLabel.setLabelFor(mZipcodeText);
GridPane.setHalignment(zipcodeLabel, HPos.RIGHT);
gridPane.add(zipcodeLabel, 0, 0);
mZipcodeText.setPrefColumnCount(6);
mZipcodeText.setOnAction(event -> lookup());
gridPane.add(mZipcodeText, 1, 0);
mLookupButton.setOnAction(event -> lookup());
gridPane.add(mLookupButton, 2, 0);
//Zip code lookups need a premium account
boolean premium = mRadioReference.premiumAccountProperty().get();
mZipcodeText.setDisable(!premium);
mLookupButton.setDisable(!premium);
mLookupStatus.setText(premium ? "United States zip codes only" :
"Zip code lookup requires a premium Radio Reference login");
mLookupStatus.setWrapText(true);
gridPane.add(mLookupStatus, 1, 1, 4, 1);
Label latitudeLabel = new Label("Latitude:");
latitudeLabel.setLabelFor(mLatitudeText);
GridPane.setHalignment(latitudeLabel, HPos.RIGHT);
gridPane.add(latitudeLabel, 0, 2);
mLatitudeText.setPrefColumnCount(10);
mLatitudeText.setPromptText("43.0481");
GridPane.setHgrow(mLatitudeText, Priority.ALWAYS);
gridPane.add(mLatitudeText, 1, 2, 2, 1);
Label longitudeLabel = new Label("Longitude:");
longitudeLabel.setLabelFor(mLongitudeText);
GridPane.setHalignment(longitudeLabel, HPos.RIGHT);
gridPane.add(longitudeLabel, 3, 2);
mLongitudeText.setPrefColumnCount(10);
mLongitudeText.setPromptText("-76.1474");
GridPane.setHgrow(mLongitudeText, Priority.ALWAYS);
gridPane.add(mLongitudeText, 4, 2);
Label hint = new Label("Decimal degrees. You can paste \"latitude, longitude\" from a map into either field.");
hint.setWrapText(true);
gridPane.add(hint, 1, 3, 4, 1);
mValidationText.setWrapText(true);
//Inline style, because the theme stylesheets override a label's text fill
mValidationText.setStyle("-fx-text-fill: orangered;");
gridPane.add(mValidationText, 1, 4, 4, 1);
mLatitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue));
mLongitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue));
return gridPane;
}
/**
* Handles an edit of either coordinate field: splits a pasted pair into both fields and forgets the zip code label
* because the location no longer comes from the zip code.
*/
private void coordinatesEdited(String text)
{
if(mUpdatingFields)
{
return;
}
mLabel = null;
double[] pair = parsePair(text);
if(pair != null)
{
//Changing a text field from its own change listener isn't allowed, so split the pair afterwards
Platform.runLater(() -> setCoordinates(pair[0], pair[1]));
}
updateValidationText();
}
private void setCoordinates(double latitude, double longitude)
{
mUpdatingFields = true;
mLatitudeText.setText(format(latitude));
mLongitudeText.setText(format(longitude));
mUpdatingFields = false;
updateValidationText();
}
private void updateValidationText()
{
String latitudeText = mLatitudeText.getText();
String longitudeText = mLongitudeText.getText();
if(isBlank(latitudeText) && isBlank(longitudeText))
{
mValidationText.setText(null);
}
else if(isBlank(latitudeText) || isBlank(longitudeText))
{
mValidationText.setText("Enter both a latitude and a longitude");
}
else if(Double.isNaN(parseCoordinate(latitudeText, true)))
{
mValidationText.setText("Latitude must be a number from -90 to 90");
}
else if(Double.isNaN(parseCoordinate(longitudeText, false)))
{
mValidationText.setText("Longitude must be a number from -180 to 180");
}
else
{
mValidationText.setText(null);
}
}
/**
* Location from the fields
* @return location, or null if the fields aren't valid
*/
private Location getLocation()
{
double latitude = parseCoordinate(mLatitudeText.getText(), true);
double longitude = parseCoordinate(mLongitudeText.getText(), false);
if(Location.isValid(latitude, longitude))
{
return new Location(latitude, longitude, mLabel);
}
return null;
}
/**
* Looks up the zip code in the background and fills the coordinates
*/
private void lookup()
{
String zipcode = mZipcodeText.getText() != null ? mZipcodeText.getText().trim() : "";
if(!zipcode.matches("\\d{5}"))
{
mLookupStatus.setText("Enter a five digit zip code");
return;
}
mLookupButton.setDisable(true);
mLookupStatus.setText("Looking up " + zipcode + "...");
ThreadPool.CACHED.execute(() -> {
Location location = null;
String error = null;
try
{
location = mRadioReference.lookupZipcode(Integer.parseInt(zipcode));
}
catch(RadioReferenceException rre)
{
error = switch(rre.getErrorType())
{
case NETWORK, TIMEOUT -> "Unable to reach Radio Reference";
case INVALID_REQUEST -> "Radio Reference has no location for zip code " + zipcode;
default -> "Lookup failed: " + rre.getMessage();
};
}
catch(RuntimeException re)
{
error = "Lookup failed: " + re.getMessage();
}
final Location result = location;
final String message = error;
Platform.runLater(() -> {
mLookupButton.setDisable(false);
if(result != null)
{
setCoordinates(result.latitude(), result.longitude());
mLabel = result.label();
mLookupStatus.setText(result.label());
}
else
{
mLookupStatus.setText(message);
}
});
});
}
/**
* Parses a latitude and longitude pair
* @param text to parse
* @return latitude and longitude, or null if the text isn't a valid pair
*/
@VisibleForTesting
static double[] parsePair(String text)
{
if(text == null)
{
return null;
}
Matcher matcher = COORDINATE_PAIR.matcher(text);
if(matcher.matches())
{
double latitude = toDegrees(matcher.group(1), matcher.group(2), true);
double longitude = toDegrees(matcher.group(3), matcher.group(4), false);
if(Location.isValid(latitude, longitude))
{
return new double[]{latitude, longitude};
}
}
return null;
}
/**
* Parses one coordinate in decimal degrees, with an optional degree sign and hemisphere letter
* @param text to parse
* @param latitude true for a latitude, false for a longitude
* @return degrees, or NaN if the text isn't a valid coordinate
*/
@VisibleForTesting
static double parseCoordinate(String text, boolean latitude)
{
if(text == null)
{
return Double.NaN;
}
Matcher matcher = SINGLE_COORDINATE.matcher(text);
if(matcher.matches())
{
double degrees = toDegrees(matcher.group(1), matcher.group(2), latitude);
if(Math.abs(degrees) <= (latitude ? 90.0 : 180.0))
{
return degrees;
}
}
return Double.NaN;
}
/**
* Converts a number and an optional hemisphere letter to signed degrees
* @return degrees, or NaN if the hemisphere letter doesn't match the coordinate type
*/
private static double toDegrees(String number, String hemisphere, boolean latitude)
{
//Accept a decimal comma
double degrees = Double.parseDouble(number.replace(',', '.'));
if(hemisphere != null)
{
char letter = Character.toUpperCase(hemisphere.charAt(0));
boolean latitudeLetter = letter == 'N' || letter == 'S';
if(latitudeLetter != latitude || degrees < 0)
{
return Double.NaN;
}
if(letter == 'S' || letter == 'W')
{
degrees = -degrees;
}
}
return degrees;
}
/**
* Formats degrees with up to six decimal places (about 0.1 meter)
*/
@VisibleForTesting
static String format(double degrees)
{
return BigDecimal.valueOf(degrees).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
}
private static boolean isBlank(String text)
{
return text == null || text.isBlank();
}
}

View File

@@ -24,6 +24,7 @@ import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.response.Fault;
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.util.ThreadPool;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.event.ActionEvent;
@@ -49,7 +50,6 @@ import jiconfont.javafx.IconNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.ConnectException;
import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
@@ -60,6 +60,8 @@ import java.util.Date;
public class LoginDialog extends Dialog<AuthorizationInformation>
{
private final static Logger mLog = LoggerFactory.getLogger(LoginDialog.class);
private static final String TEST_CONNECTION = "Test Connection";
private static final String TESTING_CONNECTION = "Testing...";
private UserPreferences mUserPreferences;
private TextField mUserNameText;
private PasswordField mPasswordField;
@@ -278,7 +280,7 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
{
if(mTestConnectionButton == null)
{
mTestConnectionButton = new Button("Test Connection");
mTestConnectionButton = new Button(TEST_CONNECTION);
mTestConnectionButton.setMaxWidth(Double.MAX_VALUE);
mTestConnectionButton.setOnAction(new EventHandler<ActionEvent>()
{
@@ -316,98 +318,26 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
return;
}
Platform.runLater(new Runnable()
{
@Override
public void run()
getTestConnectionButton().setText(TESTING_CONNECTION);
//The test calls the web service, which can take up to the connect and request timeouts, so it runs
//off the FX thread and the result is shown on the FX thread
ThreadPool.CACHED.execute(() -> {
RadioReference.LoginStatus loginStatus = null;
Exception error = null;
try
{
boolean success = false;
RadioReference.LoginStatus loginStatus;
try
{
loginStatus = RadioReference.testConnectionWithExp(userName, password);
if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
{
getTestPassIcon().setVisible(true);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(false);
//Success is only shown as an icon, so report it in the button's accessible name
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
}
else if (loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(true);
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
alert.setHeaderText("Expired Premium Account");
alert.setTitle("Radio Reference Account issue");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
}
else if (loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
Alert alert = new Alert(Alert.AlertType.ERROR, "Please verify username and password", ButtonType.OK);
alert.setHeaderText("Login Failed");
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
}
else
{
// Only way to get here is via an exception
}
}
catch(RadioReferenceException rre)
{
// Set an error state
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
if(rre.getCause() instanceof ConnectException)
{
Alert alert = new Alert(Alert.AlertType.ERROR, "Please check network or radio reference availability", ButtonType.OK);
alert.setHeaderText("No Network Connection");
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("No network connection to radioreference.com");
}
else if(rre.hasFault())
{
Fault fault = rre.getFault();
Alert alert = new Alert(Alert.AlertType.ERROR, fault.getFaultString(), ButtonType.OK);
alert.setHeaderText(fault.getFaultCode());
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
}
else
{
Alert alert = new Alert(Alert.AlertType.ERROR, "Error: " + rre.getMessage(), ButtonType.OK);
alert.setHeaderText("Unknown Error");
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
mLog.error("Error testing connection to radioreference.com", rre);
}
}
getTestConnectionButton().setDisable(false);
loginStatus = RadioReference.testConnectionWithExp(userName, password);
}
catch(Exception e)
{
error = e;
}
final RadioReference.LoginStatus status = loginStatus;
final Exception exception = error;
Platform.runLater(() -> showTestConnectionResult(status, exception));
});
}
});
@@ -416,6 +346,111 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
return mTestConnectionButton;
}
/**
* Shows the result of a connection test. Must be called on the FX thread.
* @param loginStatus from the test, or null if the test failed with an error
* @param error from the test, or null
*/
private void showTestConnectionResult(RadioReference.LoginStatus loginStatus, Exception error)
{
getTestConnectionButton().setText(TEST_CONNECTION);
getTestConnectionButton().setDisable(false);
//Results that arrive after the dialog closes are only logged
boolean showing = isShowing();
if(error != null)
{
// Set an error state
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
RadioReferenceException.ErrorType errorType = error instanceof RadioReferenceException rre ?
rre.getErrorType() : RadioReferenceException.ErrorType.UNKNOWN;
//The service has a 10 second connect timeout, so an unreachable service is reported as TIMEOUT as well
//as NETWORK
if(errorType == RadioReferenceException.ErrorType.NETWORK ||
errorType == RadioReferenceException.ErrorType.TIMEOUT)
{
mLog.error("No network connection to radioreference.com - " + error.getMessage());
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, "No Network Connection",
"Please check network or radio reference availability");
}
}
else if(error instanceof RadioReferenceException rre && rre.hasFault())
{
Fault fault = rre.getFault();
mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com");
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, fault.getFaultCode(), fault.getFaultString());
}
}
else
{
mLog.error("Error testing connection to radioreference.com", error);
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, "Unknown Error", "Error: " + error.getMessage());
}
}
}
else if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM)
{
getTestPassIcon().setVisible(true);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(false);
//Success is only shown as an icon, so report it in the button's accessible name
getTestConnectionButton().setAccessibleText("Test Connection: login successful");
}
else if(loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(false);
getTestExpiredIcon().setVisible(true);
if(showing)
{
Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK);
alert.setHeaderText("Expired Premium Account");
alert.setTitle("Radio Reference Account issue");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
}
}
else if(loginStatus == RadioReference.LoginStatus.INVALID_LOGIN)
{
getTestPassIcon().setVisible(false);
getTestFailIcon().setVisible(true);
getTestExpiredIcon().setVisible(false);
mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com");
if(showing)
{
showTestFailedAlert(Alert.AlertType.ERROR, "Login Failed", "Please verify username and password");
}
}
}
/**
* Shows an alert for a failed connection test. Must be called on the FX thread.
*/
private void showTestFailedAlert(Alert.AlertType alertType, String header, String content)
{
Alert alert = new Alert(alertType, content, ButtonType.OK);
alert.setHeaderText(header);
alert.setTitle("Test Failed");
alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow());
alert.showAndWait();
}
private IconNode getTestPassIcon()
{
if(mTestPassIcon == null)

View File

@@ -70,8 +70,28 @@ public enum ModeDecoderType
}
/**
* Lookup the entry that matches the mode
*
* Lookup the matching entry for a radio reference mode name, e.g. FMN
* @param name of the mode
* @return matching entry or UNKNOWN
*/
public static ModeDecoderType fromName(String name)
{
if(name != null)
{
for(ModeDecoderType modeDecoderType : ModeDecoderType.values())
{
if(modeDecoderType.getValue().contentEquals(name.trim().toLowerCase()))
{
return modeDecoderType;
}
}
}
return UNKNOWN;
}
/**
* Lookup the matching entry for the radio reference mode
* @param mode to match
* @return matching entry or UNKNOWN
*/

View File

@@ -0,0 +1,268 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
import io.github.dsheirer.module.decode.nbfm.DecodeConfigNBFM;
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
import io.github.dsheirer.rrapi.type.Site;
import io.github.dsheirer.rrapi.type.SiteFrequency;
import io.github.dsheirer.rrapi.type.System;
import io.github.dsheirer.rrapi.type.SystemInformation;
import io.github.dsheirer.source.config.SourceConfigTuner;
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Creates playlist channels from radio reference frequencies and trunked system sites.
*/
public final class RadioReferenceChannelFactory
{
private static final Logger mLog = LoggerFactory.getLogger(RadioReferenceChannelFactory.class);
private RadioReferenceChannelFactory()
{
}
/**
* Creates a conventional channel for a radio reference frequency
* @param modeDecoderType of the frequency's radio reference mode
* @param frequency in Hertz
* @param system name for the channel
* @param site name for the channel
* @param name of the channel
* @return channel, or null when sdrtrunk has no decoder for the mode
*/
public static Channel conventional(ModeDecoderType modeDecoderType, long frequency, String system, String site,
String name)
{
if(modeDecoderType.hasDecoderType())
{
Channel channel = new Channel();
channel.setSystem(system);
channel.setSite(site);
channel.setName(name);
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequency);
channel.setSourceConfiguration(sourceConfigTuner);
DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType());
if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM)
{
((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0);
}
else if(decodeConfiguration instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM);
}
else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn)
{
if(modeDecoderType == ModeDecoderType.NXDN48)
{
nxdn.setTransmissionMode(TransmissionMode.M4800);
}
else if(modeDecoderType == ModeDecoderType.NXDN96)
{
nxdn.setTransmissionMode(TransmissionMode.M9600);
}
}
channel.setDecodeConfiguration(decodeConfiguration);
return channel;
}
mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type");
return null;
}
/**
* Decoder type for a trunked system site's control channel. A P25 Phase II site with an FDMA control channel
* uses the Phase 1 decoder, which follows the Phase II traffic channels.
* @return decoder type, or null when sdrtrunk can't decode the site's control channel
*/
public static DecoderType getControlDecoderType(RadioReferenceDecoder decoder, System system, Site site)
{
//Motorola Type II control channels with P25 voice channels aren't supported
if(decoder.isHybridMotorolaP25(system))
{
return null;
}
DecoderType decoderType = decoder.getDecoderType(system, site);
if(decoderType == DecoderType.P25_PHASE2 && (site == null || site.getTdmaControlChannel() == 0))
{
return DecoderType.P25_PHASE1;
}
return decoderType;
}
/**
* Control and alternate control channel frequencies of the site, control channels first
*/
public static List<Long> getControlFrequencies(Site site)
{
List<Long> frequencies = new ArrayList<>();
if(site != null && site.getSiteFrequencies() != null)
{
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(SiteEditor.isControl(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
{
frequencies.add(siteFrequency.getFrequencyHz());
}
}
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(SiteEditor.isAlternate(siteFrequency) && siteFrequency.getFrequencyHz() > 0)
{
frequencies.add(siteFrequency.getFrequencyHz());
}
}
}
return frequencies;
}
/**
* Creates a channel for each of the site's repeaters on the frequencies, for systems without a control channel
* (LTR), where each repeater carries its own trunking data. The channels are named by logical channel number,
* like the playlist editor's site editor does.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site of the repeaters
* @param frequencies of the repeaters to create channels for
* @return channels, or an empty list when sdrtrunk can't decode the system
*/
public static List<Channel> repeaters(RadioReferenceDecoder decoder, System system,
SystemInformation systemInformation, Site site, List<Long> frequencies)
{
List<Channel> channels = new ArrayList<>();
DecoderType decoderType = decoder.getDecoderType(system, site);
if(decoderType == null || site == null || site.getSiteFrequencies() == null)
{
return channels;
}
for(SiteFrequency siteFrequency: site.getSiteFrequencies())
{
if(frequencies.contains(siteFrequency.getFrequencyHz()))
{
Channel channel = new Channel();
channel.setSystem(system.getName());
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
site.getDescription().trim() : "Site " + site.getSiteNumber());
channel.setName("LCN " + siteFrequency.getLogicalChannelNumber());
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(siteFrequency.getFrequencyHz());
channel.setSourceConfiguration(sourceConfigTuner);
channels.add(channel);
}
}
return channels;
}
/**
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
* alternate control channels.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site to create a channel for
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
*/
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
Site site)
{
return trunkedSite(decoder, system, systemInformation, site, null);
}
/**
* Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and
* alternate control channels, starting with the first frequency when it's one of them.
* @param decoder for the radio reference system details
* @param system of the site
* @param systemInformation of the system
* @param site to create a channel for
* @param firstFrequency to list first, so the channel starts on it, or null for control channels first
* @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed
*/
public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation,
Site site, Long firstFrequency)
{
DecoderType decoderType = getControlDecoderType(decoder, system, site);
List<Long> frequencies = getControlFrequencies(site);
//A multi-frequency channel starts on its first frequency
if(firstFrequency != null && frequencies.remove(firstFrequency))
{
frequencies.add(0, firstFrequency);
}
if(decoderType == null || frequencies.isEmpty())
{
return null;
}
Channel channel = new Channel();
channel.setSystem(system.getName());
String siteName = "Site " + site.getSiteNumber();
channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ?
site.getDescription().trim() : siteName);
channel.setName(siteName);
channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation));
if(frequencies.size() == 1)
{
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency(frequencies.get(0));
channel.setSourceConfiguration(sourceConfigTuner);
}
else
{
SourceConfigTunerMultipleFrequency sourceConfig = new SourceConfigTunerMultipleFrequency();
for(long frequency: frequencies)
{
sourceConfig.addFrequency(frequency);
}
channel.setSourceConfiguration(sourceConfig);
}
return channel;
}
}

View File

@@ -19,6 +19,16 @@
package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.module.decode.DecoderFactory;
import io.github.dsheirer.module.decode.config.DecodeConfiguration;
import io.github.dsheirer.module.decode.dmr.DecodeConfigDMR;
import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN;
import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode;
import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1;
import io.github.dsheirer.module.decode.p25.phase1.Modulation;
import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2;
import io.github.dsheirer.module.decode.p25.phase2.enumeration.ScrambleParameters;
import io.github.dsheirer.rrapi.type.RadioNetwork;
import io.github.dsheirer.alias.Alias;
import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup;
import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier;
@@ -382,7 +392,7 @@ public class RadioReferenceDecoder
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
timeslotFrequency.setNumber(lcn);
timeslotFrequency.setDownlinkFrequency((long)(siteFrequency.getFrequency() * 1E6));
timeslotFrequency.setDownlinkFrequency(siteFrequency.getFrequencyHz());
frequencies.add(timeslotFrequency);
}
@@ -432,7 +442,7 @@ public class RadioReferenceDecoder
}
}
frequencies.add(new ChannelFrequency(lcn, (long)(siteFrequency.getFrequency() * 1E6), 0));
frequencies.add(new ChannelFrequency(lcn, siteFrequency.getFrequencyHz(), 0));
}
return frequencies;
@@ -577,4 +587,126 @@ public class RadioReferenceDecoder
return null;
}
/**
* Creates a channel decode configuration for the specified site.
* @param decoderType to create
* @param site information and frequencies
* @param systemInformation with frequency mapping
* @return decode configuration
*/
public DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
{
if(decoderType == null)
{
return null;
}
switch(decoderType)
{
case DMR:
DecodeConfigDMR dmr = new DecodeConfigDMR();
List<TimeslotFrequency> timeslotFrequencies = getTimeslotFrequencies(systemInformation, site);
dmr.setTimeslotMap(timeslotFrequencies);
return dmr;
case NXDN:
Flavor flavor = getFlavor(systemInformation);
TransmissionMode mode = TransmissionMode.M4800;
/**
* 9600 bps Flavors
* ----------------------------
* 29 - NEXEDGE 9600
* 45 - Conventional Networked
*
* 4800 bps Flavors
* ---------------------------
* 11 - Narrowband Networked
* 42 - NEXEDGE 4800
* 44 - Icom IDAS Type C
*
* Type-D (also 4800 bps) Flavors
* ---------------------------
* 36 - Icom IDAS Type D
* 47 - Kenwood Type D
*/
mode = switch(flavor.getName())
{
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
default -> TransmissionMode.M4800;
};
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
List<ChannelFrequency> channelMap = getChannelMap(systemInformation, site);
nxdn.setChannelMap(channelMap);
return nxdn;
case P25_PHASE1:
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
if(isLSM(site))
{
if(p1config instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
}
}
return p1config;
case P25_PHASE2:
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
int nac = 0;
if(site != null && site.getNac() != null)
{
try
{
nac = Integer.parseInt(site.getNac(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
int wacn = 0;
int system = 0;
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
!systemInformation.getRadioNetworks().isEmpty())
{
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
if(radioNetwork.getWacn() != null)
{
try
{
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
if(radioNetwork.getSystemId() != null)
{
try
{
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
}
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
return config;
default:
return DecoderFactory.getDecodeConfiguration(decoderType);
}
}
}

View File

@@ -20,7 +20,11 @@
package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.playlist.PlaylistManager;
import com.google.common.eventbus.Subscribe;
import io.github.dsheirer.eventbus.MyEventBus;
import io.github.dsheirer.preference.PreferenceType;
import io.github.dsheirer.preference.UserPreferences;
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.type.Agency;
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
@@ -31,6 +35,7 @@ import io.github.dsheirer.rrapi.type.CountyInfo;
import io.github.dsheirer.rrapi.type.State;
import io.github.dsheirer.rrapi.type.StateInfo;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.service.radioreference.UserLocation;
import io.github.dsheirer.util.ThreadPool;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
@@ -42,12 +47,12 @@ import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.util.StringConverter;
@@ -58,6 +63,7 @@ import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
public class RadioReferenceEditor extends BorderPane implements Consumer<AuthorizationInformation>
@@ -68,6 +74,10 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
private PlaylistManager mPlaylistManager;
private VBox mTopBox;
private HBox mCredentialsBox;
private HBox mLocationBox;
private Label mLocationText;
private final AtomicLong mLocationRequest = new AtomicLong();
private volatile String mLocationKey;
private Label mRRStatusText;
private TextField mUserNameText;
private TextField mAccountExpiresText;
@@ -113,20 +123,41 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
{
refreshCountries();
}
//The preferred county fallback location needs a premium login
refreshLocation();
});
refreshLocation();
//Updates the location when it's changed elsewhere, e.g. from the map
MyEventBus.getGlobalEventBus().register(this);
}
/**
* Refreshes the location text when the location or the preferred county changes
* @param preferenceType that was updated
*/
@Subscribe
public void preferenceUpdated(PreferenceType preferenceType)
{
if(preferenceType == PreferenceType.RADIO_REFERENCE)
{
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
String key = preference.getLocation() + "/" + preference.getPreferredCountyId();
if(!key.equals(mLocationKey))
{
mLocationKey = key;
Platform.runLater(this::refreshLocation);
}
}
}
private void login()
{
if(mUserPreferences.getRadioReferencePreference().hasStoredCredentials())
{
AuthorizationInformation credentials = mUserPreferences.getRadioReferencePreference().getAuthorizationInformation();
if(credentials != null)
{
ThreadPool.CACHED.execute(() -> accept(credentials));
}
}
//Logs in on a background thread, unless the spectral display has already logged in
mRadioReference.loginWithStoredCredentials();
}
/**
@@ -354,7 +385,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
mTopBox = new VBox();
mTopBox.setSpacing(5);
mTopBox.setPadding(new Insets(10,10,10,10));
mTopBox.getChildren().addAll(getCredentialsBox(), getComboBoxPane());
mTopBox.getChildren().addAll(getCredentialsBox(), getLocationBox(), getComboBoxPane());
}
return mTopBox;
@@ -416,13 +447,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
mCredentialsBox = new HBox();
mCredentialsBox.setAlignment(Pos.CENTER_RIGHT);
mCredentialsBox.setSpacing(5.0);
Region leftFiller = new Region();
HBox.setHgrow(leftFiller, Priority.ALWAYS);
mCredentialsBox.getChildren().add(leftFiller);
mCredentialsBox.getChildren().add(getRRPremiumReqText());
Region rightFiller = new Region();
HBox.setHgrow(rightFiller, Priority.ALWAYS);
mCredentialsBox.getChildren().add(rightFiller);
mCredentialsBox.getChildren().add(new Label("User Name:"));
mCredentialsBox.getChildren().add(getUserNameText());
mCredentialsBox.getChildren().add(new Label("Expires:"));
@@ -446,8 +471,8 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
Bindings.and(
mRadioReference.availableProperty(),
mRadioReference.premiumAccountProperty().not()));
mRRStatusText.setTextFill(Color.ORANGERED);
mRRStatusText.setStyle("-fx-font-weight: bold;"); // I guess
//Inline style, because the theme stylesheets override a label's text fill
mRRStatusText.setStyle("-fx-text-fill: orangered; -fx-font-weight: bold;");
}
return mRRStatusText;
@@ -725,6 +750,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
if(selected != null)
{
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
refreshLocation();
}
});
}
@@ -732,6 +758,111 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
return mCountyComboBox;
}
/**
* Row that shows the user's location and a button to change it
*/
private HBox getLocationBox()
{
if(mLocationBox == null)
{
mLocationBox = new HBox();
mLocationBox.setAlignment(Pos.CENTER_LEFT);
mLocationBox.setSpacing(5.0);
Button setLocationButton = new Button("My Location:");
setLocationButton.setOnAction(event -> {
LocationDialog dialog = new LocationDialog(mRadioReference,
mUserPreferences.getRadioReferencePreference().getLocation(), getScene().getWindow());
//The map follows the location through the preference update
dialog.showAndWait().ifPresent(result -> {
mUserPreferences.getRadioReferencePreference().setLocation(result.location());
refreshLocation();
});
});
mLocationBox.getChildren().addAll(setLocationButton, getLocationText());
}
return mLocationBox;
}
private Label getLocationText()
{
if(mLocationText == null)
{
mLocationText = new Label();
mLocationText.setTooltip(new Tooltip("Your location is shown on the map and used to find nearby Radio " +
"Reference frequencies. When it isn't set, the center of the selected county is used."));
}
return mLocationText;
}
/**
* Updates the location text: the user's location, or else the preferred county fallback, which is looked up in
* the background.
*/
private void refreshLocation()
{
long request = mLocationRequest.incrementAndGet();
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
if(location != null)
{
getLocationText().setText(location.describe());
return;
}
String notRanked = "Not set";
if(mUserPreferences.getRadioReferencePreference().getPreferredCountyId() <= 0 ||
!mRadioReference.premiumAccountProperty().get())
{
getLocationText().setText(notRanked);
return;
}
getLocationText().setText("Not set");
ThreadPool.CACHED.execute(() -> {
String text;
try
{
UserLocation userLocation = mRadioReference.getUserLocation();
if(userLocation == null)
{
text = notRanked;
}
else if(userLocation.source() == UserLocation.Source.PREFERRED_COUNTY)
{
text = "Not set - using the center of " + userLocation.description();
}
else
{
//Set in the meantime - a newer refresh shows it
return;
}
}
catch(RadioReferenceException rre)
{
mLog.warn("Unable to look up the preferred county location - " + rre.getMessage());
text = "Not set - unable to look up the selected county";
}
final String locationText = text;
Platform.runLater(() -> {
//A newer refresh supersedes this one
if(mLocationRequest.get() == request)
{
getLocationText().setText(locationText);
}
});
});
}
/**
* Consumer interface method for receiving login credentials

View File

@@ -0,0 +1,614 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.playlist.radioreference;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelException;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.rrapi.RadioReferenceException;
import io.github.dsheirer.rrapi.RadioReferenceService;
import io.github.dsheirer.rrapi.type.Site;
import io.github.dsheirer.rrapi.type.SystemInformation;
import io.github.dsheirer.service.radioreference.FrequencyCandidate;
import io.github.dsheirer.service.radioreference.RadioReference;
import io.github.dsheirer.service.radioreference.SiteRecord;
import io.github.dsheirer.source.config.SourceConfigTuner;
import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency;
import io.github.dsheirer.source.config.SourceConfiguration;
import io.github.dsheirer.util.ThreadPool;
import java.util.ArrayList;
import java.util.Collection;
import java.util.ConcurrentModificationException;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Playlist actions for radio reference frequencies and trunked system sites: find the playlist channel that already
* decodes a frequency or site, enable or disable it, or add a channel and start it. A frequency or site that already
* has a playlist channel with the same protocol isn't added again.
*
* The add, enable and disable actions run in the background and report errors to the caller's error handler, on the
* calling thread pool: the handler shows the message on its own UI thread.
*/
public class RadioReferencePlaylistActions
{
private static final Logger mLog = LoggerFactory.getLogger(RadioReferencePlaylistActions.class);
private final PlaylistManager mPlaylistManager;
/**
* Constructs an instance
* @param playlistManager for the channels, channel processing and the radio reference service
*/
public RadioReferencePlaylistActions(PlaylistManager playlistManager)
{
mPlaylistManager = playlistManager;
}
/**
* Decoder types that count as the same protocol when looking for an existing channel: P25 Phase 1 and Phase 2
* (a Phase 2 system's control channel is often decoded with the Phase 1 decoder), and the LTR variants.
*/
public static Set<DecoderType> getProtocolFamily(DecoderType decoderType)
{
if(decoderType == null)
{
return EnumSet.noneOf(DecoderType.class);
}
return switch(decoderType)
{
case P25_PHASE1, P25_PHASE2 -> EnumSet.of(DecoderType.P25_PHASE1, DecoderType.P25_PHASE2);
case LTR, LTR_NET, PASSPORT -> EnumSet.of(DecoderType.LTR, DecoderType.LTR_NET, DecoderType.PASSPORT);
default -> EnumSet.of(decoderType);
};
}
/**
* Decoder types for a site's control channel, from the site's radio reference protocol
* @return decoder types, or an empty set when sdrtrunk can't decode the site's control channel
*/
public static Set<DecoderType> getSiteDecoderTypes(SiteRecord site)
{
String protocol = site.protocol() != null ? site.protocol().toLowerCase() : "";
if(protocol.startsWith("project 25"))
{
return getProtocolFamily(DecoderType.P25_PHASE1);
}
else if(protocol.startsWith("dmr"))
{
return EnumSet.of(DecoderType.DMR);
}
else if(protocol.startsWith("nxdn"))
{
return EnumSet.of(DecoderType.NXDN);
}
else if(protocol.startsWith("ltr"))
{
return getProtocolFamily(DecoderType.LTR);
}
else if(protocol.startsWith("mpt"))
{
return EnumSet.of(DecoderType.MPT1327);
}
//Motorola Type II, EDACS and other control channels aren't supported
return EnumSet.noneOf(DecoderType.class);
}
/**
* Indicates if the site's system has no control channel, so each repeater has its own channel: LTR, where every
* repeater carries its own trunking data
*/
public static boolean isRepeaterSite(SiteRecord site)
{
return getSiteDecoderTypes(site).contains(DecoderType.LTR);
}
/**
* Indicates if sdrtrunk can decode the site: its control channel, which radio reference lists, or its repeaters
*/
public static boolean isSupported(SiteRecord site)
{
return getUnsupportedReason(site) == null;
}
/**
* Why sdrtrunk can't decode the site
* @return reason, or null when the site is supported
*/
public static String getUnsupportedReason(SiteRecord site)
{
if(getSiteDecoderTypes(site).isEmpty())
{
return (site.protocol() != null ? site.protocol() : "The protocol") + " isn't supported";
}
if(isRepeaterSite(site) ? site.channels().isEmpty() : getControlFrequencies(site).isEmpty())
{
return isRepeaterSite(site) ? "Radio Reference lists no repeaters" :
"Radio Reference lists no control channel";
}
return null;
}
/**
* Frequencies of all of the site's channels
*/
public static List<Long> getSiteFrequencies(SiteRecord site)
{
List<Long> frequencies = new ArrayList<>();
for(SiteRecord.SiteChannel channel: site.channels())
{
frequencies.add(channel.frequencyHz());
}
return frequencies;
}
/**
* Control and alternate control channel frequencies of the site, control channels first
*/
public static List<Long> getControlFrequencies(SiteRecord site)
{
List<Long> frequencies = new ArrayList<>();
for(String use: List.of("Control", "Alternate control"))
{
for(SiteRecord.SiteChannel channel: site.channels())
{
if(use.equals(channel.use()))
{
frequencies.add(channel.frequencyHz());
}
}
}
return frequencies;
}
/**
* Decoder type for a conventional candidate's radio reference mode, or null when sdrtrunk has no decoder for it
*/
public static DecoderType getDecoderType(FrequencyCandidate candidate)
{
ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode());
return mode.hasDecoderType() ? mode.getDecoderType() : null;
}
/**
* Playlist channel, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder types
* @return channel, or null
*/
public Channel findChannel(Collection<Long> frequencies, Set<DecoderType> decoderTypes)
{
return findChannel(getChannels(), frequencies, decoderTypes);
}
/**
* Channel in the list, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder
* types
* @return channel, or null
*/
static Channel findChannel(List<Channel> channels, Collection<Long> frequencies, Set<DecoderType> decoderTypes)
{
if(frequencies.isEmpty() || decoderTypes.isEmpty())
{
return null;
}
for(Channel channel: channels)
{
if(channel.isTrafficChannel() || channel.getDecodeConfiguration() == null ||
!decoderTypes.contains(channel.getDecodeConfiguration().getDecoderType()))
{
continue;
}
for(long frequency: getFrequencies(channel.getSourceConfiguration()))
{
if(frequencies.contains(frequency))
{
return channel;
}
}
}
return null;
}
/**
* Playlist channel that decodes the site, tuned to one of its control channels
* @return channel, or null
*/
public Channel findSiteChannel(SiteRecord site)
{
return findChannel(getControlFrequencies(site), getSiteDecoderTypes(site));
}
/**
* Playlist channels that decode the site: its control channel, or each of its repeaters that has a channel
*/
public List<Channel> findSiteChannels(SiteRecord site)
{
List<Channel> channels = new ArrayList<>();
if(isRepeaterSite(site))
{
List<Channel> playlist = getChannels();
for(long frequency: getSiteFrequencies(site))
{
Channel channel = findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site));
if(channel != null && !channels.contains(channel))
{
channels.add(channel);
}
}
}
else
{
Channel channel = findSiteChannel(site);
if(channel != null)
{
channels.add(channel);
}
}
return channels;
}
/**
* Frequencies of a repeater site that don't have a playlist channel yet
*/
public List<Long> getMissingRepeaterFrequencies(SiteRecord site)
{
List<Long> missing = new ArrayList<>();
List<Channel> playlist = getChannels();
for(long frequency: getSiteFrequencies(site))
{
if(findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site)) == null)
{
missing.add(frequency);
}
}
return missing;
}
/**
* Starts the channels that aren't decoding, in the background
*/
public void enableAll(List<Channel> channels, Consumer<String> onError)
{
for(Channel channel: channels)
{
if(!channel.isProcessing())
{
enable(channel, onError);
}
}
}
/**
* Stops the channels that are decoding, in the background
*/
public void disableAll(List<Channel> channels, Consumer<String> onError)
{
for(Channel channel: channels)
{
if(channel.isProcessing())
{
disable(channel, onError);
}
}
}
/**
* Playlist channel that decodes the conventional frequency with the same protocol
* @return channel, or null
*/
public Channel findConventionalChannel(FrequencyCandidate candidate)
{
return findChannel(List.of(candidate.frequencyHz()), getProtocolFamily(getDecoderType(candidate)));
}
/**
* Starts decoding the playlist channel, in the background
*/
public void enable(Channel channel, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
try
{
if(!channel.isProcessing())
{
mPlaylistManager.getChannelProcessingManager().start(channel);
}
}
catch(ChannelException ce)
{
mLog.error("Error starting channel [" + channel.getName() + "] - " + ce.getMessage());
onError.accept("Can't start " + channel.getShortTitle() + ": " + ce.getMessage());
}
});
}
/**
* Stops decoding the playlist channel, in the background
*/
public void disable(Channel channel, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
try
{
mPlaylistManager.getChannelProcessingManager().stop(channel);
}
catch(ChannelException ce)
{
mLog.error("Error stopping channel [" + channel.getName() + "] - " + ce.getMessage());
onError.accept("Can't stop " + channel.getShortTitle() + ": " + ce.getMessage());
}
});
}
/**
* Radio reference details of a site, for creating its channels
*/
private record SiteDetails(RadioReferenceDecoder decoder, io.github.dsheirer.rrapi.type.System system,
SystemInformation systemInformation, Site site)
{
}
/**
* Fetches the site's system details from radio reference. Call from a background thread.
* @return details, or null after reporting the error
*/
private SiteDetails getSiteDetails(SiteRecord siteRecord, Consumer<String> onError)
{
try
{
RadioReference radioReference = mPlaylistManager.getRadioReference();
RadioReferenceService service = radioReference.getService();
SystemInformation systemInformation = service.getSystemInformation(siteRecord.systemId());
Site site = null;
for(Site candidate: service.getSites(siteRecord.systemId()))
{
if(candidate.getSiteId() == siteRecord.siteId())
{
site = candidate;
}
}
if(systemInformation == null || site == null)
{
onError.accept("Radio Reference didn't provide the details of " + siteRecord.systemName() + " site " +
siteRecord.siteNumber());
return null;
}
RadioReferenceDecoder decoder = new RadioReferenceDecoder(radioReference.getUserPreferences(),
service.getTypesMap(), service.getFlavorsMap(), service.getVoicesMap(), service.getTagsMap());
io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System();
system.setSystemId(siteRecord.systemId());
system.setName(systemInformation.getName() != null ? systemInformation.getName() : siteRecord.systemName());
system.setTypeId(systemInformation.getTypeId());
system.setFlavorId(systemInformation.getFlavorId());
system.setVoiceId(systemInformation.getVoiceId());
return new SiteDetails(decoder, system, systemInformation, site);
}
catch(RadioReferenceException rre)
{
mLog.error("Error getting radio reference site details - " + rre.getMessage());
onError.accept("Can't get the site details from Radio Reference: " + rre.getMessage());
return null;
}
}
/**
* Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels.
* A site with a control channel gets one channel for its control channels. A repeater site (LTR) gets a channel
* for each repeater that doesn't have one yet, and all of its channels are started. Fetches the system details
* from radio reference in the background.
*/
public void addSite(SiteRecord siteRecord, Consumer<String> onError)
{
addSite(siteRecord, null, onError);
}
/**
* Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels, like
* {@link #addSite(SiteRecord, Consumer)}. A new control channel starts on the first frequency when it's one of the
* site's control or alternate control channels.
* @param firstFrequency to start the new control channel on, e.g. the frequency the user picked, or null
*/
public void addSite(SiteRecord siteRecord, Long firstFrequency, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
SiteDetails details = getSiteDetails(siteRecord, onError);
if(details == null)
{
return;
}
if(isRepeaterSite(siteRecord))
{
List<Channel> channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(),
details.systemInformation(), details.site(), getMissingRepeaterFrequencies(siteRecord));
List<Channel> existing = findSiteChannels(siteRecord);
if(channels.isEmpty() && existing.isEmpty())
{
onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + " site " +
siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" + siteRecord.protocol() +
")" : ""));
return;
}
enableAll(existing, onError);
//The channel model's list backs the playlist editor, so it's changed on the JavaFX thread
Platform.runLater(() -> {
for(Channel channel: channels)
{
mPlaylistManager.getChannelModel().addChannel(channel);
enable(channel, onError);
}
});
return;
}
Channel channel = RadioReferenceChannelFactory.trunkedSite(details.decoder(), details.system(),
details.systemInformation(), details.site(), firstFrequency);
if(channel == null)
{
onError.accept("sdrtrunk can't decode the control channel of " + siteRecord.systemName() +
" site " + siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" +
siteRecord.protocol() + ")" : ""));
return;
}
addAndStart(channel, onError);
});
}
/**
* Adds a playlist channel for one repeater of a repeater site (LTR) and starts it, or starts its existing channel
*/
public void addRepeater(SiteRecord siteRecord, long frequency, Consumer<String> onError)
{
ThreadPool.CACHED.execute(() -> {
SiteDetails details = getSiteDetails(siteRecord, onError);
if(details == null)
{
return;
}
List<Channel> channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(),
details.systemInformation(), details.site(), List.of(frequency));
if(channels.isEmpty())
{
onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + (siteRecord.protocol() != null ?
" (" + siteRecord.protocol() + ")" : ""));
return;
}
addAndStart(channels.get(0), onError);
});
}
/**
* Adds a playlist channel for the conventional frequency and starts it, or starts the existing channel
*/
public void addConventional(FrequencyCandidate candidate, Consumer<String> onError)
{
ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode());
String[] source = candidate.source() != null ? candidate.source().split(" › ") : new String[0];
//The source is owner, category and subcategory: the channel's system is the owner, the site the subcategory
String system = source.length > 0 ? source[0] : null;
String site = source.length > 2 ? source[2] : source.length > 1 ? source[1] : null;
String name = candidate.alphaTag() != null ? candidate.alphaTag() : candidate.title();
Channel channel = RadioReferenceChannelFactory.conventional(mode, candidate.frequencyHz(), system, site, name);
if(channel == null)
{
onError.accept("sdrtrunk can't decode " + (candidate.mode() != null ? candidate.mode() : "the mode of") +
" " + candidate.title());
return;
}
addAndStart(channel, onError);
}
/**
* Adds the channel to the playlist and starts it, unless the playlist already has a channel for its frequencies
* and protocol, which is started instead
*/
private void addAndStart(Channel channel, Consumer<String> onError)
{
Channel existing = findChannel(getFrequencies(channel.getSourceConfiguration()),
getProtocolFamily(channel.getDecodeConfiguration().getDecoderType()));
if(existing != null)
{
enable(existing, onError);
return;
}
//The channel model's list backs the playlist editor, so it's changed on the JavaFX thread
Platform.runLater(() -> {
mPlaylistManager.getChannelModel().addChannel(channel);
enable(channel, onError);
});
}
/**
* Copy of the playlist channels, safe to read from any thread
*/
private List<Channel> getChannels()
{
for(int attempt = 0; attempt < 3; attempt++)
{
try
{
return new ArrayList<>(mPlaylistManager.getChannelModel().getChannels());
}
catch(ConcurrentModificationException e)
{
//The playlist changed while copying - try again
}
}
return new ArrayList<>();
}
/**
* Tuned frequencies of a channel source
*/
public static List<Long> getFrequencies(SourceConfiguration sourceConfiguration)
{
if(sourceConfiguration instanceof SourceConfigTuner tuner)
{
return List.of(tuner.getFrequency());
}
else if(sourceConfiguration instanceof SourceConfigTunerMultipleFrequency multiple)
{
return new ArrayList<>(multiple.getFrequencies());
}
return List.of();
}
}

View File

@@ -247,118 +247,7 @@ public class SiteEditor extends GridPane
*/
private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
{
if(decoderType == null)
{
return null;
}
switch(decoderType)
{
case DMR:
DecodeConfigDMR dmr = new DecodeConfigDMR();
List<TimeslotFrequency> timeslotFrequencies = mRadioReferenceDecoder
.getTimeslotFrequencies(systemInformation, site);
dmr.setTimeslotMap(timeslotFrequencies);
return dmr;
case NXDN:
Flavor flavor = mRadioReferenceDecoder.getFlavor(systemInformation);
TransmissionMode mode = TransmissionMode.M4800;
/**
* 9600 bps Flavors
* ----------------------------
* 29 - NEXEDGE 9600
* 45 - Conventional Networked
*
* 4800 bps Flavors
* ---------------------------
* 11 - Narrowband Networked
* 42 - NEXEDGE 4800
* 44 - Icom IDAS Type C
*
* Type-D (also 4800 bps) Flavors
* ---------------------------
* 36 - Icom IDAS Type D
* 47 - Kenwood Type D
*/
mode = switch(flavor.getName())
{
case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600;
case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D;
default -> TransmissionMode.M4800;
};
DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode);
List<ChannelFrequency> channelMap = mRadioReferenceDecoder.getChannelMap(systemInformation, site);
nxdn.setChannelMap(channelMap);
return nxdn;
case P25_PHASE1:
DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType);
if(mRadioReferenceDecoder.isLSM(site))
{
if(p1config instanceof DecodeConfigP25Phase1)
{
((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK);
}
}
return p1config;
case P25_PHASE2:
DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2();
int nac = 0;
if(site != null && site.getNac() != null)
{
try
{
nac = Integer.parseInt(site.getNac(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
int wacn = 0;
int system = 0;
if(systemInformation != null && systemInformation.getRadioNetworks() != null &&
!systemInformation.getRadioNetworks().isEmpty())
{
RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0);
if(radioNetwork.getWacn() != null)
{
try
{
wacn = Integer.parseInt(radioNetwork.getWacn(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
if(radioNetwork.getSystemId() != null)
{
try
{
system = Integer.parseInt(radioNetwork.getSystemId(), 16);
}
catch(Exception e)
{
//Do nothing
}
}
}
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
return config;
default:
return DecoderFactory.getDecodeConfiguration(decoderType);
}
return mRadioReferenceDecoder.getDecodeConfiguration(decoderType, site, systemInformation);
}
/**
@@ -621,7 +510,7 @@ public class SiteEditor extends GridPane
if(controls.size() == 1)
{
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
sourceConfigTuner.setFrequency((long)(controls.get(0).getFrequency() * 1E6));
sourceConfigTuner.setFrequency(controls.get(0).getFrequencyHz());
channel.setSourceConfiguration(sourceConfigTuner);
}
else
@@ -630,7 +519,7 @@ public class SiteEditor extends GridPane
for(SiteFrequency control: controls)
{
sourceConfig.addFrequency((long)(control.getFrequency() * 1E6));
sourceConfig.addFrequency(control.getFrequencyHz());
}
channel.setSourceConfiguration(sourceConfig);
@@ -856,7 +745,7 @@ public class SiteEditor extends GridPane
private static long getFrequency(SiteFrequency siteFrequency)
{
return (long)(siteFrequency.getFrequency() * 1E6);
return siteFrequency.getFrequencyHz();
}
/**

View File

@@ -139,8 +139,9 @@ public class SystemEditor extends VBox
if(systems != null && !systems.isEmpty())
{
Collections.sort(systems, new SystemComparator());
//The systems list belongs to a cached state or county info shared with other callers, so sort the items
getSystemComboBox().getItems().addAll(systems);
getSystemComboBox().getItems().sort(new SystemComparator());
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
@@ -302,7 +303,16 @@ public class SystemEditor extends VBox
//have a county ID of 99,999 as observed from API (undocumented).
if(countyId > 0 && countyId != 99999)
{
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
//The county name is only for display, so a failed lookup shouldn't fail the system load
try
{
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
}
catch(RadioReferenceException rre)
{
mLog.warn("Unable to retrieve county [" + countyId + "] for site [" +
site.getSiteNumber() + "] of system [" + system.getSystemId() + "] - " + rre.getMessage());
}
}
enrichedSites.add(new EnrichedSite(site, countyInfo));

View File

@@ -264,9 +264,11 @@ public class SystemTalkgroupSelectionEditor extends GridPane
if(talkgroups != null && !talkgroups.isEmpty())
{
Collections.sort(talkgroups, Comparator.comparingInt(Talkgroup::getDecimalValue));
//The talkgroup list is shared by the caching service and is read-only, so sort a copy
List<Talkgroup> sorted = new ArrayList<>(talkgroups);
sorted.sort(Comparator.comparingInt(Talkgroup::getDecimalValue));
for(Talkgroup talkgroup: talkgroups)
for(Talkgroup talkgroup: sorted)
{
mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup)));
}

View File

@@ -0,0 +1,90 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* Text and placement of the cursor readout: the frequency under the mouse, and the level in the spectrum, in a box
* beside the mouse. The frequency layer draws the readout. A layer that replaces the readout with a larger callout
* (see {@link SpectralDisplayLayer#replacesCursorReadout}) uses the same text and placement, so the readout appears to
* grow into the callout.
*/
public final class CursorReadout
{
/**
* Horizontal distance between the mouse and the box
*/
public static final double GAP_X = 8;
/**
* Vertical distance between the box and the mouse below it
*/
public static final double GAP_Y = 4;
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final DecimalFormat LEVEL_FORMAT = new DecimalFormat("0.0");
private CursorReadout()
{
}
/**
* Readout lines: the frequency under the mouse, and the level when the mouse is over the spectrum plot
*/
public static List<String> lines(DisplayCursor cursor, RenderArea area, SpectralDisplayModel model)
{
List<String> lines = new ArrayList<>(2);
lines.add(FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz");
if(cursor.section() == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0)
{
double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange();
lines.add(LEVEL_FORMAT.format(decibels) + " dBFS");
}
return lines;
}
/**
* Left edge of a box beside the mouse: to the right of the mouse, or to the left when it doesn't fit
*/
public static double x(DisplayCursor cursor, double width, RenderArea area)
{
double x = cursor.x() + GAP_X;
if(x + width > area.width())
{
x = cursor.x() - GAP_X - width;
}
return x;
}
/**
* Top edge of a box above the mouse, kept inside the plot
*/
public static double y(DisplayCursor cursor, double height, RenderArea area)
{
return Math.max(0, Math.min(area.plotHeight() - height, cursor.y() - height - GAP_Y));
}
}

View File

@@ -284,6 +284,24 @@ public class SpectralDisplay extends StackPane
}
}
/**
* Indicates if a layer draws its own callout in place of the cursor readout for the mouse position.
* @param cursor mouse position
* @param area of the section under the mouse
*/
public boolean isCursorReadoutReplaced(DisplayCursor cursor, RenderArea area)
{
for(SpectralDisplayLayer layer: mLayers)
{
if(layer.replacesCursorReadout(cursor, area))
{
return true;
}
}
return false;
}
/**
* Layers in drawing order.
*/

View File

@@ -124,6 +124,18 @@ public abstract class SpectralDisplayLayer
{
}
/**
* Indicates if this layer draws its own callout in place of the cursor readout for the mouse position, so the
* frequency layer skips the readout. The callout should start with the {@link CursorReadout} lines and use its
* placement. Called on the JavaFX application thread before the layers draw the section under the mouse.
* @param cursor mouse position
* @param area of the section under the mouse
*/
public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area)
{
return false;
}
/**
* Context menu items for a right-click at the frequency.
* @param section that was clicked

View File

@@ -21,7 +21,9 @@ package io.github.dsheirer.gui.spectrum;
import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
import io.github.dsheirer.gui.spectrum.layer.ChannelLayer;
import io.github.dsheirer.gui.spectrum.layer.RadioReferenceLayer;
import io.github.dsheirer.playlist.PlaylistManager;
import io.github.dsheirer.properties.SystemProperties;
import io.github.dsheirer.source.ISourceEventProcessor;
@@ -56,9 +58,11 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
public static final String SETTINGS_PREFIX = "spectral.display";
private static final String CHANNEL_DISPLAY = "channel.display";
private static final String CHANNEL_HOVER_DETAILS = "channel.hover.details";
private static final String RADIO_REFERENCE_HOVER = "radioreference.hover";
private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings";
private final ChannelLayer mChannelLayer;
private final RadioReferenceLayer mRadioReferenceLayer;
private final DiscoveredTunerModel mDiscoveredTunerModel;
private final Object mTunerLock = new Object();
private final ReadOnlyObjectWrapper<Tuner> mDisplayedTuner = new ReadOnlyObjectWrapper<>();
@@ -75,9 +79,12 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel,
boolean saveSettings)
{
this(playlistManager != null ? playlistManager.getChannelModel() : null,
playlistManager != null ? playlistManager.getChannelProcessingManager() : null,
discoveredTunerModel, saveSettings);
this(new ChannelLayer(playlistManager != null ? playlistManager.getChannelModel() : null,
playlistManager != null ? playlistManager.getChannelProcessingManager() : null),
new RadioReferenceLayer(playlistManager != null && playlistManager.getRadioReference() != null ?
playlistManager.getRadioReference().getFrequencyIndex() : null,
playlistManager != null ? new RadioReferencePlaylistActions(playlistManager) : null),
discoveredTunerModel, saveSettings);
}
/**
@@ -90,14 +97,17 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager,
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
{
this(new ChannelLayer(channelModel, channelProcessingManager), discoveredTunerModel, saveSettings);
this(new ChannelLayer(channelModel, channelProcessingManager), new RadioReferenceLayer(null, null),
discoveredTunerModel, saveSettings);
}
private TunerSpectralDisplay(ChannelLayer channelLayer, DiscoveredTunerModel discoveredTunerModel,
boolean saveSettings)
private TunerSpectralDisplay(ChannelLayer channelLayer, RadioReferenceLayer radioReferenceLayer,
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
{
super(SETTINGS_PREFIX, saveSettings, channelLayer);
//The radio reference layer draws after the channel layer, so its card is on top
super(SETTINGS_PREFIX, saveSettings, channelLayer, radioReferenceLayer);
mChannelLayer = channelLayer;
mRadioReferenceLayer = radioReferenceLayer;
mDiscoveredTunerModel = discoveredTunerModel;
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
@@ -108,6 +118,8 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE
mChannelLayer.hoverDetailsProperty().set(properties.get(key(CHANNEL_HOVER_DETAILS), true));
mChannelLayer.channelDisplayProperty().addListener((ob, ov, nv) -> save(CHANNEL_DISPLAY, nv.name()));
mChannelLayer.hoverDetailsProperty().addListener((ob, ov, nv) -> save(CHANNEL_HOVER_DETAILS, nv));
mRadioReferenceLayer.hoverCandidatesProperty().set(properties.get(key(RADIO_REFERENCE_HOVER), true));
mRadioReferenceLayer.hoverCandidatesProperty().addListener((ob, ov, nv) -> save(RADIO_REFERENCE_HOVER, nv));
installConfigPanel();
}

View File

@@ -21,6 +21,7 @@ package io.github.dsheirer.gui.spectrum.layer;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.controller.channel.ChannelEvent;
import io.github.dsheirer.controller.channel.ChannelException;
import io.github.dsheirer.controller.channel.ChannelModel;
import io.github.dsheirer.controller.channel.ChannelProcessingManager;
import io.github.dsheirer.eventbus.MyEventBus;
@@ -34,18 +35,22 @@ import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import io.github.dsheirer.util.ThreadPool;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
@@ -717,6 +722,12 @@ public class ChannelLayer extends SpectralDisplayLayer
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
frequency <= tunerChannel.getMaxFrequency())
{
//Traffic channels are started and stopped by their decoder, not from the playlist
if(!channel.isTrafficChannel() && mChannelProcessingManager != null)
{
items.add(createEnableItem(channel));
}
MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle());
item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel)));
items.add(item);
@@ -728,6 +739,52 @@ public class ChannelLayer extends SpectralDisplayLayer
return items;
}
/**
* Menu item that starts the channel when it's disabled, or stops it when it's enabled, in the background
*/
private MenuItem createEnableItem(Channel channel)
{
boolean processing = channel.isProcessing();
MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle());
item.setOnAction(e -> ThreadPool.CACHED.execute(() -> {
try
{
if(processing)
{
mChannelProcessingManager.stop(channel);
}
else
{
mChannelProcessingManager.start(channel);
}
}
catch(ChannelException ce)
{
showError((processing ? "Can't stop " : "Can't start ") + channel.getShortTitle(), ce.getMessage());
}
}));
return item;
}
/**
* Shows an error alert over the display. Can be called from any thread.
*/
private void showError(String header, String message)
{
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setTitle("Channel");
alert.setHeaderText(header);
if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null)
{
alert.initOwner(getSpectralDisplay().getScene().getWindow());
}
alert.show();
});
}
/**
* Channel box screen position.
*/

View File

@@ -19,6 +19,7 @@
package io.github.dsheirer.gui.spectrum.layer;
import io.github.dsheirer.gui.spectrum.CursorReadout;
import io.github.dsheirer.gui.spectrum.DisplayCursor;
import io.github.dsheirer.gui.spectrum.DisplaySection;
import io.github.dsheirer.gui.spectrum.RenderArea;
@@ -26,6 +27,7 @@ import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import java.text.DecimalFormat;
import java.util.List;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
@@ -44,8 +46,6 @@ public class FrequencyLayer extends SpectralDisplayLayer
private static final double INSET_PADDING = 8.0;
private static final double MAJOR_TICK = 6.0;
private static final double MINOR_TICK = 3.0;
private static final DecimalFormat CURSOR_FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final DecimalFormat CURSOR_LEVEL_FORMAT = new DecimalFormat("0.0");
private final Text mMeasure = new Text();
private final double mTextHeight;
@@ -178,49 +178,34 @@ public class FrequencyLayer extends SpectralDisplayLayer
gc.setLineWidth(1.0);
gc.strokeLine(x, 0, x, area.plotHeight());
if(cursor.section() != section)
//Another layer may show the readout lines in a larger callout
if(cursor.section() != section || getSpectralDisplay().isCursorReadoutReplaced(cursor, area))
{
return;
}
String frequency = CURSOR_FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz";
String level = null;
List<String> lines = CursorReadout.lines(cursor, area, model);
double textWidth = 0;
if(section == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0)
for(String line: lines)
{
double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange();
level = CURSOR_LEVEL_FORMAT.format(decibels) + " dBFS";
textWidth = Math.max(textWidth, measure(line));
}
double textWidth = measure(frequency);
if(level != null)
{
textWidth = Math.max(textWidth, measure(level));
}
double lines = level != null ? 2 : 1;
double boxWidth = textWidth + 8;
double boxHeight = (mTextHeight * lines) + 4;
double boxX = cursor.x() + 8;
if(boxX + boxWidth > area.width())
{
boxX = cursor.x() - 8 - boxWidth;
}
double boxY = Math.max(0, Math.min(area.plotHeight() - boxHeight, cursor.y() - boxHeight - 4));
double boxHeight = (mTextHeight * lines.size()) + 4;
double boxX = CursorReadout.x(cursor, boxWidth, area);
double boxY = CursorReadout.y(cursor, boxHeight, area);
gc.setFill(getTheme().getBackground(0.7));
gc.fillRoundRect(boxX, boxY, boxWidth, boxHeight, 6, 6);
gc.setFill(color);
gc.setFont(FONT);
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.TOP);
gc.fillText(frequency, boxX + 4, boxY + 2);
if(level != null)
for(int index = 0; index < lines.size(); index++)
{
gc.fillText(level, boxX + 4, boxY + 2 + mTextHeight);
gc.fillText(lines.get(index), boxX + 4, boxY + 2 + (index * mTextHeight));
}
}

View File

@@ -0,0 +1,747 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.gui.spectrum.layer;
import io.github.dsheirer.controller.channel.Channel;
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.service.radioreference.SiteRecord;
import io.github.dsheirer.source.tuner.channel.TunerChannel;
import java.util.HashSet;
import java.util.Set;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.MenuItem;
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
import io.github.dsheirer.gui.spectrum.CursorReadout;
import io.github.dsheirer.gui.spectrum.DisplayCursor;
import io.github.dsheirer.gui.spectrum.DisplaySection;
import io.github.dsheirer.gui.spectrum.RenderArea;
import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer;
import io.github.dsheirer.gui.spectrum.SpectralDisplayModel;
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
import io.github.dsheirer.service.radioreference.DistanceFormat;
import io.github.dsheirer.service.radioreference.FrequencyCandidate;
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex;
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.RankedCandidate;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javafx.animation.PauseTransition;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.VPos;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import javafx.util.Duration;
/**
* Radio reference candidates layer. When the mouse rests over the spectrum or the waterfall, shows a card next to the
* mouse with the radio reference frequencies near the frequency under the mouse, nearest to the user's location first.
*
* The candidates come from the radio reference frequency index, which needs a premium account and the user's location.
* Looking up a frequency doesn't call the web service.
*/
public class RadioReferenceLayer extends SpectralDisplayLayer
{
private static final Duration HOVER_DELAY = Duration.millis(400);
private static final Font FONT = Font.font(11);
private static final Font TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11);
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
private static final long MIN_TOLERANCE_HZ = 3_000;
private static final double TOLERANCE_PIXELS = 3.0;
private static final int MAX_CANDIDATES = 6;
private static final double MAX_TEXT_WIDTH = 380;
private static final double PADDING = 6;
private static final double GAP = 5;
//Space beside the text and below the readout lines, as in the cursor readout, so the readout lines stay in place
private static final double PADDING_X = 4;
//Space kept between the card and the edges of the section
private static final double EDGE_MARGIN = 2;
private static final double READOUT_PADDING = 2;
private static final String HEADER = "Radio Reference";
private final RadioReferenceFrequencyIndex mIndex;
private final RadioReferencePlaylistActions mActions;
private final BooleanProperty mHoverCandidates = new SimpleBooleanProperty(true);
private final Text mMeasure = new Text();
private final double mTextHeight;
private PauseTransition mHoverTimer;
private DisplaySection mHoverSection;
private double mHoverX = Double.NaN;
private double mHoverY = Double.NaN;
private boolean mHoverReady;
private long mResultsFrequency = Long.MIN_VALUE;
private long mResultsTolerance;
private List<RankedCandidate> mResults = Collections.emptyList();
private Card mCard;
/**
* Candidate text of the card, measured once per lookup
* @param entries lines of each candidate: the title, then the details
* @param more line, or null
* @param textWidth of the widest line
* @param lineCount of the header, candidate and more lines
* @param maxTextWidth that the lines were shortened to
* @param maxHeight that the card was limited to
* @param readoutLines that the card was sized for
*/
private record Card(List<List<String>> entries, String more, double textWidth, int lineCount,
double maxTextWidth, double maxHeight, int readoutLines)
{
}
/**
* Constructs an instance.
* @param index of the radio reference frequencies near the user, or null for a layer that shows nothing
* @param actions to add candidates to the playlist or enable their channels, or null for no context menu items
*/
public RadioReferenceLayer(RadioReferenceFrequencyIndex index, RadioReferencePlaylistActions actions)
{
mIndex = index;
mActions = actions;
mMeasure.setFont(FONT);
mMeasure.setText("Ag");
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
mHoverCandidates.addListener((ob, ov, nv) -> requestRender());
}
/**
* Shows the card of radio reference candidates when the mouse rests over the display
*/
public BooleanProperty hoverCandidatesProperty()
{
return mHoverCandidates;
}
@Override
protected void attached()
{
if(mIndex != null)
{
mIndex.start();
}
}
@Override
public void dispose()
{
if(mHoverTimer != null)
{
mHoverTimer.stop();
}
}
@Override
public String getConfigurationTitle()
{
return "Radio Reference";
}
@Override
public List<ConfigurationRow> getConfigurationRows()
{
if(mIndex == null)
{
return Collections.emptyList();
}
ConfigurationRow hover = ConfigurationRow.toggle("Hover Candidates", mHoverCandidates);
Tooltip.install(hover.control(), new Tooltip("When the mouse rests over a signal, show the Radio Reference " +
"frequencies near it, nearest to your location first"));
Label status = new Label();
status.textProperty().bind(mIndex.statusProperty());
status.setWrapText(true);
status.setMaxWidth(220);
return List.of(hover, new ConfigurationRow("Nearby", status));
}
/**
* Context menu items for the candidates at the frequency, in the card's order: add a candidate to the playlist and
* start it, or enable or disable the playlist channel that already decodes it. One item per site, and per
* frequency and protocol, so an item never adds a duplicate channel.
*/
@Override
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
{
if(mIndex == null || mActions == null)
{
return Collections.emptyList();
}
//The card's candidates when the mouse hasn't moved since the card was shown
List<RankedCandidate> candidates = frequency == mResultsFrequency ? mResults :
mIndex.lookup(frequency, mResultsTolerance > 0 ? mResultsTolerance : MIN_TOLERANCE_HZ);
List<MenuItem> items = new ArrayList<>();
Set<String> offered = new HashSet<>();
for(RankedCandidate ranked: candidates)
{
if(offered.size() >= MAX_CANDIDATES)
{
break;
}
FrequencyCandidate candidate = ranked.candidate();
if(candidate.isSiteFrequency())
{
SiteRecord site = mIndex.getSite(candidate.systemId(), candidate.siteId());
//A repeater site (LTR) has a channel per repeater, otherwise one channel for the site
String key = site == null ? null : RadioReferencePlaylistActions.isRepeaterSite(site) ?
"repeater:" + candidate.frequencyHz() : "site:" + site.systemId() + ":" + site.siteId();
if(site != null && offered.add(key))
{
addSiteItem(items, site, candidate, frequency);
}
}
else
{
DecoderType decoderType = RadioReferencePlaylistActions.getDecoderType(candidate);
Set<DecoderType> family = RadioReferencePlaylistActions.getProtocolFamily(decoderType);
String protocol = family.isEmpty() ? String.valueOf(candidate.mode()) : family.iterator().next().name();
if(offered.add("frequency:" + candidate.frequencyHz() + ":" + protocol))
{
addConventionalItem(items, candidate, decoderType, frequency);
}
}
}
if(!items.isEmpty())
{
MenuItem header = new MenuItem("Radio Reference");
header.setDisable(true);
items.add(0, header);
}
return items;
}
private void addSiteItem(List<MenuItem> items, SiteRecord site, FrequencyCandidate candidate, long frequency)
{
String label = site.systemName() + " \u00B7 Site " + site.siteNumber() +
(site.description() != null ? " " + site.description() : "");
String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site);
if(unsupported != null)
{
MenuItem item = new MenuItem(label + " \u2013 " + unsupported);
item.setDisable(true);
items.add(item);
return;
}
if(RadioReferencePlaylistActions.isRepeaterSite(site))
{
addRepeaterItem(items, site, candidate.frequencyHz(), label, frequency);
return;
}
Channel existing = mActions.findSiteChannel(site);
if(existing != null)
{
//The channel layer offers the channels at the clicked frequency
if(!covers(existing, frequency))
{
items.add(createEnableItem(existing));
}
}
else
{
MenuItem item = new MenuItem("Add and Start: " + label);
//Start on the right-clicked frequency when it's a control or alternate control channel
item.setOnAction(event -> mActions.addSite(site, candidate.frequencyHz(), this::showError));
items.add(item);
}
}
/**
* Menu item for one repeater of a repeater site (LTR): add and start it, or enable or disable its channel
*/
private void addRepeaterItem(List<MenuItem> items, SiteRecord site, long repeaterFrequency, String label,
long frequency)
{
Channel existing = mActions.findChannel(List.of(repeaterFrequency),
RadioReferencePlaylistActions.getSiteDecoderTypes(site));
if(existing != null)
{
if(!covers(existing, frequency))
{
items.add(createEnableItem(existing));
}
return;
}
String lcn = site.channels().stream().filter(channel -> channel.frequencyHz() == repeaterFrequency &&
channel.logicalChannelNumber() > 0).map(channel -> " LCN " + channel.logicalChannelNumber())
.findFirst().orElse("");
MenuItem item = new MenuItem("Add and Start: " + label + lcn + " (" +
FREQUENCY_FORMAT.format(repeaterFrequency / 1E6) + " MHz)");
item.setOnAction(event -> mActions.addRepeater(site, repeaterFrequency, this::showError));
items.add(item);
}
private void addConventionalItem(List<MenuItem> items, FrequencyCandidate candidate, DecoderType decoderType,
long frequency)
{
String label = candidate.title() + " (" + FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz" +
(candidate.mode() != null ? " " + candidate.mode() : "") + ")";
if(decoderType == null)
{
MenuItem item = new MenuItem(label + " \u2013 mode isn't supported");
item.setDisable(true);
items.add(item);
return;
}
Channel existing = mActions.findConventionalChannel(candidate);
if(existing != null)
{
if(!covers(existing, frequency))
{
items.add(createEnableItem(existing));
}
}
else
{
MenuItem item = new MenuItem("Add and Start: " + label);
item.setOnAction(event -> mActions.addConventional(candidate, this::showError));
items.add(item);
}
}
/**
* Menu item that enables the playlist channel when it's disabled, or disables it when it's enabled
*/
private MenuItem createEnableItem(Channel channel)
{
boolean processing = channel.isProcessing();
MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle());
item.setOnAction(event -> {
if(processing)
{
mActions.disable(channel, this::showError);
}
else
{
mActions.enable(channel, this::showError);
}
});
return item;
}
/**
* Indicates if one of the channel's tuner channels includes the frequency
*/
private static boolean covers(Channel channel, long frequency)
{
for(TunerChannel tunerChannel: channel.getTunerChannels())
{
if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency &&
frequency <= tunerChannel.getMaxFrequency())
{
return true;
}
}
return false;
}
/**
* Shows an error alert over the display. Can be called from any thread.
*/
private void showError(String message)
{
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setTitle("Radio Reference");
alert.setHeaderText("Can't add or start the channel");
if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null)
{
alert.initOwner(getSpectralDisplay().getScene().getWindow());
}
alert.show();
});
}
@Override
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
{
render(gc, area, DisplaySection.SPECTRUM);
}
@Override
public void renderWaterfall(GraphicsContext gc, RenderArea area)
{
render(gc, area, DisplaySection.WATERFALL);
}
/**
* Draws the card in the section under the mouse once the mouse has rested for the hover delay
*/
private void render(GraphicsContext gc, RenderArea area, DisplaySection section)
{
DisplayCursor cursor = getSpectralDisplay() != null ? getSpectralDisplay().getDisplayCursor() : null;
trackHover(cursor);
if(cursor != null && cursor.section() == section && replacesCursorReadout(cursor, area))
{
drawCard(gc, area, cursor, getCandidates(cursor.frequency(), getTolerance(getModel(), area)));
}
}
/**
* The card replaces the cursor readout while it's shown: it ends with the readout lines, in the readout's place.
*/
@Override
public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area)
{
SpectralDisplayModel model = getModel();
//Only for the position the mouse rested at - the readout comes back as soon as the mouse moves
if(mIndex == null || !mHoverCandidates.get() || !mHoverReady || model == null || !model.hasSource() ||
cursor == null || cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY ||
area.width() <= 0)
{
return false;
}
return !getCandidates(cursor.frequency(), getTolerance(model, area)).isEmpty();
}
/**
* Frequency tolerance for the candidates: a few pixels or a DFT bin at the current zoom, but at least 3 kHz
*/
private static long getTolerance(SpectralDisplayModel model, RenderArea area)
{
double hertzPerPixel = model.getDisplayBandwidth() / area.width();
return Math.max(MIN_TOLERANCE_HZ, Math.round(Math.max(TOLERANCE_PIXELS * hertzPerPixel, model.getBinWidth())));
}
/**
* Restarts the hover delay each time the mouse moves, and hides the card until it passes
*/
private void trackHover(DisplayCursor cursor)
{
if(cursor == null)
{
if(mHoverSection != null)
{
mHoverSection = null;
mHoverReady = false;
getHoverTimer().stop();
}
return;
}
if(cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY)
{
mHoverSection = cursor.section();
mHoverX = cursor.x();
mHoverY = cursor.y();
mHoverReady = false;
getHoverTimer().playFromStart();
}
}
private PauseTransition getHoverTimer()
{
if(mHoverTimer == null)
{
mHoverTimer = new PauseTransition(HOVER_DELAY);
mHoverTimer.setOnFinished(event -> {
mHoverReady = true;
requestRender();
});
}
return mHoverTimer;
}
/**
* Candidates for the frequency, looked up again only when the frequency or the tolerance changes
*/
private List<RankedCandidate> getCandidates(long frequency, long tolerance)
{
if(frequency != mResultsFrequency || tolerance != mResultsTolerance)
{
mResults = mIndex.lookup(frequency, tolerance);
mResultsFrequency = frequency;
mResultsTolerance = tolerance;
mCard = null;
}
return mResults;
}
/**
* Draws the card in place of the cursor readout: beside the mouse and above it, placed like the readout, with the
* candidates above the readout lines so the frequency and level stay where the readout showed them. The card
* fits the larger side of the mouse and the height of the section, showing as many candidates as fit.
*/
private void drawCard(GraphicsContext gc, RenderArea area, DisplayCursor cursor, List<RankedCandidate> candidates)
{
//The level line changes with the mouse height while the candidates don't
List<String> readout = CursorReadout.lines(cursor, area, getModel());
double readoutWidth = 0;
for(String line: readout)
{
readoutWidth = Math.max(readoutWidth, measure(line, FONT));
}
double room = Math.max(area.width() - cursor.x(), cursor.x()) - CursorReadout.GAP_X - EDGE_MARGIN;
double maxTextWidth = Math.max(readoutWidth, Math.min(MAX_TEXT_WIDTH, room - (PADDING_X * 2)));
//Above the mouse, so the readout lines stay where the readout showed them, when a candidate fits there
double above = cursor.y() - CursorReadout.GAP_Y;
double maxHeight = above >= cardHeight(5, 1, true, readout.size()) ? above : area.plotHeight() - EDGE_MARGIN;
if(mCard == null || mCard.maxTextWidth() != maxTextWidth || mCard.maxHeight() != maxHeight ||
mCard.readoutLines() != readout.size())
{
mCard = createCard(candidates, maxTextWidth, maxHeight, readout.size());
}
Card card = mCard;
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
double width = Math.max(card.textWidth(), readoutWidth) + (PADDING_X * 2);
double height = cardHeight(card.lineCount(), card.entries().size(), card.more() != null, readout.size());
double x = Math.max(0, CursorReadout.x(cursor, width, area));
double y = CursorReadout.y(cursor, height, area);
gc.setFill(theme.getBackground(0.92));
gc.fillRoundRect(x, y, width, height, 8, 8);
gc.setStroke(opaque(theme.getLine()));
gc.setLineWidth(1.0);
gc.strokeRoundRect(Math.floor(x) + 0.5, Math.floor(y) + 0.5, width, height, 8, 8);
gc.setTextAlign(TextAlignment.LEFT);
gc.setTextBaseline(VPos.TOP);
double textX = x + PADDING_X;
double lineY = y + PADDING;
gc.setFill(theme.getCursor());
gc.setFont(TITLE_FONT);
gc.fillText(HEADER, textX, lineY);
lineY += mTextHeight + GAP;
for(List<String> lines: card.entries())
{
gc.setFill(theme.getCursor());
gc.setFont(TITLE_FONT);
gc.fillText(lines.get(0), textX, lineY);
lineY += mTextHeight;
gc.setFill(opaque(theme.getLine()));
gc.setFont(FONT);
for(int line = 1; line < lines.size(); line++)
{
gc.fillText(lines.get(line), textX, lineY);
lineY += mTextHeight;
}
lineY += GAP;
}
if(card.more() != null)
{
gc.setFill(opaque(theme.getLine()));
gc.setFont(FONT);
gc.fillText(card.more(), textX, lineY);
lineY += mTextHeight + GAP;
}
//The readout lines, separated from the candidates, in the readout's style
double separator = Math.floor(lineY - (GAP / 2.0)) + 0.5;
gc.setStroke(theme.getLine(0.5));
gc.strokeLine(textX, separator, x + width - PADDING_X, separator);
gc.setFill(theme.getCursor());
gc.setFont(FONT);
for(String line: readout)
{
gc.fillText(line, textX, lineY);
lineY += mTextHeight;
}
}
/**
* Height of a card with the lines
* @param lineCount of the header, candidate and more lines
* @param entries number of candidates
* @param more true when the card has a more line
* @param readoutLines number of readout lines
*/
private double cardHeight(int lineCount, int entries, boolean more, int readoutLines)
{
int gaps = 1 + entries + (more ? 1 : 0);
return ((lineCount + readoutLines) * mTextHeight) + (gaps * GAP) + PADDING + READOUT_PADDING;
}
/**
* Card text for as many candidates as fit the height: each candidate's title, details and source, shortened to
* the width
*/
private Card createCard(List<RankedCandidate> candidates, double maxTextWidth, double maxHeight,
int readoutLines)
{
int limit = Math.min(candidates.size(), MAX_CANDIDATES);
List<List<String>> entries = new ArrayList<>();
double textWidth = measure(HEADER, TITLE_FONT);
int lineCount = 1;
for(int index = 0; index < limit; index++)
{
RankedCandidate candidate = candidates.get(index);
List<String> lines = new ArrayList<>();
String title = candidate.candidate().title() != null ? candidate.candidate().title() : "(untitled)";
lines.add(fit(title, TITLE_FONT, maxTextWidth));
lines.add(fit(describe(candidate), FONT, maxTextWidth));
String source = source(candidate.candidate());
if(source != null)
{
lines.add(fit(source, FONT, maxTextWidth));
}
//Keep room for the more line when candidates are left out
boolean more = index + 1 < candidates.size();
if(!entries.isEmpty() && cardHeight(lineCount + lines.size() + (more ? 1 : 0), entries.size() + 1, more,
readoutLines) > maxHeight)
{
break;
}
textWidth = Math.max(textWidth, measure(lines.get(0), TITLE_FONT));
for(int line = 1; line < lines.size(); line++)
{
textWidth = Math.max(textWidth, measure(lines.get(line), FONT));
}
lineCount += lines.size();
entries.add(lines);
}
String more = candidates.size() > entries.size() ? "+ " + (candidates.size() - entries.size()) + " more" : null;
if(more != null)
{
textWidth = Math.max(textWidth, measure(more, FONT));
lineCount++;
}
return new Card(entries, more, textWidth, lineCount, maxTextWidth, maxHeight, readoutLines);
}
/**
* Frequency, mode or use, tone, encryption and distance of the candidate
*/
private String describe(RankedCandidate ranked)
{
FrequencyCandidate candidate = ranked.candidate();
return join(FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz", candidate.use(), candidate.mode(),
candidate.tone(), candidate.encrypted() ? "Encrypted" : null,
ranked.hasDistance() ? DistanceFormat.format(ranked.distanceKilometers()) : null);
}
/**
* Where the candidate is listed, with its alpha tag when the title isn't the alpha tag
*/
private static String source(FrequencyCandidate candidate)
{
String alphaTag = candidate.alphaTag() != null && !candidate.alphaTag().equals(candidate.title()) ?
candidate.alphaTag() : null;
return join(alphaTag, candidate.source());
}
/**
* Text shortened with an ellipsis to fit the width
*/
private String fit(String text, Font font, double maxWidth)
{
if(measure(text, font) <= maxWidth)
{
return text;
}
int length = text.length();
while(length > 1 && measure(text.substring(0, length) + "…", font) > maxWidth)
{
length--;
}
return text.substring(0, length) + "…";
}
private double measure(String text, Font font)
{
mMeasure.setFont(font);
mMeasure.setText(text);
double width = mMeasure.getLayoutBounds().getWidth();
mMeasure.setFont(FONT);
return width;
}
private static String join(String... values)
{
StringBuilder sb = new StringBuilder();
for(String value: values)
{
if(value != null && !value.isEmpty())
{
if(!sb.isEmpty())
{
sb.append(" · ");
}
sb.append(value);
}
}
return sb.isEmpty() ? null : sb.toString();
}
private static Color opaque(Color color)
{
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
}
}

View File

@@ -32,6 +32,7 @@ import java.lang.ref.WeakReference;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.application.Platform;
import javafx.scene.Scene;
import javax.swing.LookAndFeel;
@@ -69,6 +70,7 @@ public class ThemeManager
private static final ThemeManager INSTANCE = new ThemeManager();
private final Set<WeakReference<Scene>> mScenes = new LinkedHashSet<>();
private final AtomicBoolean mWindowListenerAttached = new AtomicBoolean();
private final Set<WeakReference<java.awt.Component>> mSwingRoots = new LinkedHashSet<>();
private final String mDarkStylesheetUrl;
private volatile UserPreferences mUserPreferences;
@@ -135,7 +137,18 @@ public class ThemeManager
*/
private void attachJavaFxWindowListener()
{
if(mWindowListenerAttached.get())
{
return;
}
Runnable attach = () -> {
//Attach once, even if several attempts were scheduled before the first one ran
if(!mWindowListenerAttached.compareAndSet(false, true))
{
return;
}
try
{
javafx.stage.Window.getWindows().addListener(
@@ -176,8 +189,9 @@ public class ThemeManager
}
catch(IllegalStateException ignored)
{
//FX toolkit not started yet - first JFXPanel construction will start it; we
//re-attempt the attach lazily inside applyAll() via the Window.getWindows() walk.
//FX toolkit not started yet, which is the case when initialize() runs at application
//startup, before the first JFXPanel. register() attaches the listener once the
//first JavaFX scene is registered, which happens after the toolkit starts.
}
}
}
@@ -248,6 +262,9 @@ public class ThemeManager
mScenes.add(new WeakReference<>(scene));
}
//The toolkit is running once a scene exists, so attach the window listener if initialize()
//couldn't, so that ad-hoc dialogs and alerts are themed
attachJavaFxWindowListener();
applyToScene(scene, isDarkMode());
}