mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-14 09:37:36 -05:00
Compare commits
1 Commits
2641-radio
...
signal-hou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66c5727145 |
5
.github/workflows/nightly.yml
vendored
5
.github/workflows/nightly.yml
vendored
@@ -14,9 +14,6 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Full history and tags, so the build can describe the commit relative to the latest version tag
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 27
|
||||
uses: actions/setup-java@v6
|
||||
@@ -37,7 +34,7 @@ jobs:
|
||||
|
||||
- name: Create Build Info file
|
||||
run: |
|
||||
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\ndescribe: $(git describe --tags --match 'v[0-9]*' --dirty)\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
|
||||
echo -e "ref: $GITHUB_REF \ncommit: $GITHUB_SHA\nbuild: $(date +"%Y-%m-%dT%H:%M:%SZ")" > build/image/build_info.txt
|
||||
|
||||
- name: Add build artifacts to nightly release
|
||||
uses: softprops/action-gh-release@v3
|
||||
|
||||
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# Publishes a release when a version tag is pushed: v0.6.2 for a final release,
|
||||
# or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release. The build derives the
|
||||
# version from the tag (see build.gradle).
|
||||
# Publishes a release when a version tag is pushed. The tag has to match
|
||||
# projectVersion in gradle.properties with a leading 'v': v0.6.2 for a final
|
||||
# release, or v0.6.2-beta-1 / v0.6.2-alpha-1 for a pre-release.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -22,6 +22,15 @@ jobs:
|
||||
# Full history and tags, to find the previous release tag for the release notes
|
||||
fetch-depth: 0
|
||||
|
||||
- id: version
|
||||
run: |
|
||||
expected="v$(sed -n 's/^projectVersion=//p' gradle.properties | tr -d '[:space:]')"
|
||||
if [ "$GITHUB_REF_NAME" != "$expected" ]; then
|
||||
echo "::error::Tag $GITHUB_REF_NAME does not match projectVersion in gradle.properties ($expected)"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Release notes list the PRs since the previous version tag (never 'nightly'). A final release
|
||||
# starts from the previous final release, so it covers every alpha and beta in between.
|
||||
- id: previous
|
||||
@@ -45,17 +54,6 @@ jobs:
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
# The version comes from 'git describe', which can pick a different tag when more than one
|
||||
# version tag points at the same commit. Fail rather than publish zips with the wrong version.
|
||||
- id: version
|
||||
run: |
|
||||
version="$(./gradlew -q printVersion --console=plain | tail -n 1)"
|
||||
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
||||
echo "::error::Build version $version does not match tag $GITHUB_REF_NAME"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and test
|
||||
run: ./gradlew clean build --console=plain
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -54,19 +54,17 @@ committed to the code base, so it's not really 'nightly' as much as it is 'curre
|
||||
Releases are built and published automatically by the [Release](.github/workflows/release.yml) GitHub workflow
|
||||
whenever a version tag is pushed to the repository.
|
||||
|
||||
The version is taken from the tag, so there is no version number to update in the code. Create and push a tag
|
||||
with a leading `v`, following the naming convention described in `gradle.properties` (e.g. `v0.6.2-alpha-1`,
|
||||
`v0.6.2-beta-1` or `v0.6.2`):
|
||||
1. Update `projectVersion` in `gradle.properties` to the new version, following the naming convention described in
|
||||
that file (e.g. `0.6.2-alpha-1`, `0.6.2-beta-1` or `0.6.2`).
|
||||
2. Commit and push the change to `master`.
|
||||
3. Create and push a tag that matches the version with a leading `v`:
|
||||
```
|
||||
git tag v0.6.2-beta-1
|
||||
git push origin v0.6.2-beta-1
|
||||
```
|
||||
|
||||
Builds made from an untagged commit get a version like `0.6.2-beta-1-14-g4626d567` (14 commits after the
|
||||
`v0.6.2-beta-1` tag, at commit `4626d567`), with `-dirty` appended when there are uncommitted changes.
|
||||
|
||||
The workflow then:
|
||||
* Verifies that the version the build derives from git matches the tag and fails if it does not.
|
||||
* Verifies that the tag matches `projectVersion` in `gradle.properties` and fails if it does not.
|
||||
* Builds and tests the code, then creates the release zips for Linux, Mac and Windows (x86-64 and aarch64).
|
||||
* Creates a GitHub release titled `Version <version>` with auto-generated release notes (a "What's Changed" list of
|
||||
the PRs merged since the previous version tag), and attaches the six zip
|
||||
|
||||
44
build.gradle
44
build.gradle
@@ -52,44 +52,8 @@ plugins {
|
||||
id 'org.beryx.runtime' version '2.0.1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Version is derived from the most recent 'v<version>' git tag (e.g. v1.0.0-beta-1 -> 1.0.0-beta-1). Commits after
|
||||
* the tag produce a version like 1.0.0-beta-1-14-g4626d567 and uncommitted changes append '-dirty'. The moving
|
||||
* 'nightly' tag is ignored. Override with -PprojectVersion=<version> (the nightly build uses 'nightly' so that the
|
||||
* nightly release asset names stay the same). Falls back to 'unknown' when built outside a git checkout.
|
||||
*/
|
||||
String gitDescribe = describeGitVersion()
|
||||
version = project.findProperty('projectVersion') ?: (gitDescribe ? gitDescribe.substring(1) : 'unknown')
|
||||
|
||||
String describeGitVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
def describe = providers.exec {
|
||||
commandLine 'git', 'describe', '--tags', '--match', 'v[0-9]*', '--dirty'
|
||||
ignoreExitValue = true
|
||||
}
|
||||
|
||||
if(describe.result.get().exitValue == 0)
|
||||
{
|
||||
return describe.standardOutput.asText.get().trim()
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
logger.warn("Unable to determine version from git tags: " + e.message)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
tasks.register('printVersion') {
|
||||
description = 'Prints the project version'
|
||||
def projectVersion = version
|
||||
doLast {
|
||||
println projectVersion
|
||||
}
|
||||
}
|
||||
//Version is defined in gradle.properties so that we can CLI override it for nightly builds.
|
||||
version = project.findProperty('projectVersion')
|
||||
|
||||
//Java 27 is required for this version of the Project Panama preview/incubator feature
|
||||
java {
|
||||
@@ -168,7 +132,8 @@ dependencies {
|
||||
implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:radio-reference-api:18.1.0' //RadioReference SOAP API - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:jsignalhound:2026.10.05' //Signal Hound tuners - GitHub release, see settings.gradle
|
||||
implementation 'io.github.dsheirer:radio-reference-api:18.0.0'
|
||||
implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib
|
||||
implementation 'org.apache.commons:commons-lang3:3.21.0'
|
||||
implementation 'org.apache.commons:commons-math3:3.6.1'
|
||||
@@ -238,7 +203,6 @@ jar {
|
||||
attributes (
|
||||
'Implementation-Title' : 'sdrtrunk project',
|
||||
'Implementation-Version' : archiveVersion,
|
||||
'Git-Describe' : gitDescribe ?: 'unknown',
|
||||
'Created-By' : "Gradle ${gradle.gradleVersion}",
|
||||
'Build-Timestamp' : new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()),
|
||||
'Build-JDK' : "${System.properties['java.version']} (${System.properties['java.vendor']} ${System.properties['java.vm.version']}",
|
||||
|
||||
@@ -17,16 +17,15 @@
|
||||
# *****************************************************************************
|
||||
#
|
||||
|
||||
# The project version is no longer defined here. It is derived from the most recent 'v<version>' git tag (see
|
||||
# build.gradle) and can be overridden with -PprojectVersion=<version>.
|
||||
# Version naming conventions:
|
||||
# Alpha: maj.min.patch-alpha-build
|
||||
# Beta: maj.min.patch-beta-build
|
||||
# Release: maj.min.patch
|
||||
#
|
||||
# Version tag naming conventions:
|
||||
# Alpha: vmaj.min.patch-alpha-build
|
||||
# Beta: vmaj.min.patch-beta-build
|
||||
# Release: vmaj.min.patch
|
||||
# Example: 0.6.0-beta-5
|
||||
#
|
||||
# Example: v0.6.0-beta-5
|
||||
#
|
||||
# Note: this is so that the file names for the resultant release build products match the GitHub release asset
|
||||
# Note: this is so that the file names for the resultant release build products match the GitLab release asset
|
||||
# tag name, specifically the dashes inserted before and after the alpha/beta labels.
|
||||
# See: https://github.com/DSheirer/sdrtrunk/issues/1651
|
||||
|
||||
projectVersion=1.0.0-beta-1
|
||||
@@ -75,6 +75,15 @@ dependencyResolutionManagement {
|
||||
content { includeModule 'io.github.dsheirer', 'jsdrplay' }
|
||||
}
|
||||
|
||||
//jSignalHound is published as a GitHub release. The jar does not contain the Signal Hound API native libraries,
|
||||
//which users install separately from the Signal Hound SDK
|
||||
ivy {
|
||||
url = 'https://github.com/DSheirer/jSignalHound/releases/download'
|
||||
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
|
||||
metadataSources { artifact() }
|
||||
content { includeModule 'io.github.dsheirer', 'jsignalhound' }
|
||||
}
|
||||
|
||||
//jBladeRF is published as a GitHub release, with the native libraries for all platforms in the jar
|
||||
ivy {
|
||||
url = 'https://github.com/DSheirer/jBladeRF/releases/download'
|
||||
@@ -82,14 +91,5 @@ dependencyResolutionManagement {
|
||||
metadataSources { artifact() }
|
||||
content { includeModule 'io.github.dsheirer', 'jbladerf' }
|
||||
}
|
||||
|
||||
//radio-reference-api is published as a GitHub release. Its dependencies (jackson-dataformat-xml, slf4j-api)
|
||||
//are not pulled in transitively and are declared directly in build.gradle
|
||||
ivy {
|
||||
url = 'https://github.com/DSheirer/radio-reference-api/releases/download'
|
||||
patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' }
|
||||
metadataSources { artifact() }
|
||||
content { includeModule 'io.github.dsheirer', 'radio-reference-api' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
|
||||
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.thinline.ThinlineRadioConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.icecast.IcecastConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.shoutcast.v1.ShoutcastV1Configuration;
|
||||
@@ -50,7 +49,6 @@ import java.net.SocketAddress;
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = BroadcastifyCallConfiguration.class, name="broadcastifyCallConfiguration"),
|
||||
@JsonSubTypes.Type(value = RdioScannerConfiguration.class, name="RdioScannerConfiguration"),
|
||||
@JsonSubTypes.Type(value = ThinlineRadioConfiguration.class, name="ThinlineRadioConfiguration"),
|
||||
@JsonSubTypes.Type(value = OpenMHzConfiguration.class, name="OpenMHzConfiguration"),
|
||||
@JsonSubTypes.Type(value = IcecastConfiguration.class, name="icecastConfiguration"),
|
||||
@JsonSubTypes.Type(value = ShoutcastV1Configuration.class, name="shoutcastV1Configuration"),
|
||||
|
||||
@@ -24,7 +24,6 @@ import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyCallConfigura
|
||||
import io.github.dsheirer.audio.broadcast.broadcastify.BroadcastifyFeedConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerBroadcaster;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.thinline.ThinlineRadioConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerFeedConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzBroadcaster;
|
||||
import io.github.dsheirer.audio.broadcast.openmhz.OpenMHzConfiguration;
|
||||
@@ -69,7 +68,6 @@ public class BroadcastFactory
|
||||
return new BroadcastifyCallBroadcaster((BroadcastifyCallConfiguration)configuration,
|
||||
inputAudioFormat, mp3Setting, aliasModel);
|
||||
case RDIOSCANNER_CALL:
|
||||
case THINLINE_RADIO:
|
||||
return new RdioScannerBroadcaster((RdioScannerConfiguration)configuration,
|
||||
inputAudioFormat, mp3Setting, aliasModel);
|
||||
case OPENMHZ:
|
||||
@@ -115,8 +113,6 @@ public class BroadcastFactory
|
||||
return new BroadcastifyCallConfiguration(format);
|
||||
case RDIOSCANNER_CALL:
|
||||
return new RdioScannerConfiguration(format);
|
||||
case THINLINE_RADIO:
|
||||
return new ThinlineRadioConfiguration(format);
|
||||
case BROADCASTIFY:
|
||||
return new BroadcastifyFeedConfiguration(format);
|
||||
case OPENMHZ:
|
||||
|
||||
@@ -32,7 +32,6 @@ public enum BroadcastServerType
|
||||
|
||||
ICECAST_HTTP("Icecast 2 (v2.4+)", "images/icecast.png"),
|
||||
RDIOSCANNER_CALL("Rdio Scanner", "images/rdioscanner.png"),
|
||||
THINLINE_RADIO("Thinline Radio", "images/rdioscanner.png"),
|
||||
OPENMHZ("OpenMHz", "images/openmhz.png"),
|
||||
ICECAST_TCP("Icecast (v2.3)", "images/icecast.png"),
|
||||
SHOUTCAST_V1("Shoutcast v1.x", "images/shoutcast.png"),
|
||||
|
||||
@@ -113,8 +113,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
}
|
||||
else
|
||||
{
|
||||
mLog.error("Error connecting to " + getBroadcastConfiguration().getServiceName() +
|
||||
" server on startup [" + response + "]");
|
||||
mLog.error("Error connecting to Rdio Scanner server on startup [" + response + "]");
|
||||
setBroadcastState(BroadcastState.ERROR);
|
||||
}
|
||||
|
||||
@@ -250,8 +249,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() +
|
||||
" API - audio recording file not found - ignoring upload");
|
||||
mLog.error("Rdio Scanner API - audio recording file not found - ignoring upload");
|
||||
}
|
||||
|
||||
if(audioBytes != null)
|
||||
@@ -288,14 +286,14 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
//We get socket reset exceptions occasionally when the remote server doesn't
|
||||
//fully read our request and immediately responds.
|
||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
|
||||
mLog.error("Rdio Scanner API file upload fail [" +
|
||||
fileResponse.statusCode() + "] response [" +
|
||||
fileResponse.body() + "]");
|
||||
}
|
||||
else
|
||||
{
|
||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
|
||||
mLog.error("Rdio Scanner API file upload fail [" +
|
||||
fileResponse.statusCode() + "] response [" +
|
||||
fileResponse.body() + "]");
|
||||
}
|
||||
@@ -323,7 +321,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
else
|
||||
{
|
||||
setBroadcastState(BroadcastState.TEMPORARY_BROADCAST_ERROR);
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API file upload fail [" +
|
||||
mLog.error("Rdio Scanner API file upload fail [" +
|
||||
fileResponse.statusCode() + "] response [" +
|
||||
fileResponse.body() + "]");
|
||||
}
|
||||
@@ -336,7 +334,7 @@ public class RdioScannerBroadcaster extends AbstractAudioBroadcaster<RdioScanner
|
||||
else
|
||||
{
|
||||
//Register an error for the file not found exception
|
||||
mLog.error(getBroadcastConfiguration().getServiceName() + " API - upload file not found [" +
|
||||
mLog.error("Rdio Scanner API - upload file not found [" +
|
||||
audioRecording.getPath().toString() + "]");
|
||||
incrementErrorAudioCount();
|
||||
broadcast(new BroadcastEvent(RdioScannerBroadcaster.this,
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
package io.github.dsheirer.audio.broadcast.rdioscanner;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
|
||||
@@ -126,15 +125,6 @@ public class RdioScannerConfiguration extends BroadcastConfiguration
|
||||
return BroadcastServerType.RDIOSCANNER_CALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Product name written to connection and upload logs.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public String getServiceName()
|
||||
{
|
||||
return "Rdio Scanner";
|
||||
}
|
||||
|
||||
@Override
|
||||
public BroadcastConfiguration copyOf()
|
||||
{
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.audio.broadcast.thinline;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastConfiguration;
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastFormat;
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
|
||||
import io.github.dsheirer.audio.broadcast.rdioscanner.RdioScannerConfiguration;
|
||||
|
||||
/**
|
||||
* Call upload configuration for Thinline Radio.
|
||||
*
|
||||
* Thinline Radio accepts the same completed-call upload used by Rdio Scanner, including the system id, API key,
|
||||
* and /api/call-upload path.
|
||||
*/
|
||||
public class ThinlineRadioConfiguration extends RdioScannerConfiguration
|
||||
{
|
||||
/**
|
||||
* Constructor for Jackson
|
||||
*/
|
||||
public ThinlineRadioConfiguration()
|
||||
{
|
||||
this(BroadcastFormat.MP3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public constructor.
|
||||
* @param format to use for audio recording (MP3)
|
||||
*/
|
||||
public ThinlineRadioConfiguration(BroadcastFormat format)
|
||||
{
|
||||
super(format);
|
||||
}
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "type", namespace = "http://www.w3.org/2001/XMLSchema-instance")
|
||||
@Override
|
||||
public BroadcastServerType getBroadcastServerType()
|
||||
{
|
||||
return BroadcastServerType.THINLINE_RADIO;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@Override
|
||||
public String getServiceName()
|
||||
{
|
||||
return "Thinline Radio";
|
||||
}
|
||||
|
||||
@Override
|
||||
public BroadcastConfiguration copyOf()
|
||||
{
|
||||
ThinlineRadioConfiguration copy = new ThinlineRadioConfiguration();
|
||||
copy.setSystemID(getSystemID());
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public class NowPlayingPanel extends JPanel
|
||||
mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel());
|
||||
mMessageActivityPanel = new MessageActivityPanel(userPreferences);
|
||||
mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences);
|
||||
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager);
|
||||
mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager);
|
||||
mDetailTabsVisible = detailTabsVisible;
|
||||
|
||||
init();
|
||||
|
||||
@@ -25,7 +25,6 @@ import io.github.dsheirer.channel.metadata.NowPlayingPanel;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.gui.playlist.ViewPlaylistRequest;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||
import io.github.dsheirer.map.MapPanel;
|
||||
import io.github.dsheirer.map.MapService;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
@@ -64,9 +63,7 @@ public class ControllerPanel extends JPanel
|
||||
mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager,
|
||||
playlistManager.getAliasModel());
|
||||
mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible);
|
||||
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences,
|
||||
playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null,
|
||||
new RadioReferencePlaylistActions(playlistManager));
|
||||
mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, settingsManager);
|
||||
mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences);
|
||||
|
||||
init();
|
||||
|
||||
@@ -153,14 +153,6 @@ public class ChannelProcessingManager implements Listener<ChannelEvent>
|
||||
return !mProcessingChainsMap.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels that currently have a processing chain, including temporary traffic channels.
|
||||
*/
|
||||
public List<Channel> getProcessingChannels()
|
||||
{
|
||||
return new ArrayList<>(mProcessingChainsMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current processing chain associated with the channel, or
|
||||
* null if a processing chain is not currently setup for the channel
|
||||
|
||||
@@ -68,29 +68,6 @@ public class Golay24
|
||||
*/
|
||||
public static int checkAndCorrect(CorrectedBinaryMessage message, int startIndex)
|
||||
{
|
||||
//The correction below indexes the word from bit 0 and takes the parity over the whole message, so it is only
|
||||
//correct for a message that is exactly the 24-bit word. Decode any other word in a 24-bit copy.
|
||||
if(startIndex != 0 || message.size() != 24)
|
||||
{
|
||||
CorrectedBinaryMessage word = new CorrectedBinaryMessage(24);
|
||||
int original = message.getInt(startIndex, startIndex + 23);
|
||||
word.load(0, 24, original);
|
||||
int result = checkAndCorrectWord(word);
|
||||
int corrected = word.getInt(0, 23);
|
||||
message.load(startIndex, 24, corrected);
|
||||
message.incrementCorrectedBitCount(Integer.bitCount(original ^ corrected));
|
||||
return result;
|
||||
}
|
||||
|
||||
return checkAndCorrectWord(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error detection and correction of a message that is exactly one 24-bit word.
|
||||
*/
|
||||
private static int checkAndCorrectWord(CorrectedBinaryMessage message)
|
||||
{
|
||||
int startIndex = 0;
|
||||
boolean parityError = message.cardinality() % 2 != 0;
|
||||
|
||||
int syndrome = getSyndrome(message, startIndex);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
* Copyright (C) 2014-2025 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
|
||||
@@ -51,7 +51,6 @@ 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;
|
||||
@@ -60,8 +59,7 @@ import io.github.dsheirer.source.tuner.manager.TunerManager;
|
||||
import io.github.dsheirer.source.tuner.ui.TunerSpectralDisplayManager;
|
||||
import io.github.dsheirer.spectrum.DisableSpectrumWaterfallMenuItem;
|
||||
import io.github.dsheirer.spectrum.ShowTunerMenuItem;
|
||||
import io.github.dsheirer.gui.spectrum.SpectralDisplayPanel;
|
||||
import io.github.dsheirer.util.DuplicateLibraryDetector;
|
||||
import io.github.dsheirer.spectrum.SpectralDisplayPanel;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import io.github.dsheirer.util.TimeStamp;
|
||||
import io.github.dsheirer.vector.calibrate.CalibrationManager;
|
||||
@@ -83,7 +81,6 @@ import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.prefs.Preferences;
|
||||
import javafx.application.Platform;
|
||||
@@ -156,17 +153,6 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
mApplicationLog = new ApplicationLog(mUserPreferences);
|
||||
mApplicationLog.start();
|
||||
|
||||
Map<String,List<String>> duplicateLibraries = DuplicateLibraryDetector.findDuplicates();
|
||||
|
||||
if(!duplicateLibraries.isEmpty())
|
||||
{
|
||||
mLog.warn("More than one version of these libraries is in the library folder [" +
|
||||
DuplicateLibraryDetector.getFolder(duplicateLibraries) + "]. This happens when a new version of " +
|
||||
"sdrtrunk is unzipped over an older one, and it can load the older library code. Delete the " +
|
||||
"sdrtrunk folder and unzip the release into an empty folder.\n" +
|
||||
DuplicateLibraryDetector.describe(duplicateLibraries));
|
||||
}
|
||||
|
||||
mResourceMonitor = new ResourceMonitor(mUserPreferences);
|
||||
|
||||
ThreadPool.logSettings();
|
||||
@@ -185,15 +171,6 @@ 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);
|
||||
@@ -242,13 +219,10 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
mSettingsManager, mTunerManager, mUserPreferences, mNowPlayingDetailsVisible);
|
||||
}
|
||||
|
||||
if(!headless)
|
||||
{
|
||||
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
|
||||
}
|
||||
mSpectralPanel = new SpectralDisplayPanel(mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
|
||||
|
||||
TunerSpectralDisplayManager tunerSpectralDisplayManager = new TunerSpectralDisplayManager(mSpectralPanel,
|
||||
mPlaylistManager, mTunerManager.getDiscoveredTunerModel());
|
||||
mPlaylistManager, mSettingsManager, mTunerManager.getDiscoveredTunerModel());
|
||||
mTunerManager.getDiscoveredTunerModel().addListener(tunerSpectralDisplayManager);
|
||||
mTunerManager.getDiscoveredTunerModel().addListener(this);
|
||||
|
||||
@@ -279,11 +253,6 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
{
|
||||
updateTitle(tuner.getPreferredName());
|
||||
}
|
||||
|
||||
if(!duplicateLibraries.isEmpty())
|
||||
{
|
||||
showDuplicateLibrariesDialog(duplicateLibraries);
|
||||
}
|
||||
}
|
||||
|
||||
if(calibrating && !GraphicsEnvironment.isHeadless())
|
||||
@@ -653,14 +622,14 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
(mMainGui.getExtendedState() & JFrame.MAXIMIZED_BOTH) == JFrame.MAXIMIZED_BOTH);
|
||||
mUserPreferences.getSwingPreference().setDimension(SPECTRAL_PANEL_IDENTIFIER, mSpectralPanel.getSize());
|
||||
mUserPreferences.getSwingPreference().setDimension(CONTROLLER_PANEL_IDENTIFIER, mControllerPanel.getSize());
|
||||
mLog.info("Stopping spectral display ...");
|
||||
mSpectralPanel.dispose();
|
||||
mJavaFxWindowManager.shutdown();
|
||||
mLog.info("Stopping channels ...");
|
||||
mPlaylistManager.getChannelProcessingManager().shutdown();
|
||||
mAudioRecordingManager.stop();
|
||||
mResourceMonitor.stop();
|
||||
|
||||
mLog.info("Stopping spectral display ...");
|
||||
mSpectralPanel.clearTuner();
|
||||
mLog.info("Stopping tuners ...");
|
||||
mTunerManager.stop();
|
||||
mLog.info("Shutdown complete.");
|
||||
@@ -829,7 +798,7 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
updateTitle(null);
|
||||
break;
|
||||
case NOTIFICATION_SHUTTING_DOWN:
|
||||
Tuner currentTuner = mSpectralPanel != null ? mSpectralPanel.getTuner() : null;
|
||||
Tuner currentTuner = mSpectralPanel.getTuner();
|
||||
|
||||
if(event.hasTuner() && event.getTuner().equals(currentTuner) || currentTuner == null)
|
||||
{
|
||||
@@ -940,28 +909,6 @@ public class SDRTrunk implements Listener<TunerEvent>
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns the user that the library folder holds more than one version of some libraries, which happens when a new
|
||||
* release is unzipped over an older one. The launch scripts put every jar in the library folder on the class
|
||||
* path, so the older library code can load instead of the newer code.
|
||||
* @param duplicates library name to jar paths, from DuplicateLibraryDetector
|
||||
*/
|
||||
private void showDuplicateLibrariesDialog(Map<String,List<String>> duplicates)
|
||||
{
|
||||
String message = "<html><body style='width: 450px'>" +
|
||||
"<p>The sdrtrunk library folder has more than one version of these libraries:</p><br>" +
|
||||
"<pre>" + DuplicateLibraryDetector.describe(duplicates) + "</pre>" +
|
||||
"<p>This happens when a new version of sdrtrunk is unzipped over an older one. sdrtrunk can load " +
|
||||
"the older library code, which can cause crashes or errors that the newer version fixed.</p><br>" +
|
||||
"<p>Close sdrtrunk, delete the sdrtrunk folder that contains this library folder and unzip the " +
|
||||
"release into a new, empty folder:</p><br>" +
|
||||
"<p><b>" + DuplicateLibraryDetector.getFolder(duplicates) + "</b></p><br>" +
|
||||
"<p>Your playlist and settings are stored in your user folder and won't be affected.</p>" +
|
||||
"</body></html>";
|
||||
|
||||
JOptionPane.showMessageDialog(mMainGui, message, "Duplicate Library Versions", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the application.
|
||||
*/
|
||||
|
||||
@@ -22,8 +22,6 @@ package io.github.dsheirer.gui.channel;
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource;
|
||||
import io.github.dsheirer.gui.power.SignalPowerView;
|
||||
import io.github.dsheirer.gui.spectrum.ChannelSpectralDisplay;
|
||||
import io.github.dsheirer.gui.spectrum.DftSpectralDisplay;
|
||||
import io.github.dsheirer.gui.squelch.NoiseSquelchView;
|
||||
import io.github.dsheirer.gui.symbol.SymbolView;
|
||||
import io.github.dsheirer.gui.theme.ThemeManager;
|
||||
@@ -35,14 +33,23 @@ import io.github.dsheirer.module.decode.nbfm.NBFMDecoder;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule;
|
||||
import io.github.dsheirer.settings.SettingsManager;
|
||||
import io.github.dsheirer.source.ChannelFrequencyCorrectionStatusNotification;
|
||||
import io.github.dsheirer.source.Source;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import io.github.dsheirer.source.tuner.channel.HalfBandTunerChannelSource;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannel;
|
||||
import io.github.dsheirer.source.tuner.channel.TunerChannelSource;
|
||||
import io.github.dsheirer.spectrum.ComplexDftProcessor;
|
||||
import io.github.dsheirer.spectrum.FrequencyOverlayPanel;
|
||||
import io.github.dsheirer.spectrum.SpectrumPanel;
|
||||
import io.github.dsheirer.spectrum.converter.ComplexDecibelConverter;
|
||||
import io.github.dsheirer.spectrum.converter.DFTResultsConverter;
|
||||
import java.awt.Component;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.event.ComponentEvent;
|
||||
import java.awt.event.ComponentListener;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.net.URL;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.List;
|
||||
@@ -56,8 +63,12 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JLayeredPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import javax.swing.event.MouseInputAdapter;
|
||||
|
||||
/**
|
||||
* Display for channel FFT and squelch details
|
||||
@@ -74,20 +85,18 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
private final PlaylistManager mPlaylistManager;
|
||||
private ProcessingChain mProcessingChain;
|
||||
private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule();
|
||||
private final JFXPanel mSpectralPanel = new JFXPanel();
|
||||
//Created on the JavaFX thread - guarded by this. Channel state is kept here until the display exists.
|
||||
private ChannelSpectralDisplay mChannelSpectralDisplay;
|
||||
private long mChannelFrequency;
|
||||
private int mChannelSampleRate;
|
||||
private int mChannelBandwidth;
|
||||
private Long mCarrierOffset;
|
||||
private final ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor();
|
||||
private SpectrumPanel mSpectrumPanel;
|
||||
private final FrequencyOverlayPanel mFrequencyOverlayPanel;
|
||||
private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor();
|
||||
private final SpinnerNumberModel mNoiseFloorSpinnerModel;
|
||||
private final JLabel mTunerPPMLabel;
|
||||
private final JLabel mTunerCorrectionLabel;
|
||||
private final JLabel mChannelCorrectionLabel;
|
||||
private final JLabel mDecoderCorrectionLabel;
|
||||
private final JLabel mDummyLabel = new JLabel(" ");
|
||||
private boolean mPanelVisible = false;
|
||||
private boolean mDftProcessing = false;
|
||||
private final NoiseSquelchView mNoiseSquelchView;
|
||||
private final SignalPowerView mSignalPowerView;
|
||||
private final SymbolView mSymbolView = new SymbolView();
|
||||
@@ -99,7 +108,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public ChannelSpectrumPanel(PlaylistManager playlistManager)
|
||||
public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager)
|
||||
{
|
||||
mPlaylistManager = playlistManager;
|
||||
mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager);
|
||||
@@ -115,7 +124,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]"));
|
||||
|
||||
JPanel labelPanel = new JPanel();
|
||||
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill]", ""));
|
||||
labelPanel.setLayout(new MigLayout("insets 2", "[grow,fill][][][][][grow,fill][][]", ""));
|
||||
|
||||
mTunerPPMLabel = new JLabel(LABEL_PREFIX_PPM);
|
||||
mTunerPPMLabel.setEnabled(false);
|
||||
@@ -137,6 +146,15 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
|
||||
labelPanel.add(mDummyLabel);
|
||||
|
||||
mNoiseFloorSpinnerModel = new SpinnerNumberModel(18, 8, 36, 1);
|
||||
mNoiseFloorSpinnerModel.addChangeListener(e -> {
|
||||
Number number = mNoiseFloorSpinnerModel.getNumber();
|
||||
mSpectrumPanel.setSampleSize(number.doubleValue());
|
||||
});
|
||||
JSpinner noiseFloorSpinner = new JSpinner(mNoiseFloorSpinnerModel);
|
||||
labelPanel.add(new JLabel("Noise Floor:"));
|
||||
labelPanel.add(noiseFloorSpinner);
|
||||
|
||||
JButton logIndexesButton = new JButton("Log Settings");
|
||||
logIndexesButton.addActionListener(e -> {
|
||||
if(mProcessingChain != null)
|
||||
@@ -181,16 +199,32 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
|
||||
fftPanel.add(labelPanel, "wrap");
|
||||
|
||||
fftPanel.add(mSpectralPanel);
|
||||
ThemeManager.getInstance().registerSwing(mSpectralPanel);
|
||||
mFrequencyOverlayPanel = new FrequencyOverlayPanel(settingsManager);
|
||||
mSpectrumPanel = new SpectrumPanel(settingsManager);
|
||||
mSpectrumPanel.setSampleSize(18.0);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
ChannelSpectralDisplay display = new ChannelSpectralDisplay();
|
||||
Scene scene = new Scene(display);
|
||||
ThemeManager.getInstance().register(scene);
|
||||
mSpectralPanel.setScene(scene);
|
||||
displayCreated(display);
|
||||
});
|
||||
/**
|
||||
* The layered pane holds the overlapping spectrum and channel panels
|
||||
* and manages the sizing of each panel with the resize listener
|
||||
*/
|
||||
JLayeredPane layeredPanel = new JLayeredPane();
|
||||
layeredPanel.addComponentListener(new ResizeListener());
|
||||
|
||||
/**
|
||||
* Create a mouse adapter to handle mouse events over the spectrum
|
||||
* and waterfall panels
|
||||
*/
|
||||
MouseEventProcessor mouser = new MouseEventProcessor();
|
||||
|
||||
mFrequencyOverlayPanel.addMouseListener(mouser);
|
||||
mFrequencyOverlayPanel.addMouseMotionListener(mouser);
|
||||
mFrequencyOverlayPanel.addMouseWheelListener(mouser);
|
||||
|
||||
//Add the spectrum and channel panels to the layered panel
|
||||
layeredPanel.add(mSpectrumPanel, 0, 0);
|
||||
layeredPanel.add(mFrequencyOverlayPanel, 1, 0);
|
||||
|
||||
fftPanel.add(layeredPanel);
|
||||
|
||||
mNoiseSquelchPanel = new JFXPanel();
|
||||
mSymbolPanel = new JFXPanel();
|
||||
@@ -215,8 +249,6 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
}
|
||||
|
||||
ThemeManager.getInstance().register(scene2);
|
||||
//Use the same color theme as the channel spectral display
|
||||
mSymbolView.followTheme(DftSpectralDisplay.getSharedTheme());
|
||||
mSymbolPanel.setScene(scene2);
|
||||
});
|
||||
|
||||
@@ -226,49 +258,11 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
add(mSplitPane);
|
||||
// mSplitPane.setDividerLocation(0.5);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the spectral display once it's created on the JavaFX thread and applies the current channel state.
|
||||
*/
|
||||
private synchronized void displayCreated(ChannelSpectralDisplay display)
|
||||
{
|
||||
mChannelSpectralDisplay = display;
|
||||
|
||||
if(mChannelSampleRate > 0)
|
||||
{
|
||||
display.setChannel(mChannelFrequency, mChannelSampleRate, mChannelBandwidth);
|
||||
}
|
||||
|
||||
if(mCarrierOffset != null)
|
||||
{
|
||||
display.setCarrierOffset(mCarrierOffset);
|
||||
}
|
||||
|
||||
updateFFTProcessing();
|
||||
}
|
||||
|
||||
private synchronized void setChannel(long frequency, int sampleRate, int bandwidth)
|
||||
{
|
||||
mChannelFrequency = frequency;
|
||||
mChannelSampleRate = sampleRate;
|
||||
mChannelBandwidth = bandwidth;
|
||||
mCarrierOffset = null;
|
||||
|
||||
if(mChannelSpectralDisplay != null)
|
||||
{
|
||||
mChannelSpectralDisplay.setChannel(frequency, sampleRate, bandwidth);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void setCarrierOffset(long offset)
|
||||
{
|
||||
mCarrierOffset = offset;
|
||||
|
||||
if(mChannelSpectralDisplay != null)
|
||||
{
|
||||
mChannelSpectralDisplay.setCarrierOffset(offset);
|
||||
}
|
||||
mSampleStreamTapModule.setListener(mComplexDftProcessor);
|
||||
DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter();
|
||||
mComplexDftProcessor.addConverter(DFTResultsConverter);
|
||||
DFTResultsConverter.addListener(mSpectrumPanel);
|
||||
mSpectrumPanel.clearSpectrum();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,28 +295,46 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates processing state for the spectral display. Turns on DFT processing when we have a processing chain and
|
||||
* Updates processing state for the DFT processor. Turns on DFT processing when we have a processing chain and
|
||||
* when the user has this tab selected and visible. Otherwise, turns off DFT processing.
|
||||
*/
|
||||
private synchronized void updateFFTProcessing()
|
||||
private void updateFFTProcessing()
|
||||
{
|
||||
if(mChannelSpectralDisplay == null)
|
||||
if(mPanelVisible && mProcessingChain != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
boolean active = mPanelVisible && mProcessingChain != null;
|
||||
|
||||
if(active)
|
||||
{
|
||||
mSampleStreamTapModule.setListener(mChannelSpectralDisplay.getSampleListener());
|
||||
startDftProcessing();
|
||||
}
|
||||
else
|
||||
{
|
||||
mSampleStreamTapModule.removeListener();
|
||||
stopDftProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
mChannelSpectralDisplay.setActive(active);
|
||||
/**
|
||||
* Starts DFT processing
|
||||
*/
|
||||
private void startDftProcessing()
|
||||
{
|
||||
if(!mDftProcessing)
|
||||
{
|
||||
mDftProcessing = true;
|
||||
mSampleStreamTapModule.setListener(mComplexDftProcessor);
|
||||
mComplexDftProcessor.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops DFT processing
|
||||
*/
|
||||
private void stopDftProcessing()
|
||||
{
|
||||
if(mDftProcessing)
|
||||
{
|
||||
mSampleStreamTapModule.removeListener();
|
||||
mComplexDftProcessor.stop();
|
||||
mSpectrumPanel.clearSpectrum();
|
||||
mDftProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcast(SourceEvent sourceEvent)
|
||||
@@ -347,7 +359,10 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
mChannelCorrectionLabel.setEnabled(false);
|
||||
mDecoderCorrectionLabel.setText(LABEL_PREFIX_DECODER);
|
||||
mDecoderCorrectionLabel.setEnabled(false);
|
||||
setChannel(0, 0, 0);
|
||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, 0));
|
||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(0));
|
||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(0);
|
||||
mFrequencyOverlayPanel.setChannelBandwidth(0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,7 +414,12 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
mProcessingChain.addModule(mSampleStreamTapModule);
|
||||
Source source = mProcessingChain.getSource();
|
||||
|
||||
int bandwidth = 0;
|
||||
if(source instanceof TunerChannelSource tcs)
|
||||
{
|
||||
mFrequencyOverlayPanel.process(SourceEvent.frequencyChange(null, tcs.getFrequency()));
|
||||
mFrequencyOverlayPanel.process(SourceEvent.sampleRateChange(tcs.getSampleRate()));
|
||||
}
|
||||
|
||||
Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain);
|
||||
|
||||
if(channel != null)
|
||||
@@ -408,14 +428,9 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
|
||||
if(!tunerChannels.isEmpty())
|
||||
{
|
||||
bandwidth = tunerChannels.getFirst().getBandwidth();
|
||||
mFrequencyOverlayPanel.setChannelBandwidth(tunerChannels.getFirst().getBandwidth());
|
||||
}
|
||||
}
|
||||
|
||||
if(source instanceof TunerChannelSource tcs)
|
||||
{
|
||||
setChannel(tcs.getFrequency(), (int)tcs.getSampleRate(), bandwidth);
|
||||
}
|
||||
}
|
||||
|
||||
updateFFTProcessing();
|
||||
@@ -471,7 +486,7 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
|
||||
//We se the offset cursor line to negative so that it aligns with where the signal is located
|
||||
//instead of the correction that's needed to bring the signal to zero.
|
||||
setCarrierOffset(-status.getDecoderCorrection());
|
||||
mFrequencyOverlayPanel.setEstimatedCarrierOffsetFrequency(-status.getDecoderCorrection());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -480,4 +495,52 @@ public class ChannelSpectrumPanel extends JPanel implements Listener<ProcessingC
|
||||
mSignalPowerView.receive(sourceEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors the sizing of the layered pane and resizes the spectrum and
|
||||
* channel panels whenever the layered pane is resized
|
||||
*/
|
||||
public class ResizeListener implements ComponentListener
|
||||
{
|
||||
@Override public void componentResized(ComponentEvent e)
|
||||
{
|
||||
Component c = e.getComponent();
|
||||
|
||||
mSpectrumPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
|
||||
mFrequencyOverlayPanel.setBounds(0, 0, c.getWidth(), c.getHeight());
|
||||
}
|
||||
|
||||
@Override public void componentHidden(ComponentEvent arg0) {}
|
||||
@Override public void componentMoved(ComponentEvent arg0) {}
|
||||
@Override public void componentShown(ComponentEvent arg0) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse event handler for the spectral display panel.
|
||||
*/
|
||||
public class MouseEventProcessor extends MouseInputAdapter
|
||||
{
|
||||
@Override public void mouseMoved(MouseEvent event)
|
||||
{
|
||||
update(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cursor display while the mouse is performing actions
|
||||
*/
|
||||
private void update(MouseEvent event)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorLocation(event.getPoint());
|
||||
}
|
||||
|
||||
@Override public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorVisible(true);
|
||||
}
|
||||
|
||||
@Override public void mouseExited(MouseEvent e)
|
||||
{
|
||||
mFrequencyOverlayPanel.setCursorVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
private EventLogConfigurationEditor mEventLogConfigurationEditor;
|
||||
private RecordConfigurationEditor mRecordConfigurationEditor;
|
||||
private ToggleSwitch mIgnoreDataCallsButton;
|
||||
private ToggleSwitch mIgnoreEncryptedCallsButton;
|
||||
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
||||
private SegmentedButton mModulationSegmentedButton;
|
||||
private ToggleButton mC4FMToggleButton;
|
||||
@@ -158,15 +157,6 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
GridPane.setConstraints(directionLabel, 5, row);
|
||||
gridPane.getChildren().add(directionLabel);
|
||||
|
||||
GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 6, row);
|
||||
gridPane.getChildren().add(getIgnoreEncryptedCallsButton());
|
||||
|
||||
Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls");
|
||||
ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton());
|
||||
GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT);
|
||||
GridPane.setConstraints(ignoreEncryptedLabel, 7, row);
|
||||
gridPane.getChildren().add(ignoreEncryptedLabel);
|
||||
|
||||
row++;
|
||||
|
||||
Label modulationHelpLabel = new Label("C4FM: repeaters and non-simulcast trunked systems. LSM: simulcast trunked systems.");
|
||||
@@ -353,21 +343,6 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
return mLSMToggleButton;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreEncryptedCallsButton()
|
||||
{
|
||||
if(mIgnoreEncryptedCallsButton == null)
|
||||
{
|
||||
mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch();
|
||||
mIgnoreEncryptedCallsButton.setDisable(true);
|
||||
mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " +
|
||||
"Phase 2: the channel is still tuned when the other timeslot has a clear call."));
|
||||
mIgnoreEncryptedCallsButton.selectedProperty()
|
||||
.addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
|
||||
}
|
||||
|
||||
return mIgnoreEncryptedCallsButton;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreDataCallsButton()
|
||||
{
|
||||
if(mIgnoreDataCallsButton == null)
|
||||
@@ -423,13 +398,11 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
protected void setDecoderConfiguration(DecodeConfiguration config)
|
||||
{
|
||||
getIgnoreDataCallsButton().setDisable(config == null);
|
||||
getIgnoreEncryptedCallsButton().setDisable(config == null);
|
||||
getTrafficChannelPoolSizeSpinner().setDisable(config == null);
|
||||
|
||||
if(config instanceof DecodeConfigP25Phase1 decodeConfig)
|
||||
{
|
||||
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
||||
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
|
||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
||||
if(decodeConfig.getModulation() == Modulation.C4FM)
|
||||
{
|
||||
@@ -454,7 +427,6 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
else
|
||||
{
|
||||
getIgnoreDataCallsButton().setSelected(false);
|
||||
getIgnoreEncryptedCallsButton().setSelected(false);
|
||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(0);
|
||||
getJmbeAGCButton().setDisable(true);
|
||||
getJmbeNoiseGainSlider().setDisable(true);
|
||||
@@ -482,7 +454,6 @@ public class P25P1ConfigurationEditor extends ChannelConfigurationEditor
|
||||
}
|
||||
|
||||
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
||||
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
|
||||
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
||||
config.setModulation(getC4FMToggleButton().isSelected() ? Modulation.C4FM : Modulation.CQPSK);
|
||||
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
||||
|
||||
@@ -71,7 +71,6 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
private IntegerTextField mSystemTextField;
|
||||
private IntegerTextField mNacTextField;
|
||||
private ToggleSwitch mIgnoreDataCallsButton;
|
||||
private ToggleSwitch mIgnoreEncryptedCallsButton;
|
||||
private Spinner<Integer> mTrafficChannelPoolSizeSpinner;
|
||||
private ToggleSwitch mJmbeAGCButton;
|
||||
private Slider mJmbeToneGainSlider;
|
||||
@@ -143,15 +142,6 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
GridPane.setConstraints(directionLabel, 3, row);
|
||||
gridPane.getChildren().add(directionLabel);
|
||||
|
||||
GridPane.setConstraints(getIgnoreEncryptedCallsButton(), 4, row);
|
||||
gridPane.getChildren().add(getIgnoreEncryptedCallsButton());
|
||||
|
||||
Label ignoreEncryptedLabel = new Label("Ignore Encrypted Calls");
|
||||
ignoreEncryptedLabel.setLabelFor(getIgnoreEncryptedCallsButton());
|
||||
GridPane.setHalignment(ignoreEncryptedLabel, HPos.LEFT);
|
||||
GridPane.setConstraints(ignoreEncryptedLabel, 5, row);
|
||||
gridPane.getChildren().add(ignoreEncryptedLabel);
|
||||
|
||||
Label wacnLabel = new Label("WACN");
|
||||
wacnLabel.setLabelFor(getWacnTextField());
|
||||
GridPane.setHalignment(wacnLabel, HPos.RIGHT);
|
||||
@@ -310,21 +300,6 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
return mEventLogConfigurationEditor;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreEncryptedCallsButton()
|
||||
{
|
||||
if(mIgnoreEncryptedCallsButton == null)
|
||||
{
|
||||
mIgnoreEncryptedCallsButton = new AccessibleToggleSwitch();
|
||||
mIgnoreEncryptedCallsButton.setDisable(true);
|
||||
mIgnoreEncryptedCallsButton.setTooltip(new Tooltip("Don't tune a traffic channel when the channel grant indicates the call is encrypted. " +
|
||||
"Phase 2: the channel is still tuned when the other timeslot has a clear call."));
|
||||
mIgnoreEncryptedCallsButton.selectedProperty()
|
||||
.addListener((observable, oldValue, newValue) -> modifiedProperty().set(true));
|
||||
}
|
||||
|
||||
return mIgnoreEncryptedCallsButton;
|
||||
}
|
||||
|
||||
private ToggleSwitch getIgnoreDataCallsButton()
|
||||
{
|
||||
if(mIgnoreDataCallsButton == null)
|
||||
@@ -442,8 +417,6 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
|
||||
getIgnoreDataCallsButton().setDisable(false);
|
||||
getIgnoreDataCallsButton().setSelected(decodeConfig.getIgnoreDataCalls());
|
||||
getIgnoreEncryptedCallsButton().setDisable(false);
|
||||
getIgnoreEncryptedCallsButton().setSelected(decodeConfig.getIgnoreEncryptedCalls());
|
||||
getTrafficChannelPoolSizeSpinner().setDisable(false);
|
||||
getTrafficChannelPoolSizeSpinner().getValueFactory().setValue(decodeConfig.getTrafficChannelPoolSize());
|
||||
getJmbeAGCButton().setSelected(decodeConfig.isAGC());
|
||||
@@ -460,8 +433,6 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
getSystemTextField().setDisable(true);
|
||||
getNacTextField().setDisable(true);
|
||||
getIgnoreDataCallsButton().setDisable(true);
|
||||
getIgnoreEncryptedCallsButton().setDisable(true);
|
||||
getIgnoreEncryptedCallsButton().setSelected(false);
|
||||
getTrafficChannelPoolSizeSpinner().setDisable(true);
|
||||
getJmbeAGCButton().setDisable(true);
|
||||
getJmbeNoiseGainSlider().setDisable(true);
|
||||
@@ -494,7 +465,6 @@ public class P25P2ConfigurationEditor extends ChannelConfigurationEditor
|
||||
int nac = getNacTextField().get();
|
||||
config.setScrambleParameters(new ScrambleParameters(wacn, system, nac));
|
||||
config.setIgnoreDataCalls(getIgnoreDataCallsButton().isSelected());
|
||||
config.setIgnoreEncryptedCalls(getIgnoreEncryptedCallsButton().isSelected());
|
||||
config.setTrafficChannelPoolSize(getTrafficChannelPoolSizeSpinner().getValue());
|
||||
config.setAGC(getJmbeAGCButton().selectedProperty().getValue());
|
||||
config.setComfortNoiseGain((float)getJmbeNoiseGainSlider().getValue());
|
||||
|
||||
@@ -39,6 +39,7 @@ 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;
|
||||
|
||||
@@ -91,9 +92,8 @@ public class AgencyEditor extends VBox
|
||||
|
||||
if(agencies != null && !agencies.isEmpty())
|
||||
{
|
||||
//The agencies list belongs to a cached state or county info shared with other callers, so sort the items
|
||||
Collections.sort(agencies, new AgencyComparator());
|
||||
getAgencyListView().getItems().addAll(agencies);
|
||||
getAgencyListView().getItems().sort(new AgencyComparator());
|
||||
|
||||
int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel);
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ 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;
|
||||
@@ -154,18 +153,19 @@ 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 (sorted.size() < 10) {
|
||||
sorted.add(0, ALL_CATEGORIES);
|
||||
if (categories.size() < 10) {
|
||||
categories.add(0, ALL_CATEGORIES);
|
||||
}
|
||||
getCategoryComboBox().getItems().addAll(sorted);
|
||||
getCategoryComboBox().getSelectionModel().select(sorted.get(0));
|
||||
getCategoryComboBox().getItems().addAll(categories);
|
||||
getCategoryComboBox().getSelectionModel().select(categories.get(0));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -441,17 +441,6 @@ 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);
|
||||
@@ -460,10 +449,7 @@ public class AgencyFrequencyEditor extends GridPane
|
||||
catch(Throwable t)
|
||||
{
|
||||
mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", t);
|
||||
Platform.runLater(() -> {
|
||||
setLoading(false);
|
||||
new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait();
|
||||
});
|
||||
Platform.runLater(() -> new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ public class FrequencyEditor extends VBox
|
||||
|
||||
if(item != null)
|
||||
{
|
||||
mFrequency = item.getDownlinkHz();
|
||||
mFrequency = (long)(item.getDownlink() * 1E6);
|
||||
getAlphaTagTextField().setText(item.getAlphaTag());
|
||||
getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink()));
|
||||
getToneTextField().setText(item.getTone());
|
||||
@@ -413,7 +413,46 @@ public class FrequencyEditor extends VBox
|
||||
*/
|
||||
private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name)
|
||||
{
|
||||
return RadioReferenceChannelFactory.conventional(modeDecoderType, frequency, system, site, 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;
|
||||
}
|
||||
|
||||
private TextField getSystemTextField()
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ 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;
|
||||
@@ -50,6 +49,7 @@ 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,8 +60,6 @@ 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;
|
||||
@@ -280,7 +278,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>()
|
||||
{
|
||||
@@ -318,26 +316,98 @@ public class LoginDialog extends Dialog<AuthorizationInformation>
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
Platform.runLater(new Runnable()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
loginStatus = RadioReference.testConnectionWithExp(userName, password);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
error = e;
|
||||
}
|
||||
boolean success = false;
|
||||
RadioReference.LoginStatus loginStatus;
|
||||
|
||||
final RadioReference.LoginStatus status = loginStatus;
|
||||
final Exception exception = error;
|
||||
Platform.runLater(() -> showTestConnectionResult(status, exception));
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -346,111 +416,6 @@ 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)
|
||||
|
||||
@@ -70,28 +70,8 @@ public enum ModeDecoderType
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Lookup the entry that matches the mode
|
||||
*
|
||||
* @param mode to match
|
||||
* @return matching entry or UNKNOWN
|
||||
*/
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,6 @@
|
||||
|
||||
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;
|
||||
@@ -392,7 +382,7 @@ public class RadioReferenceDecoder
|
||||
|
||||
TimeslotFrequency timeslotFrequency = new TimeslotFrequency();
|
||||
timeslotFrequency.setNumber(lcn);
|
||||
timeslotFrequency.setDownlinkFrequency(siteFrequency.getFrequencyHz());
|
||||
timeslotFrequency.setDownlinkFrequency((long)(siteFrequency.getFrequency() * 1E6));
|
||||
frequencies.add(timeslotFrequency);
|
||||
}
|
||||
|
||||
@@ -442,7 +432,7 @@ public class RadioReferenceDecoder
|
||||
}
|
||||
}
|
||||
|
||||
frequencies.add(new ChannelFrequency(lcn, siteFrequency.getFrequencyHz(), 0));
|
||||
frequencies.add(new ChannelFrequency(lcn, (long)(siteFrequency.getFrequency() * 1E6), 0));
|
||||
}
|
||||
|
||||
return frequencies;
|
||||
@@ -587,126 +577,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
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;
|
||||
@@ -35,7 +31,6 @@ 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;
|
||||
@@ -47,12 +42,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;
|
||||
@@ -63,7 +58,6 @@ 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>
|
||||
@@ -74,10 +68,6 @@ 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;
|
||||
@@ -123,41 +113,20 @@ 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()
|
||||
{
|
||||
//Logs in on a background thread, unless the spectral display has already logged in
|
||||
mRadioReference.loginWithStoredCredentials();
|
||||
if(mUserPreferences.getRadioReferencePreference().hasStoredCredentials())
|
||||
{
|
||||
AuthorizationInformation credentials = mUserPreferences.getRadioReferencePreference().getAuthorizationInformation();
|
||||
|
||||
if(credentials != null)
|
||||
{
|
||||
ThreadPool.CACHED.execute(() -> accept(credentials));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,7 +354,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(), getLocationBox(), getComboBoxPane());
|
||||
mTopBox.getChildren().addAll(getCredentialsBox(), getComboBoxPane());
|
||||
}
|
||||
|
||||
return mTopBox;
|
||||
@@ -447,7 +416,13 @@ 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:"));
|
||||
@@ -471,8 +446,8 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
Bindings.and(
|
||||
mRadioReference.availableProperty(),
|
||||
mRadioReference.premiumAccountProperty().not()));
|
||||
//Inline style, because the theme stylesheets override a label's text fill
|
||||
mRRStatusText.setStyle("-fx-text-fill: orangered; -fx-font-weight: bold;");
|
||||
mRRStatusText.setTextFill(Color.ORANGERED);
|
||||
mRRStatusText.setStyle("-fx-font-weight: bold;"); // I guess
|
||||
}
|
||||
|
||||
return mRRStatusText;
|
||||
@@ -750,7 +725,6 @@ public class RadioReferenceEditor extends BorderPane implements Consumer<Authori
|
||||
if(selected != null)
|
||||
{
|
||||
mUserPreferences.getRadioReferencePreference().setPreferredCountyId(selected.getCountyId());
|
||||
refreshLocation();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -758,111 +732,6 @@ 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
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,118 @@ public class SiteEditor extends GridPane
|
||||
*/
|
||||
private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation)
|
||||
{
|
||||
return mRadioReferenceDecoder.getDecodeConfiguration(decoderType, site, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,7 +621,7 @@ public class SiteEditor extends GridPane
|
||||
if(controls.size() == 1)
|
||||
{
|
||||
SourceConfigTuner sourceConfigTuner = new SourceConfigTuner();
|
||||
sourceConfigTuner.setFrequency(controls.get(0).getFrequencyHz());
|
||||
sourceConfigTuner.setFrequency((long)(controls.get(0).getFrequency() * 1E6));
|
||||
channel.setSourceConfiguration(sourceConfigTuner);
|
||||
}
|
||||
else
|
||||
@@ -519,7 +630,7 @@ public class SiteEditor extends GridPane
|
||||
|
||||
for(SiteFrequency control: controls)
|
||||
{
|
||||
sourceConfig.addFrequency(control.getFrequencyHz());
|
||||
sourceConfig.addFrequency((long)(control.getFrequency() * 1E6));
|
||||
}
|
||||
|
||||
channel.setSourceConfiguration(sourceConfig);
|
||||
@@ -745,7 +856,7 @@ public class SiteEditor extends GridPane
|
||||
|
||||
private static long getFrequency(SiteFrequency siteFrequency)
|
||||
{
|
||||
return siteFrequency.getFrequencyHz();
|
||||
return (long)(siteFrequency.getFrequency() * 1E6);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,9 +139,8 @@ public class SystemEditor extends VBox
|
||||
|
||||
if(systems != null && !systems.isEmpty())
|
||||
{
|
||||
//The systems list belongs to a cached state or county info shared with other callers, so sort the items
|
||||
Collections.sort(systems, new SystemComparator());
|
||||
getSystemComboBox().getItems().addAll(systems);
|
||||
getSystemComboBox().getItems().sort(new SystemComparator());
|
||||
|
||||
int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel);
|
||||
|
||||
@@ -303,16 +302,7 @@ public class SystemEditor extends VBox
|
||||
//have a county ID of 99,999 as observed from API (undocumented).
|
||||
if(countyId > 0 && countyId != 99999)
|
||||
{
|
||||
//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());
|
||||
}
|
||||
countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId());
|
||||
}
|
||||
|
||||
enrichedSites.add(new EnrichedSite(site, countyInfo));
|
||||
|
||||
@@ -264,11 +264,9 @@ public class SystemTalkgroupSelectionEditor extends GridPane
|
||||
|
||||
if(talkgroups != null && !talkgroups.isEmpty())
|
||||
{
|
||||
//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));
|
||||
Collections.sort(talkgroups, Comparator.comparingInt(Talkgroup::getDecimalValue));
|
||||
|
||||
for(Talkgroup talkgroup: sorted)
|
||||
for(Talkgroup talkgroup: talkgroups)
|
||||
{
|
||||
mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup)));
|
||||
}
|
||||
|
||||
@@ -119,22 +119,6 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
||||
return BroadcastServerType.RDIOSCANNER_CALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the server URL field. Subclasses can override this for another product that uses the same API.
|
||||
*/
|
||||
protected String getHostLabel()
|
||||
{
|
||||
return "RdioScanner URL";
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the credential field.
|
||||
*/
|
||||
protected String getApiKeyLabel()
|
||||
{
|
||||
return "API Key";
|
||||
}
|
||||
|
||||
protected GridPane getEditorPane()
|
||||
{
|
||||
if(mEditorPane == null)
|
||||
@@ -173,7 +157,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
||||
GridPane.setConstraints(getNameTextField(), 1, row);
|
||||
mEditorPane.getChildren().add(getNameTextField());
|
||||
|
||||
Label apiKeyLabel = new Label(getApiKeyLabel());
|
||||
Label apiKeyLabel = new Label("API Key");
|
||||
apiKeyLabel.setLabelFor(getApiKeyTextField());
|
||||
GridPane.setHalignment(apiKeyLabel, HPos.RIGHT);
|
||||
GridPane.setConstraints(apiKeyLabel, 0, ++row);
|
||||
@@ -191,7 +175,7 @@ public class RdioScannerEditor extends AbstractBroadcastEditor<RdioScannerConfig
|
||||
GridPane.setConstraints(getSystemIdTextField(), 1, row);
|
||||
mEditorPane.getChildren().add(getSystemIdTextField());
|
||||
|
||||
Label hostLabel = new Label(getHostLabel());
|
||||
Label hostLabel = new Label("RdioScanner URL");
|
||||
hostLabel.setLabelFor(getHostTextField());
|
||||
GridPane.setHalignment(hostLabel, HPos.RIGHT);
|
||||
GridPane.setConstraints(hostLabel, 0, ++row);
|
||||
|
||||
@@ -41,8 +41,6 @@ public class StreamEditorFactory
|
||||
return new BroadcastifyStreamEditor(playlistManager);
|
||||
case RDIOSCANNER_CALL:
|
||||
return new RdioScannerEditor(playlistManager);
|
||||
case THINLINE_RADIO:
|
||||
return new ThinlineRadioEditor(playlistManager);
|
||||
case OPENMHZ:
|
||||
return new OpenMHzEditor(playlistManager);
|
||||
case BROADCASTIFY_CALL:
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 io.github.dsheirer.buffer.INativeBuffer;
|
||||
import io.github.dsheirer.gui.spectrum.layer.ChannelTuningLayer;
|
||||
import io.github.dsheirer.properties.SystemProperties;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
/**
|
||||
* Spectral display for a single decoding channel's sample stream, centered on the channel frequency. The channel
|
||||
* tuning layer marks the channel passband and the decoder's estimated carrier position (PLL).
|
||||
*
|
||||
* Construct on the JavaFX application thread. The other methods can be called from any thread. Feed the channel's
|
||||
* complex sample buffers to getSampleListener() and turn processing on with setActive(true) while the display is
|
||||
* showing.
|
||||
*/
|
||||
public class ChannelSpectralDisplay extends DftSpectralDisplay
|
||||
{
|
||||
public static final String SETTINGS_PREFIX = "channel.spectral.display";
|
||||
public static final double DEFAULT_SAMPLE_SIZE = 18.0;
|
||||
public static final double MINIMUM_SAMPLE_SIZE = 8.0;
|
||||
public static final double MAXIMUM_SAMPLE_SIZE = 36.0;
|
||||
private static final String NOISE_FLOOR = "noise.floor";
|
||||
private static final String MESSAGE_NO_CHANNEL = "Select a decoding channel to view its spectrum";
|
||||
|
||||
private final ChannelTuningLayer mTuningLayer;
|
||||
private final EdgeSlider mNoiseFloorSlider;
|
||||
private final Object mLock = new Object();
|
||||
private boolean mActive;
|
||||
private boolean mHasChannel;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public ChannelSpectralDisplay()
|
||||
{
|
||||
this(new ChannelTuningLayer());
|
||||
}
|
||||
|
||||
private ChannelSpectralDisplay(ChannelTuningLayer tuningLayer)
|
||||
{
|
||||
super(SETTINGS_PREFIX, true, tuningLayer);
|
||||
mTuningLayer = tuningLayer;
|
||||
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||
getSpectralDisplay().messageProperty().set(MESSAGE_NO_CHANNEL);
|
||||
|
||||
//Noise floor slider along the right edge, below the configuration panel tab
|
||||
mNoiseFloorSlider = new EdgeSlider("Floor", "Noise floor: moves the bottom of the spectrum's dB range. " +
|
||||
"Higher settings show a deeper range and move the noise floor up the display.",
|
||||
MINIMUM_SAMPLE_SIZE, MAXIMUM_SAMPLE_SIZE, 1, this::formatPlotBottom);
|
||||
double sampleSize = SystemProperties.getInstance().get(key(NOISE_FLOOR), (int)DEFAULT_SAMPLE_SIZE);
|
||||
sampleSize = Math.max(MINIMUM_SAMPLE_SIZE, Math.min(MAXIMUM_SAMPLE_SIZE, sampleSize));
|
||||
model.sampleSizeProperty().set(sampleSize);
|
||||
mNoiseFloorSlider.valueProperty().set(sampleSize);
|
||||
mNoiseFloorSlider.valueProperty().addListener((ob, ov, nv) -> {
|
||||
int value = (int)Math.round(nv.doubleValue());
|
||||
model.sampleSizeProperty().set(value);
|
||||
save(NOISE_FLOOR, value);
|
||||
});
|
||||
//The value shows the plot bottom in dBFS, which also depends on the DFT size and window
|
||||
model.binCountProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel());
|
||||
model.noiseBandwidthProperty().addListener((ob, ov, nv) -> mNoiseFloorSlider.refreshLabel());
|
||||
mNoiseFloorSlider.refreshLabel();
|
||||
StackPane.setAlignment(mNoiseFloorSlider, Pos.CENTER_RIGHT);
|
||||
StackPane.setMargin(mNoiseFloorSlider, new Insets(44, 0, 8, 0));
|
||||
getChildren().add(mNoiseFloorSlider);
|
||||
|
||||
installConfigPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for the channel's complex sample buffers.
|
||||
*/
|
||||
public Listener<INativeBuffer> getSampleListener()
|
||||
{
|
||||
return getDftProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a channel.
|
||||
* @param frequency of the channel center in Hertz
|
||||
* @param sampleRate of the channel sample stream in Hertz
|
||||
* @param bandwidth of the channel in Hertz, or zero to hide the passband markers
|
||||
*/
|
||||
public void setChannel(long frequency, int sampleRate, int bandwidth)
|
||||
{
|
||||
synchronized(mLock)
|
||||
{
|
||||
mHasChannel = sampleRate > 0;
|
||||
updateProcessing();
|
||||
}
|
||||
|
||||
getSpectralDisplay().clear();
|
||||
runOnFxThread(() -> {
|
||||
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||
model.resetZoom();
|
||||
model.centerFrequencyProperty().set(frequency);
|
||||
model.sampleRateProperty().set(sampleRate);
|
||||
mTuningLayer.channelBandwidthProperty().set(bandwidth);
|
||||
mTuningLayer.carrierOffsetValidProperty().set(false);
|
||||
mTuningLayer.carrierOffsetProperty().set(0);
|
||||
getSpectralDisplay().messageProperty().set(sampleRate > 0 ? null : MESSAGE_NO_CHANNEL);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops showing the channel.
|
||||
*/
|
||||
public void clearChannel()
|
||||
{
|
||||
setChannel(0, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the estimated carrier offset from the channel center, from the decoder's frequency correction.
|
||||
* @param offset in Hertz: where the signal is relative to the channel center
|
||||
*/
|
||||
public void setCarrierOffset(long offset)
|
||||
{
|
||||
runOnFxThread(() -> {
|
||||
mTuningLayer.carrierOffsetProperty().set(offset);
|
||||
mTuningLayer.carrierOffsetValidProperty().set(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Noise floor slider along the right edge of the display.
|
||||
*/
|
||||
public EdgeSlider getNoiseFloorSlider()
|
||||
{
|
||||
return mNoiseFloorSlider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the bottom of the spectrum plot in dBFS for the noise floor slider value.
|
||||
*/
|
||||
private String formatPlotBottom(double sampleSize)
|
||||
{
|
||||
double range = (20.0 * Math.log10(Math.pow(2.0, sampleSize - 1.0))) +
|
||||
getSpectralDisplay().getModel().getProcessingGain();
|
||||
return Math.round(-range) + " dB";
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns DFT processing on while the display is showing, and off when it's hidden.
|
||||
*/
|
||||
public void setActive(boolean active)
|
||||
{
|
||||
synchronized(mLock)
|
||||
{
|
||||
mActive = active;
|
||||
updateProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processingEnabledChanged()
|
||||
{
|
||||
synchronized(mLock)
|
||||
{
|
||||
updateProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or stops the DFT processor. Hold the lock when calling.
|
||||
*/
|
||||
private void updateProcessing()
|
||||
{
|
||||
if(mActive && mHasChannel && isProcessingEnabled())
|
||||
{
|
||||
startDft();
|
||||
}
|
||||
else
|
||||
{
|
||||
stopDft();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose()
|
||||
{
|
||||
setActive(false);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 javafx.beans.property.BooleanProperty;
|
||||
import javafx.scene.Node;
|
||||
import org.controlsfx.control.ToggleSwitch;
|
||||
|
||||
/**
|
||||
* Labeled control that a display layer adds to its section of the configuration panel.
|
||||
*
|
||||
* @param label shown to the left of the control
|
||||
* @param control to change the setting
|
||||
*/
|
||||
public record ConfigurationRow(String label, Node control)
|
||||
{
|
||||
private static final double SWITCH_WIDTH = 40;
|
||||
|
||||
/**
|
||||
* Creates a row with an on/off switch bound to the property.
|
||||
*/
|
||||
public static ConfigurationRow toggle(String label, BooleanProperty property)
|
||||
{
|
||||
ToggleSwitch toggleSwitch = new ToggleSwitch();
|
||||
toggleSwitch.selectedProperty().bindBidirectional(property);
|
||||
//The switch skin reserves space for its text even when there is none, so size it to the thumb track
|
||||
toggleSwitch.setMinWidth(SWITCH_WIDTH);
|
||||
toggleSwitch.setPrefWidth(SWITCH_WIDTH);
|
||||
toggleSwitch.setMaxWidth(SWITCH_WIDTH);
|
||||
return new ConfigurationRow(label, toggleSwitch);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 io.github.dsheirer.dsp.window.WindowType;
|
||||
import io.github.dsheirer.gui.spectrum.layer.DecibelLayer;
|
||||
import io.github.dsheirer.gui.spectrum.layer.FrequencyLayer;
|
||||
import io.github.dsheirer.properties.SystemProperties;
|
||||
import io.github.dsheirer.spectrum.ComplexDftProcessor;
|
||||
import io.github.dsheirer.spectrum.DFTSize;
|
||||
import io.github.dsheirer.spectrum.converter.FullScaleDecibelConverter;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
/**
|
||||
* Base class for a spectral display fed by DFT processing of a complex sample stream: the spectral display with the
|
||||
* decibel and frequency layers plus any layers the subclass adds, the DFT processor and dBFS converter, the
|
||||
* collapsible configuration panel, and saving the display settings under a property key prefix.
|
||||
*
|
||||
* The color theme is shared by all spectral displays in the application.
|
||||
*
|
||||
* Construct on the JavaFX application thread. Subclasses call installConfigPanel() at the end of their constructor.
|
||||
*/
|
||||
public abstract class DftSpectralDisplay extends StackPane
|
||||
{
|
||||
public static final int[] FRAME_RATES = {10, 14, 16, 18, 20, 25, 30, 40, 50, 60};
|
||||
public static final String PROPERTY_THEME = "spectral.display.theme";
|
||||
private static final String DFT_SIZE = "dft.size";
|
||||
private static final String WINDOW_TYPE = "window.type";
|
||||
private static final String FRAME_RATE = "frame.rate";
|
||||
private static final String AVERAGING = "averaging";
|
||||
private static final String PEAK_HOLD = "peak.hold";
|
||||
private static final String SPECTRUM_ENABLED = "spectrum.enabled";
|
||||
private static final String WATERFALL_ENABLED = "waterfall.enabled";
|
||||
private static final String DIVIDER_POSITION = "divider.position";
|
||||
private static final String DECIBEL_SCALE = "decibel.scale";
|
||||
private static final String CONFIG_PINNED = "config.pinned";
|
||||
private static ObjectProperty<SpectralDisplayTheme> sSharedTheme;
|
||||
|
||||
private final SpectralDisplay mSpectralDisplay;
|
||||
private final DecibelLayer mDecibelLayer = new DecibelLayer();
|
||||
private final ComplexDftProcessor mDftProcessor = new ComplexDftProcessor();
|
||||
private final FullScaleDecibelConverter mDecibelConverter = new FullScaleDecibelConverter();
|
||||
private final String mSettingsPrefix;
|
||||
private final boolean mSaveSettings;
|
||||
private final ObjectProperty<DFTSize> mDftSize = new SimpleObjectProperty<>(DFTSize.FFT04096);
|
||||
private final ObjectProperty<WindowType> mWindowType = new SimpleObjectProperty<>(WindowType.BLACKMAN_HARRIS_7);
|
||||
private final IntegerProperty mFrameRate = new SimpleIntegerProperty(20);
|
||||
private SpectralDisplayConfigPanel mConfigPanel;
|
||||
private volatile boolean mProcessingEnabled;
|
||||
private volatile boolean mDftRunning;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param settingsPrefix for the saved setting keys, for example "spectral.display"
|
||||
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
|
||||
* @param layers to draw after the decibel scale and frequency layers
|
||||
*/
|
||||
protected DftSpectralDisplay(String settingsPrefix, boolean saveSettings, SpectralDisplayLayer... layers)
|
||||
{
|
||||
mSettingsPrefix = settingsPrefix;
|
||||
mSaveSettings = saveSettings;
|
||||
|
||||
SpectralDisplayLayer[] all = new SpectralDisplayLayer[layers.length + 2];
|
||||
all[0] = mDecibelLayer;
|
||||
all[1] = new FrequencyLayer();
|
||||
System.arraycopy(layers, 0, all, 2, layers.length);
|
||||
mSpectralDisplay = new SpectralDisplay(all);
|
||||
|
||||
mDftProcessor.setUnityGainWindow(true);
|
||||
mDftProcessor.addConverter(mDecibelConverter);
|
||||
mDecibelConverter.addListener(mSpectralDisplay::receive);
|
||||
//The processor starts its timer on construction - leave it stopped until there is a sample source
|
||||
mDftProcessor.stop();
|
||||
|
||||
loadSettings();
|
||||
|
||||
mDftSize.addListener((ob, ov, nv) -> {
|
||||
mDftProcessor.setDFTSize(nv);
|
||||
mSpectralDisplay.getModel().resetZoom();
|
||||
save(DFT_SIZE, nv.name());
|
||||
});
|
||||
mWindowType.addListener((ob, ov, nv) -> {
|
||||
mDftProcessor.setWindowType(nv);
|
||||
mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(nv));
|
||||
save(WINDOW_TYPE, nv.name());
|
||||
});
|
||||
mFrameRate.addListener((ob, ov, nv) -> {
|
||||
mDftProcessor.setFrameRateNoSave(nv.intValue());
|
||||
|
||||
if(!mDftRunning)
|
||||
{
|
||||
//Changing the frame rate restarts the timer - keep it stopped while not processing
|
||||
mDftProcessor.stop();
|
||||
}
|
||||
|
||||
save(FRAME_RATE, nv.intValue());
|
||||
});
|
||||
mSpectralDisplay.averagingProperty().addListener((ob, ov, nv) -> save(AVERAGING, nv.intValue()));
|
||||
mSpectralDisplay.peakHoldProperty().addListener((ob, ov, nv) -> save(PEAK_HOLD, nv));
|
||||
mSpectralDisplay.dividerPositionProperty().addListener((ob, ov, nv) ->
|
||||
save(DIVIDER_POSITION, String.valueOf(nv.doubleValue())));
|
||||
mDecibelLayer.visibleProperty().addListener((ob, ov, nv) -> save(DECIBEL_SCALE, nv));
|
||||
mSpectralDisplay.spectrumEnabledProperty().addListener((ob, ov, nv) -> {
|
||||
save(SPECTRUM_ENABLED, nv);
|
||||
updateProcessingEnabled();
|
||||
});
|
||||
mSpectralDisplay.waterfallEnabledProperty().addListener((ob, ov, nv) -> {
|
||||
save(WATERFALL_ENABLED, nv);
|
||||
updateProcessingEnabled();
|
||||
});
|
||||
mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() ||
|
||||
mSpectralDisplay.waterfallEnabledProperty().get();
|
||||
|
||||
mSpectralDisplay.themeProperty().bindBidirectional(getSharedTheme());
|
||||
|
||||
getChildren().add(mSpectralDisplay);
|
||||
setMinSize(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the configuration panel. Subclasses call this at the end of their constructor, after their own
|
||||
* settings are loaded, since the panel reads the subclass's controls.
|
||||
*/
|
||||
protected void installConfigPanel()
|
||||
{
|
||||
mConfigPanel = new SpectralDisplayConfigPanel(this);
|
||||
mConfigPanel.pinnedProperty().set(SystemProperties.getInstance().get(key(CONFIG_PINNED), false));
|
||||
mConfigPanel.pinnedProperty().addListener((ob, ov, nv) -> save(CONFIG_PINNED, nv));
|
||||
StackPane.setAlignment(mConfigPanel, Pos.TOP_RIGHT);
|
||||
StackPane.setMargin(mConfigPanel, new Insets(6));
|
||||
getChildren().add(mConfigPanel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color theme shared by all spectral displays, loaded from and saved to the theme setting. JavaFX thread only.
|
||||
*/
|
||||
public static ObjectProperty<SpectralDisplayTheme> getSharedTheme()
|
||||
{
|
||||
if(sSharedTheme == null)
|
||||
{
|
||||
SpectralDisplayTheme theme = parse(SpectralDisplayTheme.class, SystemProperties.getInstance()
|
||||
.get(PROPERTY_THEME, SpectralDisplayTheme.getDefault().name()), SpectralDisplayTheme.getDefault());
|
||||
sSharedTheme = new SimpleObjectProperty<>(theme);
|
||||
sSharedTheme.addListener((ob, ov, nv) -> {
|
||||
if(nv != null)
|
||||
{
|
||||
SystemProperties.getInstance().set(PROPERTY_THEME, nv.name());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sSharedTheme;
|
||||
}
|
||||
|
||||
private void loadSettings()
|
||||
{
|
||||
SystemProperties properties = SystemProperties.getInstance();
|
||||
DFTSize dftSize = parse(DFTSize.class, properties.get(key(DFT_SIZE), DFTSize.FFT04096.name()),
|
||||
DFTSize.FFT04096);
|
||||
mDftSize.set(dftSize);
|
||||
mDftProcessor.setDFTSize(dftSize);
|
||||
|
||||
WindowType windowType = parse(WindowType.class, properties.get(key(WINDOW_TYPE),
|
||||
WindowType.BLACKMAN_HARRIS_7.name()), WindowType.BLACKMAN_HARRIS_7);
|
||||
|
||||
if(!isWindowSupported(windowType))
|
||||
{
|
||||
windowType = WindowType.BLACKMAN_HARRIS_7;
|
||||
}
|
||||
|
||||
mWindowType.set(windowType);
|
||||
mDftProcessor.setWindowType(windowType);
|
||||
mSpectralDisplay.getModel().noiseBandwidthProperty().set(ComplexDftProcessor.getNoiseBandwidth(windowType));
|
||||
|
||||
int frameRate = Math.max(1, Math.min(100, properties.get(key(FRAME_RATE), mDftProcessor.getFrameRate())));
|
||||
mFrameRate.set(frameRate);
|
||||
mDftProcessor.setFrameRateNoSave(frameRate);
|
||||
mDftProcessor.stop();
|
||||
|
||||
int averaging = properties.get(key(AVERAGING), SpectralDisplay.AVERAGING_DEFAULT);
|
||||
mSpectralDisplay.averagingProperty().set(Math.max(SpectralDisplay.AVERAGING_MINIMUM,
|
||||
Math.min(SpectralDisplay.AVERAGING_MAXIMUM, averaging)));
|
||||
mSpectralDisplay.peakHoldProperty().set(properties.get(key(PEAK_HOLD), false));
|
||||
mDecibelLayer.visibleProperty().set(properties.get(key(DECIBEL_SCALE), true));
|
||||
|
||||
try
|
||||
{
|
||||
double divider = Double.parseDouble(properties.get(key(DIVIDER_POSITION), "0.5"));
|
||||
mSpectralDisplay.dividerPositionProperty().set(Math.max(0.05, Math.min(0.95, divider)));
|
||||
}
|
||||
catch(NumberFormatException nfe)
|
||||
{
|
||||
mSpectralDisplay.dividerPositionProperty().set(0.5);
|
||||
}
|
||||
|
||||
mSpectralDisplay.spectrumEnabledProperty().set(properties.get(key(SPECTRUM_ENABLED), true));
|
||||
mSpectralDisplay.waterfallEnabledProperty().set(properties.get(key(WATERFALL_ENABLED), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full property key for the setting name.
|
||||
*/
|
||||
protected String key(String name)
|
||||
{
|
||||
return mSettingsPrefix + "." + name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the window type can be used for the spectral display. Kaiser windows need extra parameters.
|
||||
*/
|
||||
public static boolean isWindowSupported(WindowType windowType)
|
||||
{
|
||||
return windowType != null && windowType != WindowType.KAISER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an enum setting value, or returns the default for a missing or unrecognized value.
|
||||
*/
|
||||
protected static <E extends Enum<E>> E parse(Class<E> type, String value, E defaultValue)
|
||||
{
|
||||
if(value != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Enum.valueOf(type, value);
|
||||
}
|
||||
catch(IllegalArgumentException iae)
|
||||
{
|
||||
//Fall through to the default
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
protected void save(String name, String value)
|
||||
{
|
||||
if(mSaveSettings)
|
||||
{
|
||||
SystemProperties.getInstance().set(key(name), value);
|
||||
}
|
||||
}
|
||||
|
||||
protected void save(String name, int value)
|
||||
{
|
||||
if(mSaveSettings)
|
||||
{
|
||||
SystemProperties.getInstance().set(key(name), value);
|
||||
}
|
||||
}
|
||||
|
||||
protected void save(String name, boolean value)
|
||||
{
|
||||
if(mSaveSettings)
|
||||
{
|
||||
SystemProperties.getInstance().set(key(name), value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The spectral display.
|
||||
*/
|
||||
public SpectralDisplay getSpectralDisplay()
|
||||
{
|
||||
return mSpectralDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible configuration panel, or null before installConfigPanel() is called.
|
||||
*/
|
||||
public SpectralDisplayConfigPanel getConfigPanel()
|
||||
{
|
||||
return mConfigPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFT processor. Register it with the sample source to feed the display.
|
||||
*/
|
||||
protected ComplexDftProcessor getDftProcessor()
|
||||
{
|
||||
return mDftProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFT size.
|
||||
*/
|
||||
public ObjectProperty<DFTSize> dftSizeProperty()
|
||||
{
|
||||
return mDftSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFT window type.
|
||||
*/
|
||||
public ObjectProperty<WindowType> windowTypeProperty()
|
||||
{
|
||||
return mWindowType;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFT frames per second.
|
||||
*/
|
||||
public IntegerProperty frameRateProperty()
|
||||
{
|
||||
return mFrameRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the spectrum or the waterfall is turned on. Safe to read from any thread.
|
||||
*/
|
||||
protected boolean isProcessingEnabled()
|
||||
{
|
||||
return mProcessingEnabled;
|
||||
}
|
||||
|
||||
private void updateProcessingEnabled()
|
||||
{
|
||||
mProcessingEnabled = mSpectralDisplay.spectrumEnabledProperty().get() ||
|
||||
mSpectralDisplay.waterfallEnabledProperty().get();
|
||||
processingEnabledChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on the JavaFX thread when the spectrum and waterfall are both turned off, or one is turned back on.
|
||||
* Subclasses start or stop their sample source with isProcessingEnabled().
|
||||
*/
|
||||
protected abstract void processingEnabledChanged();
|
||||
|
||||
/**
|
||||
* Starts the DFT processor timer.
|
||||
*/
|
||||
protected void startDft()
|
||||
{
|
||||
if(!mDftRunning)
|
||||
{
|
||||
mDftRunning = true;
|
||||
mDftProcessor.clearBuffer();
|
||||
mDftProcessor.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the DFT processor timer and clears the display.
|
||||
*/
|
||||
protected void stopDft()
|
||||
{
|
||||
if(mDftRunning)
|
||||
{
|
||||
mDftRunning = false;
|
||||
mDftProcessor.stop();
|
||||
mDftProcessor.clearBuffer();
|
||||
mSpectralDisplay.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the DFT processor timer is running.
|
||||
*/
|
||||
protected boolean isDftRunning()
|
||||
{
|
||||
return mDftRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the runnable on the JavaFX application thread, ignoring the request after the JavaFX toolkit has exited
|
||||
* during application shutdown.
|
||||
*/
|
||||
protected static void runOnFxThread(Runnable runnable)
|
||||
{
|
||||
try
|
||||
{
|
||||
Platform.runLater(runnable);
|
||||
}
|
||||
catch(IllegalStateException ise)
|
||||
{
|
||||
//JavaFX toolkit has exited
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the DFT processing and the display. Subclasses disconnect their sample source first.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
stopDft();
|
||||
mDftProcessor.dispose();
|
||||
mDecibelConverter.dispose();
|
||||
runOnFxThread(() -> {
|
||||
mSpectralDisplay.themeProperty().unbindBidirectional(getSharedTheme());
|
||||
|
||||
if(mConfigPanel != null)
|
||||
{
|
||||
mConfigPanel.dispose();
|
||||
}
|
||||
|
||||
mSpectralDisplay.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Mouse cursor position over the spectral display.
|
||||
*
|
||||
* @param section under the mouse
|
||||
* @param x position in pixels within the section
|
||||
* @param y position in pixels within the section
|
||||
* @param frequency under the mouse in Hertz
|
||||
*/
|
||||
public record DisplayCursor(DisplaySection section, double x, double y, long frequency)
|
||||
{
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.net.URL;
|
||||
import java.util.function.DoubleFunction;
|
||||
import javafx.animation.FadeTransition;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Slider;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.Region;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.util.Duration;
|
||||
|
||||
/**
|
||||
* Vertical slider that floats along the right edge of a display. While collapsed it shows as a thin strip with a
|
||||
* marker at the current value. Moving the mouse over the strip expands it into a translucent panel with a title, a
|
||||
* vertical slider and the formatted value. It collapses again shortly after the mouse leaves, unless the slider is
|
||||
* being dragged.
|
||||
*
|
||||
* Place it in a StackPane aligned to the right edge. The slider's minimum is at the bottom.
|
||||
*/
|
||||
public class EdgeSlider extends StackPane
|
||||
{
|
||||
private static final String STYLESHEET = "/spectral_display.css";
|
||||
private static final double COLLAPSED_WIDTH = 14;
|
||||
private static final double STRIP_WIDTH = 4;
|
||||
private static final double MARKER_HEIGHT = 10;
|
||||
private static final double EXPANDED_WIDTH = 64;
|
||||
private static final Duration COLLAPSE_DELAY = Duration.millis(400);
|
||||
private static final Duration FADE_DURATION = Duration.millis(120);
|
||||
|
||||
private final Slider mSlider;
|
||||
private final Label mValueLabel = new Label();
|
||||
private final DoubleFunction<String> mFormatter;
|
||||
private final VBox mExpanded;
|
||||
private final Pane mCollapsed = new Pane();
|
||||
private final Region mStrip = new Region();
|
||||
private final Region mMarker = new Region();
|
||||
private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY);
|
||||
private boolean mIsExpanded = false;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param title shown above the slider
|
||||
* @param tooltip describing the control
|
||||
* @param min value, at the bottom of the slider
|
||||
* @param max value, at the top of the slider
|
||||
* @param step between values
|
||||
* @param formatter for the value shown below the slider
|
||||
*/
|
||||
public EdgeSlider(String title, String tooltip, double min, double max, double step,
|
||||
DoubleFunction<String> formatter)
|
||||
{
|
||||
mFormatter = formatter;
|
||||
URL stylesheet = EdgeSlider.class.getResource(STYLESHEET);
|
||||
|
||||
if(stylesheet != null)
|
||||
{
|
||||
getStylesheets().add(stylesheet.toExternalForm());
|
||||
}
|
||||
|
||||
getStyleClass().add("spectral-edge-slider");
|
||||
setMaxWidth(Region.USE_PREF_SIZE);
|
||||
setMinWidth(Region.USE_PREF_SIZE);
|
||||
setPickOnBounds(true);
|
||||
|
||||
mSlider = new Slider(min, max, min);
|
||||
mSlider.setOrientation(Orientation.VERTICAL);
|
||||
mSlider.setMajorTickUnit(step);
|
||||
mSlider.setMinorTickCount(0);
|
||||
mSlider.setBlockIncrement(step);
|
||||
mSlider.setSnapToTicks(true);
|
||||
mSlider.setMaxHeight(Double.MAX_VALUE);
|
||||
VBox.setVgrow(mSlider, Priority.ALWAYS);
|
||||
mSlider.valueProperty().addListener((ob, ov, nv) -> {
|
||||
refreshLabel();
|
||||
requestLayout();
|
||||
});
|
||||
|
||||
Label titleLabel = new Label(title);
|
||||
titleLabel.getStyleClass().add("spectral-edge-slider-title");
|
||||
mValueLabel.getStyleClass().add("spectral-edge-slider-value");
|
||||
mExpanded = new VBox(4, titleLabel, mSlider, mValueLabel);
|
||||
mExpanded.setAlignment(Pos.CENTER);
|
||||
mExpanded.getStyleClass().add("spectral-edge-slider-panel");
|
||||
mExpanded.setPrefWidth(EXPANDED_WIDTH);
|
||||
Tooltip.install(mExpanded, new Tooltip(tooltip));
|
||||
|
||||
mStrip.getStyleClass().add("spectral-edge-slider-strip");
|
||||
mMarker.getStyleClass().add("spectral-edge-slider-marker");
|
||||
//Unmanaged so the pane doesn't resize them to their (empty) preferred size - layoutChildren() places them
|
||||
mStrip.setManaged(false);
|
||||
mMarker.setManaged(false);
|
||||
mCollapsed.getChildren().addAll(mStrip, mMarker);
|
||||
mCollapsed.setPrefWidth(COLLAPSED_WIDTH);
|
||||
mCollapsed.setMinWidth(COLLAPSED_WIDTH);
|
||||
|
||||
mCollapseTimer.setOnFinished(e -> {
|
||||
if(!isHover() && !mSlider.isValueChanging() && !mSlider.isPressed())
|
||||
{
|
||||
setExpanded(false);
|
||||
}
|
||||
});
|
||||
setOnMouseEntered(e -> {
|
||||
mCollapseTimer.stop();
|
||||
setExpanded(true);
|
||||
});
|
||||
setOnMouseExited(e -> mCollapseTimer.playFromStart());
|
||||
mSlider.valueChangingProperty().addListener((ob, ov, nv) -> {
|
||||
if(!nv && !isHover())
|
||||
{
|
||||
mCollapseTimer.playFromStart();
|
||||
}
|
||||
});
|
||||
|
||||
getChildren().add(mCollapsed);
|
||||
refreshLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Slider value.
|
||||
*/
|
||||
public DoubleProperty valueProperty()
|
||||
{
|
||||
return mSlider.valueProperty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the value text, for example when something the formatter depends on changes.
|
||||
*/
|
||||
public void refreshLabel()
|
||||
{
|
||||
mValueLabel.setText(mFormatter != null ? mFormatter.apply(mSlider.getValue()) :
|
||||
String.valueOf(mSlider.getValue()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the slider panel is expanded.
|
||||
*/
|
||||
public boolean isExpanded()
|
||||
{
|
||||
return mIsExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands or collapses the slider panel.
|
||||
*/
|
||||
public void setExpanded(boolean expanded)
|
||||
{
|
||||
if(expanded != mIsExpanded)
|
||||
{
|
||||
mIsExpanded = expanded;
|
||||
|
||||
if(expanded)
|
||||
{
|
||||
getChildren().setAll(mExpanded);
|
||||
mExpanded.setOpacity(0);
|
||||
FadeTransition fade = new FadeTransition(FADE_DURATION, mExpanded);
|
||||
fade.setToValue(1.0);
|
||||
fade.play();
|
||||
}
|
||||
else
|
||||
{
|
||||
getChildren().setAll(mCollapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren()
|
||||
{
|
||||
super.layoutChildren();
|
||||
|
||||
if(!mIsExpanded)
|
||||
{
|
||||
//Strip along the right side of the hover area, with the marker at the value position
|
||||
double width = mCollapsed.getWidth();
|
||||
double height = mCollapsed.getHeight();
|
||||
double stripX = width - STRIP_WIDTH - 2;
|
||||
mStrip.resizeRelocate(stripX, 0, STRIP_WIDTH, height);
|
||||
|
||||
double range = mSlider.getMax() - mSlider.getMin();
|
||||
double fraction = range > 0 ? (mSlider.getValue() - mSlider.getMin()) / range : 0;
|
||||
double markerY = (1.0 - fraction) * Math.max(0, height - MARKER_HEIGHT);
|
||||
mMarker.resizeRelocate(stripX - 2, markerY, STRIP_WIDTH + 4, MARKER_HEIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Size of a display section for rendering.
|
||||
*
|
||||
* @param width of the section in pixels
|
||||
* @param height of the section in pixels
|
||||
* @param plotHeight of the area above the bottom inset where the spectrum trace is drawn. This is the same as the
|
||||
* height for the waterfall.
|
||||
*/
|
||||
public record RenderArea(double width, double height, double plotHeight)
|
||||
{
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,596 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 io.github.dsheirer.dsp.window.WindowType;
|
||||
import io.github.dsheirer.source.tuner.Tuner;
|
||||
import io.github.dsheirer.spectrum.DFTSize;
|
||||
import java.net.URL;
|
||||
import java.util.List;
|
||||
import javafx.animation.FadeTransition;
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ReadOnlyBooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.geometry.HPos;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.MenuButton;
|
||||
import javafx.scene.control.RadioMenuItem;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.Slider;
|
||||
import javafx.scene.control.SeparatorMenuItem;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.control.Tooltip;
|
||||
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.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import jiconfont.icons.font_awesome.FontAwesome;
|
||||
import jiconfont.javafx.IconFontFX;
|
||||
import jiconfont.javafx.IconNode;
|
||||
import org.controlsfx.control.ToggleSwitch;
|
||||
|
||||
/**
|
||||
* Translucent configuration panel that floats over the right side of the spectral display. It shows as a small
|
||||
* settings tab while collapsed and expands while the mouse is over it. The pin button keeps it expanded.
|
||||
*
|
||||
* Controls: tuner selection, spectrum and waterfall on/off switches, waterfall pause, DFT size, window, frame rate,
|
||||
* averaging, peak hold, the settings of each display layer that has settings, and the color theme.
|
||||
*/
|
||||
public class SpectralDisplayConfigPanel extends VBox
|
||||
{
|
||||
private static final String STYLESHEET = "/spectral_display.css";
|
||||
private static final double PANEL_WIDTH = 360;
|
||||
private static final double SWITCH_WIDTH = 40;
|
||||
private static final java.text.DecimalFormat RESOLUTION_HZ_FORMAT = new java.text.DecimalFormat("0.#");
|
||||
private static final java.text.DecimalFormat RESOLUTION_KHZ_FORMAT = new java.text.DecimalFormat("0.##");
|
||||
private static final Duration COLLAPSE_DELAY = Duration.millis(500);
|
||||
private static final Duration FADE_DURATION = Duration.millis(150);
|
||||
|
||||
static
|
||||
{
|
||||
IconFontFX.register(FontAwesome.getIconFont());
|
||||
}
|
||||
|
||||
private final DftSpectralDisplay mHost;
|
||||
private final TunerSpectralDisplay mTunerSpectralDisplay;
|
||||
private final SpectralDisplay mSpectralDisplay;
|
||||
private final BooleanProperty mExpanded = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty mPinned = new SimpleBooleanProperty(false);
|
||||
private final PauseTransition mCollapseTimer = new PauseTransition(COLLAPSE_DELAY);
|
||||
private final IconNode mTabIcon = new IconNode(FontAwesome.SLIDERS);
|
||||
private final VBox mExpandedContent = new VBox();
|
||||
private final MenuButton mTunerMenuButton = new MenuButton();
|
||||
private int mPopupsShowing = 0;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param host display to configure. A tuner display adds a tuner menu to the panel.
|
||||
*/
|
||||
public SpectralDisplayConfigPanel(DftSpectralDisplay host)
|
||||
{
|
||||
mHost = host;
|
||||
mTunerSpectralDisplay = host instanceof TunerSpectralDisplay tunerDisplay ? tunerDisplay : null;
|
||||
mSpectralDisplay = host.getSpectralDisplay();
|
||||
|
||||
URL stylesheet = SpectralDisplayConfigPanel.class.getResource(STYLESHEET);
|
||||
|
||||
if(stylesheet != null)
|
||||
{
|
||||
getStylesheets().add(stylesheet.toExternalForm());
|
||||
}
|
||||
|
||||
getStyleClass().add("spectral-config-panel");
|
||||
setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
|
||||
setFillWidth(true);
|
||||
|
||||
mTabIcon.setIconSize(16);
|
||||
mTabIcon.getStyleClass().add("spectral-config-icon");
|
||||
|
||||
buildExpandedContent();
|
||||
|
||||
mCollapseTimer.setOnFinished(e -> {
|
||||
if(!isHover() && mPopupsShowing == 0 && !mPinned.get())
|
||||
{
|
||||
mExpanded.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
setOnMouseEntered(e -> {
|
||||
mCollapseTimer.stop();
|
||||
mExpanded.set(true);
|
||||
});
|
||||
setOnMouseExited(e -> scheduleCollapse());
|
||||
mExpanded.addListener((ob, ov, nv) -> updateExpanded());
|
||||
mPinned.addListener((ob, ov, nv) -> {
|
||||
if(nv)
|
||||
{
|
||||
mExpanded.set(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
scheduleCollapse();
|
||||
}
|
||||
});
|
||||
|
||||
updateExpanded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the panel expanded when true.
|
||||
*/
|
||||
public BooleanProperty pinnedProperty()
|
||||
{
|
||||
return mPinned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the panel is expanded.
|
||||
*/
|
||||
public BooleanProperty expandedProperty()
|
||||
{
|
||||
return mExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
mCollapseTimer.stop();
|
||||
}
|
||||
|
||||
private void scheduleCollapse()
|
||||
{
|
||||
if(!mPinned.get())
|
||||
{
|
||||
mCollapseTimer.playFromStart();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the panel expanded while a combo box, menu or color picker popup is showing, since the mouse leaves the
|
||||
* panel to use the popup.
|
||||
*/
|
||||
private void trackPopup(ReadOnlyBooleanProperty showing)
|
||||
{
|
||||
showing.addListener((ob, ov, nv) -> {
|
||||
mPopupsShowing = Math.max(0, mPopupsShowing + (nv ? 1 : -1));
|
||||
|
||||
if(!nv)
|
||||
{
|
||||
scheduleCollapse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateExpanded()
|
||||
{
|
||||
boolean expanded = mExpanded.get() || mPinned.get();
|
||||
|
||||
if(expanded)
|
||||
{
|
||||
getChildren().setAll(mExpandedContent);
|
||||
getStyleClass().remove("collapsed");
|
||||
setPrefWidth(PANEL_WIDTH);
|
||||
mExpandedContent.setOpacity(0);
|
||||
FadeTransition fade = new FadeTransition(FADE_DURATION, mExpandedContent);
|
||||
fade.setToValue(1.0);
|
||||
fade.play();
|
||||
}
|
||||
else
|
||||
{
|
||||
getChildren().setAll(mTabIcon);
|
||||
if(!getStyleClass().contains("collapsed"))
|
||||
{
|
||||
getStyleClass().add("collapsed");
|
||||
}
|
||||
setPrefWidth(Region.USE_COMPUTED_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
private void buildExpandedContent()
|
||||
{
|
||||
//Header: icon, title and pin button
|
||||
IconNode headerIcon = new IconNode(FontAwesome.SLIDERS);
|
||||
headerIcon.setIconSize(14);
|
||||
headerIcon.getStyleClass().add("spectral-config-icon");
|
||||
Label title = new Label("Display Settings");
|
||||
title.getStyleClass().add("spectral-config-title");
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
IconNode pinIcon = new IconNode(FontAwesome.THUMB_TACK);
|
||||
pinIcon.setIconSize(12);
|
||||
pinIcon.getStyleClass().add("spectral-config-icon");
|
||||
ToggleButton pinButton = new ToggleButton(null, pinIcon);
|
||||
pinButton.getStyleClass().add("spectral-config-pin");
|
||||
pinButton.selectedProperty().bindBidirectional(mPinned);
|
||||
pinButton.setTooltip(new Tooltip("Keep the settings panel open"));
|
||||
HBox header = new HBox(6, headerIcon, title, spacer, pinButton);
|
||||
header.setAlignment(Pos.CENTER_LEFT);
|
||||
header.setPadding(new Insets(2, 2, 4, 4));
|
||||
|
||||
GridPane grid = new GridPane();
|
||||
grid.setHgap(8);
|
||||
grid.setVgap(5);
|
||||
grid.setPadding(new Insets(0, 8, 6, 6));
|
||||
ColumnConstraints labelColumn = new ColumnConstraints();
|
||||
labelColumn.setHalignment(HPos.RIGHT);
|
||||
//Labels keep their full width - the controls column absorbs any shortfall
|
||||
labelColumn.setMinWidth(Region.USE_PREF_SIZE);
|
||||
ColumnConstraints controlColumn = new ColumnConstraints();
|
||||
controlColumn.setHgrow(Priority.ALWAYS);
|
||||
controlColumn.setFillWidth(true);
|
||||
grid.getColumnConstraints().addAll(labelColumn, controlColumn);
|
||||
int row = 0;
|
||||
|
||||
//Tuner
|
||||
if(mTunerSpectralDisplay != null)
|
||||
{
|
||||
grid.add(sectionLabel("Source"), 0, row++, 2, 1);
|
||||
mTunerMenuButton.setMaxWidth(Double.MAX_VALUE);
|
||||
mTunerMenuButton.setOnShowing(e -> updateTunerMenu());
|
||||
trackPopup(mTunerMenuButton.showingProperty());
|
||||
mTunerMenuButton.textProperty().bind(Bindings.createStringBinding(() -> {
|
||||
Tuner tuner = mTunerSpectralDisplay.displayedTunerProperty().get();
|
||||
return tuner != null ? tuner.getPreferredName() : "None";
|
||||
}, mTunerSpectralDisplay.displayedTunerProperty()));
|
||||
row = addRow(grid, row, "Tuner", mTunerMenuButton);
|
||||
}
|
||||
|
||||
//Display sections
|
||||
grid.add(sectionLabel("Display"), 0, row++, 2, 1);
|
||||
ToggleSwitch spectrumSwitch = toggleSwitch("Spectrum", mSpectralDisplay.spectrumEnabledProperty());
|
||||
ToggleSwitch waterfallSwitch = toggleSwitch("Waterfall", mSpectralDisplay.waterfallEnabledProperty());
|
||||
ToggleSwitch pauseSwitch = toggleSwitch("Pause", mSpectralDisplay.waterfallPausedProperty());
|
||||
pauseSwitch.disableProperty().bind(mSpectralDisplay.waterfallEnabledProperty().not());
|
||||
pauseSwitch.setTooltip(new Tooltip("Pause the waterfall"));
|
||||
HBox switches = new HBox(12, labeled(spectrumSwitch), labeled(waterfallSwitch), labeled(pauseSwitch));
|
||||
switches.setAlignment(Pos.CENTER);
|
||||
grid.add(switches, 0, row++, 2, 1);
|
||||
|
||||
ComboBox<SpectralDisplayTheme> theme = comboBox(SpectralDisplayTheme.values());
|
||||
theme.valueProperty().bindBidirectional(mSpectralDisplay.themeProperty());
|
||||
theme.setCellFactory(listView -> new ThemeCell());
|
||||
theme.setButtonCell(new ThemeCell());
|
||||
row = addRow(grid, row, "Theme", theme);
|
||||
|
||||
//Layer settings without a section of their own
|
||||
for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers())
|
||||
{
|
||||
if(layer.getConfigurationTitle() == null)
|
||||
{
|
||||
row = addLayerRows(grid, row, layer.getConfigurationRows());
|
||||
}
|
||||
}
|
||||
|
||||
//DFT
|
||||
grid.add(sectionLabel("FFT"), 0, row++, 2, 1);
|
||||
ComboBox<DFTSize> dftSize = comboBox(DFTSize.values());
|
||||
dftSize.setConverter(dftSizeConverter());
|
||||
//Refresh the resolution shown with each size when the sample rate changes
|
||||
mSpectralDisplay.getModel().sampleRateProperty().addListener((ob, ov, nv) ->
|
||||
dftSize.setConverter(dftSizeConverter()));
|
||||
dftSize.setTooltip(new Tooltip("FFT size and the resulting frequency resolution (bandwidth of each bin)"));
|
||||
dftSize.valueProperty().bindBidirectional(mHost.dftSizeProperty());
|
||||
row = addRow(grid, row, "Size", dftSize);
|
||||
|
||||
ComboBox<WindowType> windowType = comboBox(java.util.Arrays.stream(WindowType.values())
|
||||
.filter(DftSpectralDisplay::isWindowSupported).toArray(WindowType[]::new));
|
||||
windowType.valueProperty().bindBidirectional(mHost.windowTypeProperty());
|
||||
row = addRow(grid, row, "Window", windowType);
|
||||
|
||||
ComboBox<Integer> frameRate = new ComboBox<>();
|
||||
for(int rate: DftSpectralDisplay.FRAME_RATES)
|
||||
{
|
||||
frameRate.getItems().add(rate);
|
||||
}
|
||||
if(!frameRate.getItems().contains(mHost.frameRateProperty().get()))
|
||||
{
|
||||
frameRate.getItems().add(mHost.frameRateProperty().get());
|
||||
frameRate.getItems().sort(Integer::compare);
|
||||
}
|
||||
frameRate.setMaxWidth(Double.MAX_VALUE);
|
||||
frameRate.setConverter(converter(rate -> rate + " fps"));
|
||||
frameRate.setValue(mHost.frameRateProperty().get());
|
||||
frameRate.valueProperty().addListener((ob, ov, nv) -> {
|
||||
if(nv != null)
|
||||
{
|
||||
mHost.frameRateProperty().set(nv);
|
||||
}
|
||||
});
|
||||
trackPopup(frameRate.showingProperty());
|
||||
row = addRow(grid, row, "Frame Rate", frameRate);
|
||||
|
||||
Slider averaging = new Slider(SpectralDisplay.AVERAGING_MINIMUM, SpectralDisplay.AVERAGING_MAXIMUM,
|
||||
mSpectralDisplay.averagingProperty().get());
|
||||
averaging.setBlockIncrement(1);
|
||||
averaging.setMajorTickUnit(1);
|
||||
averaging.setMinorTickCount(0);
|
||||
averaging.setSnapToTicks(true);
|
||||
averaging.valueProperty().addListener((ob, ov, nv) ->
|
||||
mSpectralDisplay.averagingProperty().set((int)Math.round(nv.doubleValue())));
|
||||
HBox.setHgrow(averaging, Priority.ALWAYS);
|
||||
Label fast = new Label("Fast");
|
||||
fast.getStyleClass().add("spectral-config-hint");
|
||||
Label slow = new Label("Slow");
|
||||
slow.getStyleClass().add("spectral-config-hint");
|
||||
HBox averagingBox = new HBox(4, fast, averaging, slow);
|
||||
averagingBox.setAlignment(Pos.CENTER_LEFT);
|
||||
Tooltip.install(averagingBox, new Tooltip("Spectrum averaging: signals rise quickly and fade slowly. " +
|
||||
"Slower settings give a steadier noise floor."));
|
||||
row = addRow(grid, row, "Averaging", averagingBox);
|
||||
|
||||
ConfigurationRow peakHold = ConfigurationRow.toggle("Peak Hold", mSpectralDisplay.peakHoldProperty());
|
||||
Tooltip.install(peakHold.control(), new Tooltip("Show a line at the recent peak of each frequency, " +
|
||||
"falling slowly"));
|
||||
row = addRow(grid, row, peakHold.label(), peakHold.control());
|
||||
|
||||
//Layer settings
|
||||
for(SpectralDisplayLayer layer: mSpectralDisplay.getLayers())
|
||||
{
|
||||
List<ConfigurationRow> rows = layer.getConfigurationRows();
|
||||
|
||||
if(layer.getConfigurationTitle() != null && rows != null && !rows.isEmpty())
|
||||
{
|
||||
grid.add(sectionLabel(layer.getConfigurationTitle()), 0, row++, 2, 1);
|
||||
row = addLayerRows(grid, row, rows);
|
||||
}
|
||||
}
|
||||
|
||||
VBox content = new VBox(4, grid);
|
||||
content.setPadding(new Insets(0, 0, 4, 0));
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane(content);
|
||||
scrollPane.setFitToWidth(true);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
scrollPane.getStyleClass().add("spectral-config-scroll");
|
||||
|
||||
//Limit the panel to the display height and scroll the controls when they don't fit
|
||||
scrollPane.prefViewportHeightProperty().bind(content.heightProperty());
|
||||
scrollPane.maxHeightProperty().bind(Bindings.max(40, mHost.heightProperty()
|
||||
.subtract(header.heightProperty()).subtract(24)));
|
||||
|
||||
mExpandedContent.getChildren().addAll(header, scrollPane);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter that labels each DFT size with its bin bandwidth at the current sample rate.
|
||||
*/
|
||||
private StringConverter<DFTSize> dftSizeConverter()
|
||||
{
|
||||
return converter(size -> {
|
||||
int sampleRate = mSpectralDisplay.getModel().getSampleRate();
|
||||
|
||||
if(sampleRate <= 0)
|
||||
{
|
||||
return size.getLabel();
|
||||
}
|
||||
|
||||
return size.getLabel() + " \u00B7 " + formatResolution((double)sampleRate / size.getSize());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bin bandwidth in Hz or kHz.
|
||||
*/
|
||||
static String formatResolution(double hertz)
|
||||
{
|
||||
if(hertz >= 1000.0)
|
||||
{
|
||||
return RESOLUTION_KHZ_FORMAT.format(hertz / 1000.0) + " kHz/bin";
|
||||
}
|
||||
|
||||
return RESOLUTION_HZ_FORMAT.format(hertz) + " Hz/bin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the tuner menu with the currently available tuners.
|
||||
*/
|
||||
private void updateTunerMenu()
|
||||
{
|
||||
mTunerMenuButton.getItems().clear();
|
||||
ToggleGroup group = new ToggleGroup();
|
||||
Tuner current = mTunerSpectralDisplay.displayedTunerProperty().get();
|
||||
List<Tuner> tuners = mTunerSpectralDisplay.getAvailableTuners();
|
||||
|
||||
for(Tuner tuner: tuners)
|
||||
{
|
||||
RadioMenuItem item = new RadioMenuItem(tuner.getPreferredName());
|
||||
item.setToggleGroup(group);
|
||||
item.setSelected(tuner == current);
|
||||
item.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(tuner));
|
||||
mTunerMenuButton.getItems().add(item);
|
||||
}
|
||||
|
||||
if(!tuners.isEmpty())
|
||||
{
|
||||
mTunerMenuButton.getItems().add(new SeparatorMenuItem());
|
||||
}
|
||||
|
||||
RadioMenuItem none = new RadioMenuItem("None");
|
||||
none.setToggleGroup(group);
|
||||
none.setSelected(current == null);
|
||||
none.setOnAction(e -> mTunerSpectralDisplay.tunerSelected(null));
|
||||
mTunerMenuButton.getItems().add(none);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a layer's configuration rows to the grid.
|
||||
* @return next row index
|
||||
*/
|
||||
private int addLayerRows(GridPane grid, int row, List<ConfigurationRow> rows)
|
||||
{
|
||||
if(rows != null)
|
||||
{
|
||||
for(ConfigurationRow configurationRow: rows)
|
||||
{
|
||||
if(configurationRow.control() instanceof ComboBox<?> comboBox)
|
||||
{
|
||||
trackPopup(comboBox.showingProperty());
|
||||
}
|
||||
|
||||
row = addRow(grid, row, configurationRow.label(), configurationRow.control());
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private int addRow(GridPane grid, int row, String label, Node control)
|
||||
{
|
||||
grid.add(new Label(label), 0, row);
|
||||
grid.add(control, 1, row);
|
||||
GridPane.setHgrow(control, Priority.ALWAYS);
|
||||
return row + 1;
|
||||
}
|
||||
|
||||
private static Label sectionLabel(String text)
|
||||
{
|
||||
Label label = new Label(text);
|
||||
label.getStyleClass().add("spectral-config-section");
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the switch's text in a label to the left of the switch. The switch's own text adds a wide gap.
|
||||
*/
|
||||
private static HBox labeled(ToggleSwitch toggleSwitch)
|
||||
{
|
||||
Label label = new Label(toggleSwitch.getText());
|
||||
label.setMinWidth(Region.USE_PREF_SIZE);
|
||||
label.disableProperty().bind(toggleSwitch.disabledProperty());
|
||||
label.setOnMouseClicked(e -> {
|
||||
if(!toggleSwitch.isDisabled())
|
||||
{
|
||||
toggleSwitch.setSelected(!toggleSwitch.isSelected());
|
||||
}
|
||||
});
|
||||
toggleSwitch.setText(null);
|
||||
//The switch skin reserves space for its text even when there is none, so size it to the thumb track
|
||||
toggleSwitch.setMinWidth(SWITCH_WIDTH);
|
||||
toggleSwitch.setPrefWidth(SWITCH_WIDTH);
|
||||
toggleSwitch.setMaxWidth(SWITCH_WIDTH);
|
||||
HBox box = new HBox(4, label, toggleSwitch);
|
||||
box.setAlignment(Pos.CENTER_LEFT);
|
||||
return box;
|
||||
}
|
||||
|
||||
private static ToggleSwitch toggleSwitch(String text, BooleanProperty property)
|
||||
{
|
||||
ToggleSwitch toggleSwitch = new ToggleSwitch(text);
|
||||
toggleSwitch.selectedProperty().bindBidirectional(property);
|
||||
return toggleSwitch;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private <T> ComboBox<T> comboBox(T... values)
|
||||
{
|
||||
ComboBox<T> comboBox = new ComboBox<>();
|
||||
comboBox.getItems().addAll(values);
|
||||
comboBox.setMaxWidth(Double.MAX_VALUE);
|
||||
trackPopup(comboBox.showingProperty());
|
||||
return comboBox;
|
||||
}
|
||||
|
||||
private static <T> StringConverter<T> converter(java.util.function.Function<T,String> function)
|
||||
{
|
||||
return new StringConverter<>()
|
||||
{
|
||||
@Override
|
||||
public String toString(T value)
|
||||
{
|
||||
return value != null ? function.apply(value) : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public T fromString(String string)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme list cell with a preview swatch: the spectrum background, a channel highlight and the waterfall palette.
|
||||
*/
|
||||
private static class ThemeCell extends javafx.scene.control.ListCell<SpectralDisplayTheme>
|
||||
{
|
||||
private static final double SWATCH_WIDTH = 44;
|
||||
private static final double SWATCH_HEIGHT = 12;
|
||||
|
||||
@Override
|
||||
protected void updateItem(SpectralDisplayTheme theme, boolean empty)
|
||||
{
|
||||
super.updateItem(theme, empty);
|
||||
|
||||
if(empty || theme == null)
|
||||
{
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setText(theme.getLabel());
|
||||
setGraphic(createSwatch(theme));
|
||||
setGraphicTextGap(8);
|
||||
}
|
||||
|
||||
private static Node createSwatch(SpectralDisplayTheme theme)
|
||||
{
|
||||
javafx.scene.canvas.Canvas canvas = new javafx.scene.canvas.Canvas(SWATCH_WIDTH, SWATCH_HEIGHT);
|
||||
javafx.scene.canvas.GraphicsContext gc = canvas.getGraphicsContext2D();
|
||||
double half = SWATCH_WIDTH / 2.0;
|
||||
|
||||
//Left half: spectrum background, trace fill and a processing channel highlight
|
||||
gc.setFill(theme.getBackground());
|
||||
gc.fillRect(0, 0, half, SWATCH_HEIGHT);
|
||||
gc.setFill(theme.getChannelProcessing());
|
||||
gc.fillRect(half * 0.55, 0, half * 0.3, SWATCH_HEIGHT);
|
||||
gc.setFill(theme.getGradientBottom());
|
||||
gc.fillRect(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.35);
|
||||
gc.setStroke(theme.getTrace());
|
||||
gc.strokeLine(0, SWATCH_HEIGHT * 0.65, half, SWATCH_HEIGHT * 0.65);
|
||||
|
||||
//Right half: waterfall palette from the noise floor to the strongest signal
|
||||
for(int x = 0; x < half; x++)
|
||||
{
|
||||
int index = (int)Math.round(x / (half - 1) * (WaterfallPalette.SIZE - 1));
|
||||
gc.setFill(WaterfallPalette.toColor(theme.getWaterfallColor(index)));
|
||||
gc.fillRect(half + x, 0, 1, SWATCH_HEIGHT);
|
||||
}
|
||||
|
||||
gc.setStroke(javafx.scene.paint.Color.gray(0.5, 0.8));
|
||||
gc.strokeRect(0.5, 0.5, SWATCH_WIDTH - 1, SWATCH_HEIGHT - 1);
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.util.Collections;
|
||||
import java.util.List;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.control.MenuItem;
|
||||
|
||||
/**
|
||||
* Optional layer that draws on the spectral display. The display calls the render methods on the JavaFX application
|
||||
* thread each time it redraws, in the order the layers were added. Override only the methods the layer needs.
|
||||
*
|
||||
* Spectrum drawing order: background color, each layer's background, spectrum trace, each layer's foreground.
|
||||
* Waterfall drawing order: waterfall image, each layer's waterfall overlay.
|
||||
*/
|
||||
public abstract class SpectralDisplayLayer
|
||||
{
|
||||
private SpectralDisplay mSpectralDisplay;
|
||||
|
||||
/**
|
||||
* Called by the display when the layer is added.
|
||||
*/
|
||||
void attach(SpectralDisplay spectralDisplay)
|
||||
{
|
||||
mSpectralDisplay = spectralDisplay;
|
||||
attached();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the display when the layer is removed.
|
||||
*/
|
||||
void detach()
|
||||
{
|
||||
detached();
|
||||
mSpectralDisplay = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subclasses after the layer is added to a display.
|
||||
*/
|
||||
protected void attached()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for subclasses before the layer is removed from a display.
|
||||
*/
|
||||
protected void detached()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Display this layer is added to, or null.
|
||||
*/
|
||||
protected SpectralDisplay getSpectralDisplay()
|
||||
{
|
||||
return mSpectralDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency model of the display this layer is added to, or null.
|
||||
*/
|
||||
protected SpectralDisplayModel getModel()
|
||||
{
|
||||
return mSpectralDisplay != null ? mSpectralDisplay.getModel() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the display to redraw on the next frame. Safe to call from any thread.
|
||||
*/
|
||||
protected void requestRender()
|
||||
{
|
||||
if(mSpectralDisplay != null)
|
||||
{
|
||||
mSpectralDisplay.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixels this layer needs along the bottom of the spectrum section, below the spectrum plot. The display reserves
|
||||
* the largest inset that any layer asks for.
|
||||
*/
|
||||
public double getSpectrumBottomInset()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws behind the spectrum trace.
|
||||
*/
|
||||
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws on top of the spectrum trace.
|
||||
*/
|
||||
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws on top of the waterfall.
|
||||
*/
|
||||
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param frequency under the mouse
|
||||
* @return menu items, or an empty list
|
||||
*/
|
||||
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Title of this layer's section in the configuration panel, or null to place the layer's rows in the panel's
|
||||
* Display section instead of a section of their own.
|
||||
*/
|
||||
public String getConfigurationTitle()
|
||||
{
|
||||
return getClass().getSimpleName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Labeled controls for this layer's section of the configuration panel, or an empty list if the layer has no
|
||||
* settings.
|
||||
*/
|
||||
public List<ConfigurationRow> getConfigurationRows()
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases resources and listeners.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.LongProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
|
||||
/**
|
||||
* Frequency and zoom state shared by the spectrum, the waterfall and the display layers, with the conversions
|
||||
* between frequency, DFT bin and screen position. Access this model only on the JavaFX application thread.
|
||||
*
|
||||
* DFT bins are in display order (after the half swap) so bin 0 is the lowest frequency and bin (size / 2) is the
|
||||
* center frequency. The center of bin i is at minimum frequency + (i * bin width). The display shows the bins
|
||||
* [zoom offset, zoom offset + visible bin count) and each visible bin covers an equal share of the screen width.
|
||||
*/
|
||||
public class SpectralDisplayModel
|
||||
{
|
||||
public static final int MIN_ZOOM = 0;
|
||||
public static final int MAX_ZOOM = 6;
|
||||
|
||||
private final LongProperty mCenterFrequency = new SimpleLongProperty(0);
|
||||
private final IntegerProperty mSampleRate = new SimpleIntegerProperty(0);
|
||||
private final IntegerProperty mBinCount = new SimpleIntegerProperty(4096);
|
||||
private final IntegerProperty mZoom = new SimpleIntegerProperty(MIN_ZOOM);
|
||||
private final DoubleProperty mZoomOffset = new SimpleDoubleProperty(0);
|
||||
private final DoubleProperty mSampleSize = new SimpleDoubleProperty(16.0);
|
||||
private final DoubleProperty mNoiseBandwidth = new SimpleDoubleProperty(0);
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public SpectralDisplayModel()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Center frequency of the source in Hertz.
|
||||
*/
|
||||
public LongProperty centerFrequencyProperty()
|
||||
{
|
||||
return mCenterFrequency;
|
||||
}
|
||||
|
||||
public long getCenterFrequency()
|
||||
{
|
||||
return mCenterFrequency.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample rate (bandwidth) of the source in Hertz.
|
||||
*/
|
||||
public IntegerProperty sampleRateProperty()
|
||||
{
|
||||
return mSampleRate;
|
||||
}
|
||||
|
||||
public int getSampleRate()
|
||||
{
|
||||
return mSampleRate.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of DFT bins in each result set.
|
||||
*/
|
||||
public IntegerProperty binCountProperty()
|
||||
{
|
||||
return mBinCount;
|
||||
}
|
||||
|
||||
public int getBinCount()
|
||||
{
|
||||
return mBinCount.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the bin count and keeps the same frequency at the left edge of the zoom window.
|
||||
*/
|
||||
public void setBinCount(int binCount)
|
||||
{
|
||||
if(binCount > 0 && binCount != getBinCount())
|
||||
{
|
||||
double ratio = getZoomOffset() / (double)getBinCount();
|
||||
mBinCount.set(binCount);
|
||||
setZoomOffset(ratio * binCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom level where the zoom multiplier is 2 ^ zoom.
|
||||
*/
|
||||
public IntegerProperty zoomProperty()
|
||||
{
|
||||
return mZoom;
|
||||
}
|
||||
|
||||
public int getZoom()
|
||||
{
|
||||
return mZoom.get();
|
||||
}
|
||||
|
||||
public int getZoomMultiplier()
|
||||
{
|
||||
return 1 << getZoom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the first DFT bin shown at the left edge of the display.
|
||||
*/
|
||||
public DoubleProperty zoomOffsetProperty()
|
||||
{
|
||||
return mZoomOffset;
|
||||
}
|
||||
|
||||
public double getZoomOffset()
|
||||
{
|
||||
return mZoomOffset.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the zoom window offset, limited so the zoom window stays inside the DFT bins.
|
||||
*/
|
||||
public void setZoomOffset(double offset)
|
||||
{
|
||||
double max = getBinCount() - getVisibleBinCount();
|
||||
mZoomOffset.set(Math.max(0, Math.min(max, offset)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Source sample size in bits, which sets the dB range of the spectrum plot.
|
||||
*/
|
||||
public DoubleProperty sampleSizeProperty()
|
||||
{
|
||||
return mSampleSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equivalent noise bandwidth of the DFT window, in bins, for dBFS data. When set, the spectrum plot extends
|
||||
* below the sample dynamic range by the DFT processing gain, since the noise in each bin is that much lower than
|
||||
* the total noise. Zero (the default) for data that doesn't include the processing gain.
|
||||
*/
|
||||
public DoubleProperty noiseBandwidthProperty()
|
||||
{
|
||||
return mNoiseBandwidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFT processing gain in dB: 10 * log10(bin count / window noise bandwidth), or zero when the noise bandwidth is
|
||||
* not set.
|
||||
*/
|
||||
public double getProcessingGain()
|
||||
{
|
||||
double noiseBandwidth = mNoiseBandwidth.get();
|
||||
return noiseBandwidth > 0 ? 10.0 * Math.log10(getBinCount() / noiseBandwidth) : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range of the spectrum plot in dB below full scale. The bottom of the plot is at minus this value. The range is
|
||||
* the dynamic range of the source sample size plus the DFT processing gain.
|
||||
*/
|
||||
public double getDynamicRange()
|
||||
{
|
||||
return 20.0 * Math.log10(Math.pow(2.0, mSampleSize.get() - 1.0)) + getProcessingGain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if a source frequency and sample rate are set.
|
||||
*/
|
||||
public boolean hasSource()
|
||||
{
|
||||
return getSampleRate() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of DFT bins in the zoom window.
|
||||
*/
|
||||
public int getVisibleBinCount()
|
||||
{
|
||||
return Math.max(1, getBinCount() / getZoomMultiplier());
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of each DFT bin in Hertz.
|
||||
*/
|
||||
public double getBinWidth()
|
||||
{
|
||||
return (double)getSampleRate() / (double)getBinCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowest frequency of the source.
|
||||
*/
|
||||
public long getMinFrequency()
|
||||
{
|
||||
return getCenterFrequency() - (getSampleRate() / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest frequency of the source.
|
||||
*/
|
||||
public long getMaxFrequency()
|
||||
{
|
||||
return getCenterFrequency() + (getSampleRate() / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency at the left edge of the display.
|
||||
*/
|
||||
public double getDisplayMinFrequency()
|
||||
{
|
||||
return getMinFrequency() + ((getZoomOffset() - 0.5) * getBinWidth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency at the right edge of the display.
|
||||
*/
|
||||
public double getDisplayMaxFrequency()
|
||||
{
|
||||
return getDisplayMinFrequency() + getDisplayBandwidth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bandwidth shown across the display, in Hertz.
|
||||
*/
|
||||
public double getDisplayBandwidth()
|
||||
{
|
||||
return getVisibleBinCount() * getBinWidth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen x position of the frequency.
|
||||
* @param frequency in Hertz
|
||||
* @param width of the display in pixels
|
||||
*/
|
||||
public double getX(double frequency, double width)
|
||||
{
|
||||
double bandwidth = getDisplayBandwidth();
|
||||
|
||||
if(bandwidth <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (frequency - getDisplayMinFrequency()) / bandwidth * width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency at the screen x position.
|
||||
* @param x position in pixels
|
||||
* @param width of the display in pixels
|
||||
*/
|
||||
public long getFrequency(double x, double width)
|
||||
{
|
||||
if(width <= 0)
|
||||
{
|
||||
return getCenterFrequency();
|
||||
}
|
||||
|
||||
return Math.round(getDisplayMinFrequency() + (x / width * getDisplayBandwidth()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel width of one DFT bin at the current zoom.
|
||||
*/
|
||||
public double getPixelsPerBin(double width)
|
||||
{
|
||||
return width / getVisibleBinCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the zoom level and keeps the frequency at the same screen position.
|
||||
* @param zoom level, limited to MIN_ZOOM - MAX_ZOOM
|
||||
* @param frequency to hold in place
|
||||
* @param xFraction screen position of the frequency as a fraction (0 - 1.0) of the display width
|
||||
*/
|
||||
public void setZoom(int zoom, long frequency, double xFraction)
|
||||
{
|
||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
||||
|
||||
if(zoom != getZoom())
|
||||
{
|
||||
double bin = hasSource() ? ((frequency - getMinFrequency()) / getBinWidth()) + 0.5 : getBinCount() / 2.0;
|
||||
mZoom.set(zoom);
|
||||
setZoomOffset(bin - (xFraction * getVisibleBinCount()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the zoom to the full source bandwidth.
|
||||
*/
|
||||
public void resetZoom()
|
||||
{
|
||||
mZoom.set(MIN_ZOOM);
|
||||
setZoomOffset(0);
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 io.github.dsheirer.gui.theme.ThemeManager;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.properties.SystemProperties;
|
||||
import io.github.dsheirer.source.tuner.Tuner;
|
||||
import io.github.dsheirer.source.tuner.TunerEvent;
|
||||
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import java.awt.EventQueue;
|
||||
import javafx.application.Platform;
|
||||
import javafx.embed.swing.JFXPanel;
|
||||
import javafx.scene.Scene;
|
||||
|
||||
/**
|
||||
* Swing panel that holds the JavaFX tuner spectral display for the main application window.
|
||||
*
|
||||
* The JavaFX display is created on the JavaFX application thread after construction. A tuner requested before the
|
||||
* display exists is shown once the display is ready.
|
||||
*/
|
||||
public class SpectralDisplayPanel extends JFXPanel
|
||||
{
|
||||
private final DiscoveredTunerModel mDiscoveredTunerModel;
|
||||
private TunerSpectralDisplay mTunerSpectralDisplay;
|
||||
private Tuner mPendingTuner;
|
||||
private boolean mDisposed;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param playlistManager for the channels
|
||||
* @param discoveredTunerModel for the list of tuners
|
||||
*/
|
||||
public SpectralDisplayPanel(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel)
|
||||
{
|
||||
mDiscoveredTunerModel = discoveredTunerModel;
|
||||
Platform.setImplicitExit(false);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, true);
|
||||
display.setTunerSelectionHandler(this::tunerSelected);
|
||||
Scene scene = new Scene(display);
|
||||
ThemeManager.getInstance().register(scene);
|
||||
setScene(scene);
|
||||
|
||||
Tuner pending;
|
||||
|
||||
synchronized(SpectralDisplayPanel.this)
|
||||
{
|
||||
if(mDisposed)
|
||||
{
|
||||
ThreadPool.CACHED.submit(display::dispose);
|
||||
return;
|
||||
}
|
||||
|
||||
mTunerSpectralDisplay = display;
|
||||
pending = mPendingTuner;
|
||||
mPendingTuner = null;
|
||||
}
|
||||
|
||||
if(pending != null)
|
||||
{
|
||||
ThreadPool.CACHED.submit(() -> display.showTuner(pending));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a tuner selection in the configuration panel. Selecting a tuner turns the main spectral display on and
|
||||
* selecting no tuner turns it off, the same as the View menu.
|
||||
*/
|
||||
private void tunerSelected(Tuner tuner)
|
||||
{
|
||||
//Broadcast on the Swing event thread, the same as the Swing tuner menu items, since listeners update Swing
|
||||
EventQueue.invokeLater(() -> {
|
||||
if(tuner != null)
|
||||
{
|
||||
SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, true);
|
||||
mDiscoveredTunerModel.broadcast(new TunerEvent(tuner, TunerEvent.Event.REQUEST_MAIN_SPECTRAL_DISPLAY));
|
||||
}
|
||||
else
|
||||
{
|
||||
SystemProperties.getInstance().set(TunerSpectralDisplay.SPECTRAL_DISPLAY_ENABLED, false);
|
||||
mDiscoveredTunerModel.broadcast(new TunerEvent(getTuner(),
|
||||
TunerEvent.Event.REQUEST_CLEAR_MAIN_SPECTRAL_DISPLAY));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the tuner in the display. Can block while the tuner starts its sample stream.
|
||||
*/
|
||||
public void showTuner(Tuner tuner)
|
||||
{
|
||||
TunerSpectralDisplay display;
|
||||
|
||||
synchronized(this)
|
||||
{
|
||||
if(mTunerSpectralDisplay == null)
|
||||
{
|
||||
mPendingTuner = tuner;
|
||||
return;
|
||||
}
|
||||
|
||||
display = mTunerSpectralDisplay;
|
||||
}
|
||||
|
||||
display.showTuner(tuner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops showing the current tuner.
|
||||
*/
|
||||
public void clearTuner()
|
||||
{
|
||||
TunerSpectralDisplay display;
|
||||
|
||||
synchronized(this)
|
||||
{
|
||||
if(mTunerSpectralDisplay == null)
|
||||
{
|
||||
mPendingTuner = null;
|
||||
return;
|
||||
}
|
||||
|
||||
display = mTunerSpectralDisplay;
|
||||
}
|
||||
|
||||
display.clearTuner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently displayed tuner, or null.
|
||||
*/
|
||||
public Tuner getTuner()
|
||||
{
|
||||
TunerSpectralDisplay display;
|
||||
|
||||
synchronized(this)
|
||||
{
|
||||
if(mTunerSpectralDisplay == null)
|
||||
{
|
||||
return mPendingTuner;
|
||||
}
|
||||
|
||||
display = mTunerSpectralDisplay;
|
||||
}
|
||||
|
||||
return display.getTuner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops showing the tuner and releases resources.
|
||||
*/
|
||||
public void dispose()
|
||||
{
|
||||
TunerSpectralDisplay display;
|
||||
|
||||
synchronized(this)
|
||||
{
|
||||
mDisposed = true;
|
||||
display = mTunerSpectralDisplay;
|
||||
mTunerSpectralDisplay = null;
|
||||
mPendingTuner = null;
|
||||
}
|
||||
|
||||
if(display != null)
|
||||
{
|
||||
display.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 javafx.scene.paint.Color;
|
||||
|
||||
/**
|
||||
* Color themes for the spectral display. Each theme sets every display color and the waterfall palette, so the user
|
||||
* picks one theme instead of setting each color.
|
||||
*
|
||||
* Color blind safe themes use perceptually uniform waterfall palettes (viridis, cividis, inferno) that keep an even
|
||||
* brightness ramp for each type of color vision deficiency, and Okabe-Ito colors for the channel highlights, which
|
||||
* also differ in brightness so they don't rely on hue alone.
|
||||
*/
|
||||
public enum SpectralDisplayTheme
|
||||
{
|
||||
CLASSIC("Classic", Group.STANDARD,
|
||||
"#000000", "#FFFFFF80", "#00FF0080", "#FFFFFF80", "#C0C0C080", "#FFC800",
|
||||
"#C0C0C03C", "#00FF003C", "#0000FF3C", "#FFFF00",
|
||||
WaterfallPalette.classic()),
|
||||
|
||||
DARK("Dark", Group.STANDARD,
|
||||
"#121417", "#64B5F68C", "#1565C059", "#90CAF9E6", "#8A939ECC", "#FFCA28",
|
||||
"#9AA4B138", "#26A69A59", "#7E57C273", "#FFEE58",
|
||||
WaterfallPalette.gradient("#0B0E24", "#152A6B", "#1F5FA8", "#2BA3B8", "#7BD389", "#F2E35B", "#F28C38",
|
||||
"#E8443A")),
|
||||
|
||||
MEDIUM("Medium", Group.STANDARD,
|
||||
"#3A3F47", "#E0E6EE99", "#8FA8C866", "#F0F4F8E6", "#C4CCD6CC", "#FFB74D",
|
||||
"#C4CCD640", "#66BB6A66", "#42A5F573", "#FFF176",
|
||||
WaterfallPalette.gradient("#2A2F38", "#31527A", "#3C8DAD", "#7BC8A4", "#F2D06B", "#F08A4B", "#E04F5F")),
|
||||
|
||||
LIGHT("Light", Group.STANDARD,
|
||||
"#F7F8FA", "#1E88E559", "#90CAF940", "#1565C0", "#5F6B7AE6", "#D84315",
|
||||
"#607D8B2E", "#43A0474D", "#1E88E54D", "#E65100",
|
||||
WaterfallPalette.gradient("#FFFFFF", "#DCEBF7", "#9CC9E8", "#4F9BD0", "#1F6AA5", "#0B3C73", "#061A40")),
|
||||
|
||||
GREEN_PHOSPHOR("Green Phosphor", Group.MONOCHROME,
|
||||
"#000A00", "#33FF6673", "#00661A59", "#66FF8C", "#2E9E4AD9", "#CCFFCC",
|
||||
"#1F7A354D", "#33FF664D", "#B3FFC666", "#CCFFCC",
|
||||
WaterfallPalette.gradient("#000A00", "#00290A", "#005C1A", "#00A332", "#33FF66", "#CCFFD9")),
|
||||
|
||||
AMBER("Amber", Group.MONOCHROME,
|
||||
"#0D0700", "#FFB00073", "#7A4A0059", "#FFC233", "#B07A10D9", "#FFF0C2",
|
||||
"#8A5A004D", "#FFB00047", "#FFE08A66", "#FFF0C2",
|
||||
WaterfallPalette.gradient("#0D0700", "#2E1A00", "#5C3600", "#A66300", "#FFB000", "#FFE6A6")),
|
||||
|
||||
GRAYSCALE("Grayscale", Group.MONOCHROME,
|
||||
"#000000", "#FFFFFF80", "#60606066", "#FFFFFFD9", "#A0A0A0D9", "#FFFFFF",
|
||||
"#80808040", "#C0C0C04D", "#FFFFFF66", "#FFFFFF",
|
||||
WaterfallPalette.gradient("#000000", "#404040", "#808080", "#C0C0C0", "#FFFFFF")),
|
||||
|
||||
HIGH_CONTRAST_DARK("High Contrast Dark", Group.HIGH_CONTRAST,
|
||||
"#000000", "#FFFFFFBF", "#FFFFFF59", "#FFFFFF", "#FFFFFF", "#FFFF00",
|
||||
"#FFFFFF2E", "#00FF0059", "#00FFFF66", "#FF00FF",
|
||||
WaterfallPalette.gradient("#000000", "#0000FF", "#00FFFF", "#FFFF00", "#FFFFFF")),
|
||||
|
||||
HIGH_CONTRAST_LIGHT("High Contrast Light", Group.HIGH_CONTRAST,
|
||||
"#FFFFFF", "#00000099", "#00000040", "#000000", "#000000", "#D00000",
|
||||
"#0000001F", "#0080004D", "#0000FF4D", "#C000C0",
|
||||
WaterfallPalette.gradient("#FFFFFF", "#FFFF00", "#FF8000", "#C00000", "#000000")),
|
||||
|
||||
VIRIDIS("Viridis (red/green color blind)", Group.COLOR_BLIND,
|
||||
"#0A0A12", "#FDE72573", "#21918C59", "#FDE725", "#A0A0B0D9", "#FFFFFF",
|
||||
"#A0A0A038", "#0072B273", "#E69F0073", "#CC79A7",
|
||||
WaterfallPalette.gradient("#440154", "#482878", "#3E4A89", "#31688E", "#26828E", "#1F9E89", "#35B779",
|
||||
"#6DCD59", "#B4DE2C", "#FDE725")),
|
||||
|
||||
CIVIDIS("Cividis (red/green color blind)", Group.COLOR_BLIND,
|
||||
"#00122E", "#FEE83873", "#575D6D66", "#FEE838", "#A9B0BFD9", "#FFFFFF",
|
||||
"#A9B0BF38", "#56B4E966", "#E69F0073", "#CC79A7",
|
||||
WaterfallPalette.gradient("#00224E", "#123570", "#3B496C", "#575D6D", "#707173", "#8A8779", "#A69D75",
|
||||
"#C4B56C", "#E4CF5B", "#FEE838")),
|
||||
|
||||
INFERNO("Inferno (blue/yellow color blind)", Group.COLOR_BLIND,
|
||||
"#000004", "#FCFFA473", "#BC375466", "#FCFFA4", "#B0A8B8D9", "#FFFFFF",
|
||||
"#A0A0A038", "#D55E0073", "#56B4E973", "#FFFFFF",
|
||||
WaterfallPalette.gradient("#000004", "#1B0C41", "#4A0C6B", "#781C6D", "#A52C60", "#CF4446", "#ED6925",
|
||||
"#FB9B06", "#F7D13D", "#FCFFA4"));
|
||||
|
||||
/**
|
||||
* Theme groups, for ordering and labeling in the theme selection list.
|
||||
*/
|
||||
public enum Group
|
||||
{
|
||||
STANDARD,
|
||||
MONOCHROME,
|
||||
HIGH_CONTRAST,
|
||||
COLOR_BLIND;
|
||||
}
|
||||
|
||||
private final String mLabel;
|
||||
private final Group mGroup;
|
||||
private final Color mBackground;
|
||||
private final Color mGradientTop;
|
||||
private final Color mGradientBottom;
|
||||
private final Color mTrace;
|
||||
private final Color mLine;
|
||||
private final Color mCursor;
|
||||
private final Color mChannel;
|
||||
private final Color mChannelProcessing;
|
||||
private final Color mChannelSelected;
|
||||
private final Color mCorrection;
|
||||
private final int[] mWaterfallPalette;
|
||||
|
||||
/**
|
||||
* Constructs a theme. Colors are web color strings with an optional alpha byte (#RRGGBBAA).
|
||||
* @param label to show to the user
|
||||
* @param group the theme belongs to
|
||||
* @param background of the spectrum
|
||||
* @param gradientTop of the spectrum fill, at the middle of the spectrum height
|
||||
* @param gradientBottom of the spectrum fill, at the bottom of the spectrum
|
||||
* @param trace line along the top of the spectrum fill
|
||||
* @param line color of the axis, grid lines and text
|
||||
* @param cursor line and readout color
|
||||
* @param channel fill for channels that are not processing
|
||||
* @param channelProcessing fill for processing channels
|
||||
* @param channelSelected fill for the selected channel
|
||||
* @param correction color of the channel frequency correction marker
|
||||
* @param waterfallPalette 256 ARGB colors from the noise floor to the strongest signal
|
||||
*/
|
||||
SpectralDisplayTheme(String label, Group group, String background, String gradientTop, String gradientBottom,
|
||||
String trace, String line, String cursor, String channel, String channelProcessing,
|
||||
String channelSelected, String correction, int[] waterfallPalette)
|
||||
{
|
||||
mLabel = label;
|
||||
mGroup = group;
|
||||
mBackground = Color.web(background);
|
||||
mGradientTop = Color.web(gradientTop);
|
||||
mGradientBottom = Color.web(gradientBottom);
|
||||
mTrace = Color.web(trace);
|
||||
mLine = Color.web(line);
|
||||
mCursor = Color.web(cursor);
|
||||
mChannel = Color.web(channel);
|
||||
mChannelProcessing = Color.web(channelProcessing);
|
||||
mChannelSelected = Color.web(channelSelected);
|
||||
mCorrection = Color.web(correction);
|
||||
mWaterfallPalette = waterfallPalette;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default theme: the original sdrtrunk spectral display colors.
|
||||
*/
|
||||
public static SpectralDisplayTheme getDefault()
|
||||
{
|
||||
return CLASSIC;
|
||||
}
|
||||
|
||||
public String getLabel()
|
||||
{
|
||||
return mLabel;
|
||||
}
|
||||
|
||||
public Group getGroup()
|
||||
{
|
||||
return mGroup;
|
||||
}
|
||||
|
||||
public Color getBackground()
|
||||
{
|
||||
return mBackground;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background color with the opacity, for readout boxes drawn over the display.
|
||||
*/
|
||||
public Color getBackground(double opacity)
|
||||
{
|
||||
return Color.color(mBackground.getRed(), mBackground.getGreen(), mBackground.getBlue(), opacity);
|
||||
}
|
||||
|
||||
public Color getGradientTop()
|
||||
{
|
||||
return mGradientTop;
|
||||
}
|
||||
|
||||
public Color getGradientBottom()
|
||||
{
|
||||
return mGradientBottom;
|
||||
}
|
||||
|
||||
public Color getTrace()
|
||||
{
|
||||
return mTrace;
|
||||
}
|
||||
|
||||
public Color getLine()
|
||||
{
|
||||
return mLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line color with a fraction of its opacity, for grid lines.
|
||||
*/
|
||||
public Color getLine(double opacityFraction)
|
||||
{
|
||||
return Color.color(mLine.getRed(), mLine.getGreen(), mLine.getBlue(), mLine.getOpacity() * opacityFraction);
|
||||
}
|
||||
|
||||
public Color getCursor()
|
||||
{
|
||||
return mCursor;
|
||||
}
|
||||
|
||||
public Color getChannel()
|
||||
{
|
||||
return mChannel;
|
||||
}
|
||||
|
||||
public Color getChannelProcessing()
|
||||
{
|
||||
return mChannelProcessing;
|
||||
}
|
||||
|
||||
public Color getChannelSelected()
|
||||
{
|
||||
return mChannelSelected;
|
||||
}
|
||||
|
||||
public Color getCorrection()
|
||||
{
|
||||
return mCorrection;
|
||||
}
|
||||
|
||||
/**
|
||||
* ARGB color for the waterfall intensity index (0 - 255).
|
||||
*/
|
||||
public int getWaterfallColor(int index)
|
||||
{
|
||||
return mWaterfallPalette[index & 0xFF];
|
||||
}
|
||||
|
||||
/**
|
||||
* Waterfall color at the lowest intensity, which is the waterfall background.
|
||||
*/
|
||||
public Color getWaterfallBackground()
|
||||
{
|
||||
return WaterfallPalette.toColor(mWaterfallPalette[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return mLabel;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 io.github.dsheirer.gui.theme.ThemeManager;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.properties.SystemProperties;
|
||||
import io.github.dsheirer.source.tuner.Tuner;
|
||||
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
/**
|
||||
* Separate window with a tuner spectral display. The window starts with the saved spectral display settings, but
|
||||
* changes made in the window are not saved.
|
||||
*/
|
||||
public class SpectralDisplayWindow
|
||||
{
|
||||
private SpectralDisplayWindow()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a window that shows the tuner. Can be called from any thread once the JavaFX toolkit is running.
|
||||
*/
|
||||
public static void show(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel, Tuner tuner)
|
||||
{
|
||||
Platform.runLater(() -> {
|
||||
TunerSpectralDisplay display = new TunerSpectralDisplay(playlistManager, discoveredTunerModel, false);
|
||||
Scene scene = new Scene(display, 1280, 600);
|
||||
ThemeManager.getInstance().register(scene);
|
||||
|
||||
Stage stage = new Stage();
|
||||
stage.setScene(scene);
|
||||
String application = SystemProperties.getInstance().getApplicationName();
|
||||
stage.titleProperty().bind(Bindings.createStringBinding(() -> {
|
||||
Tuner displayed = display.displayedTunerProperty().get();
|
||||
return application + " - " + (displayed != null ? displayed.getPreferredName() : "Spectral Display");
|
||||
}, display.displayedTunerProperty()));
|
||||
stage.setOnHidden(e -> ThreadPool.CACHED.submit(display::dispose));
|
||||
stage.show();
|
||||
|
||||
ThreadPool.CACHED.submit(() -> display.showTuner(tuner));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Spectrum trace averaging and peak hold. Thread safe.
|
||||
*
|
||||
* Averaging is an exponential moving average of each bin's linear power with separate rates for rising and falling
|
||||
* values. A falling bin moves 1 / averaging of the way to the new value each frame, and a rising bin moves
|
||||
* ATTACK_RATIO times faster, so signals appear quickly while the noise floor stays steady. Averaging linear power
|
||||
* instead of decibels keeps the result at the true mean power. Averaging decibel values reads about 2.5 dB low for
|
||||
* noise.
|
||||
*
|
||||
* Peak hold keeps the highest recent value of each bin from the unaveraged frames, falling PEAK_DECAY_DB per frame.
|
||||
*/
|
||||
public class SpectrumAverager
|
||||
{
|
||||
public static final int AVERAGING_MINIMUM = 1;
|
||||
public static final int AVERAGING_MAXIMUM = 20;
|
||||
public static final int AVERAGING_DEFAULT = 4;
|
||||
public static final double ATTACK_RATIO = 2.5;
|
||||
public static final float PEAK_DECAY_DB = 0.15f;
|
||||
private static final float MINIMUM_DB = -196.0f;
|
||||
private static final double LN10_OVER_10 = Math.log(10.0) / 10.0;
|
||||
|
||||
private int mAveraging = AVERAGING_DEFAULT;
|
||||
private boolean mPeakHold = false;
|
||||
private boolean mResetPeak = false;
|
||||
//Double precision so the average can fall more than 70 dB in one frame without float rounding error
|
||||
private double[] mAveragedPower;
|
||||
private float[] mPeakBins;
|
||||
|
||||
/**
|
||||
* Processed spectrum for one DFT frame.
|
||||
* @param average bins in dB after averaging
|
||||
* @param peak hold bins in dB, or null when peak hold is off
|
||||
*/
|
||||
public record Frame(float[] average, float[] peak)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the averaging, from AVERAGING_MINIMUM (no averaging) to AVERAGING_MAXIMUM (slowest).
|
||||
*/
|
||||
public synchronized void setAveraging(int averaging)
|
||||
{
|
||||
mAveraging = Math.max(AVERAGING_MINIMUM, Math.min(AVERAGING_MAXIMUM, averaging));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns peak hold on or off. Turning it on starts from the next frame.
|
||||
*/
|
||||
public synchronized void setPeakHold(boolean peakHold)
|
||||
{
|
||||
mPeakHold = peakHold;
|
||||
mResetPeak = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the peak hold from the next frame, for example after a frequency change.
|
||||
*/
|
||||
public synchronized void resetPeak()
|
||||
{
|
||||
mResetPeak = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the averaging history and the peak hold.
|
||||
*/
|
||||
public synchronized void reset()
|
||||
{
|
||||
mAveragedPower = null;
|
||||
mPeakBins = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one frame of DFT bins in dB.
|
||||
*/
|
||||
public synchronized Frame process(float[] bins)
|
||||
{
|
||||
int size = bins.length;
|
||||
double decay = 1.0 / mAveraging;
|
||||
double attack = Math.min(1.0, ATTACK_RATIO * decay);
|
||||
|
||||
if(mAveragedPower == null || mAveragedPower.length != size)
|
||||
{
|
||||
mAveragedPower = new double[size];
|
||||
|
||||
for(int x = 0; x < size; x++)
|
||||
{
|
||||
mAveragedPower[x] = toPowerDouble(bins[x]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for(int x = 0; x < size; x++)
|
||||
{
|
||||
double power = toPowerDouble(bins[x]);
|
||||
double average = mAveragedPower[x];
|
||||
mAveragedPower[x] = average + ((power - average) * (power > average ? attack : decay));
|
||||
}
|
||||
}
|
||||
|
||||
float[] averaged = new float[size];
|
||||
|
||||
for(int x = 0; x < size; x++)
|
||||
{
|
||||
averaged[x] = mAveragedPower[x] > 0 ?
|
||||
Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(mAveragedPower[x]))) : MINIMUM_DB;
|
||||
}
|
||||
|
||||
float[] peak = null;
|
||||
|
||||
if(mPeakHold)
|
||||
{
|
||||
if(mPeakBins == null || mPeakBins.length != size || mResetPeak)
|
||||
{
|
||||
mResetPeak = false;
|
||||
mPeakBins = bins.clone();
|
||||
}
|
||||
else
|
||||
{
|
||||
for(int x = 0; x < size; x++)
|
||||
{
|
||||
mPeakBins[x] = Math.max(bins[x], mPeakBins[x] - PEAK_DECAY_DB);
|
||||
}
|
||||
}
|
||||
|
||||
peak = mPeakBins.clone();
|
||||
}
|
||||
else
|
||||
{
|
||||
mPeakBins = null;
|
||||
}
|
||||
|
||||
return new Frame(averaged, peak);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts decibels to linear power.
|
||||
*/
|
||||
public static float toPower(float decibels)
|
||||
{
|
||||
return (float)Math.exp(decibels * LN10_OVER_10);
|
||||
}
|
||||
|
||||
private static double toPowerDouble(float decibels)
|
||||
{
|
||||
return Math.exp(decibels * LN10_OVER_10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts linear power to decibels.
|
||||
*/
|
||||
public static float toDecibels(float power)
|
||||
{
|
||||
return power > 0 ? Math.max(MINIMUM_DB, (float)(10.0 * Math.log10(power))) : MINIMUM_DB;
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 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;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import io.github.dsheirer.source.tuner.Tuner;
|
||||
import io.github.dsheirer.source.tuner.manager.DiscoveredTuner;
|
||||
import io.github.dsheirer.source.tuner.ui.DiscoveredTunerModel;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Spectral display for a tuner: the spectral display with the decibel, frequency and playlist channel layers, the DFT
|
||||
* processing for the tuner's sample stream, and the collapsible configuration panel with a tuner menu.
|
||||
*
|
||||
* Construct on the JavaFX application thread. showTuner() and clearTuner() can be called from any thread, but they
|
||||
* can block while the tuner starts or stops its sample stream, so don't call them on the JavaFX application thread.
|
||||
*/
|
||||
public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceEventProcessor
|
||||
{
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(TunerSpectralDisplay.class);
|
||||
|
||||
/**
|
||||
* Indicates if the main window spectral display shows a tuner. Cleared when the user turns the display off.
|
||||
*/
|
||||
public static final String SPECTRAL_DISPLAY_ENABLED = "spectral.display.enabled";
|
||||
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<>();
|
||||
private Consumer<Tuner> mTunerSelectionHandler;
|
||||
private Tuner mTuner;
|
||||
private boolean mProcessing;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param playlistManager for the channels
|
||||
* @param discoveredTunerModel for the list of tuners
|
||||
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
|
||||
*/
|
||||
public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel,
|
||||
boolean 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param channelModel for the channels, or null
|
||||
* @param channelProcessingManager for channel processing events, or null
|
||||
* @param discoveredTunerModel for the list of tuners, or null
|
||||
* @param saveSettings true to save setting changes, false to load the saved settings but not change them
|
||||
*/
|
||||
public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager,
|
||||
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
|
||||
{
|
||||
this(new ChannelLayer(channelModel, channelProcessingManager), new RadioReferenceLayer(null, null),
|
||||
discoveredTunerModel, saveSettings);
|
||||
}
|
||||
|
||||
private TunerSpectralDisplay(ChannelLayer channelLayer, RadioReferenceLayer radioReferenceLayer,
|
||||
DiscoveredTunerModel discoveredTunerModel, boolean saveSettings)
|
||||
{
|
||||
//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);
|
||||
|
||||
SystemProperties properties = SystemProperties.getInstance();
|
||||
mChannelLayer.channelDisplayProperty().set(parse(ChannelLayer.ChannelDisplay.class,
|
||||
properties.get(key(CHANNEL_DISPLAY), ChannelLayer.ChannelDisplay.ALL.name()),
|
||||
ChannelLayer.ChannelDisplay.ALL));
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuner shown in the display, updated on the JavaFX application thread.
|
||||
*/
|
||||
public ReadOnlyObjectProperty<Tuner> displayedTunerProperty()
|
||||
{
|
||||
return mDisplayedTuner.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuners that can be shown.
|
||||
*/
|
||||
public List<Tuner> getAvailableTuners()
|
||||
{
|
||||
List<Tuner> tuners = new ArrayList<>();
|
||||
|
||||
if(mDiscoveredTunerModel != null)
|
||||
{
|
||||
for(DiscoveredTuner discoveredTuner: mDiscoveredTunerModel.getAvailableTuners())
|
||||
{
|
||||
if(discoveredTuner.hasTuner())
|
||||
{
|
||||
tuners.add(discoveredTuner.getTuner());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tuners;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the handler for tuner selections in the configuration panel. The default handler shows the selected tuner.
|
||||
* @param handler receives the selected tuner, or null when the user selects no tuner. Called on the JavaFX thread.
|
||||
*/
|
||||
public void setTunerSelectionHandler(Consumer<Tuner> handler)
|
||||
{
|
||||
mTunerSelectionHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the configuration panel on the JavaFX application thread when the user selects a tuner, or null.
|
||||
*/
|
||||
void tunerSelected(Tuner tuner)
|
||||
{
|
||||
if(mTunerSelectionHandler != null)
|
||||
{
|
||||
mTunerSelectionHandler.accept(tuner);
|
||||
}
|
||||
else
|
||||
{
|
||||
ThreadPool.CACHED.submit(() -> {
|
||||
if(tuner != null)
|
||||
{
|
||||
showTuner(tuner);
|
||||
}
|
||||
else
|
||||
{
|
||||
clearTuner();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently displayed tuner, or null.
|
||||
*/
|
||||
public Tuner getTuner()
|
||||
{
|
||||
synchronized(mTunerLock)
|
||||
{
|
||||
return mTuner;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the tuner in the display and stops showing the previous tuner.
|
||||
*/
|
||||
public void showTuner(Tuner tuner)
|
||||
{
|
||||
synchronized(mTunerLock)
|
||||
{
|
||||
if(tuner == mTuner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
clearTuner();
|
||||
|
||||
if(tuner == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
mTuner = tuner;
|
||||
tuner.getTunerController().addListener(this);
|
||||
long frequency = tuner.getTunerController().getFrequency();
|
||||
int sampleRate = (int)tuner.getTunerController().getSampleRate();
|
||||
double sampleSize = tuner.getSampleSize();
|
||||
|
||||
runOnFxThread(() -> {
|
||||
if(getTuner() == tuner)
|
||||
{
|
||||
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||
model.sampleSizeProperty().set(sampleSize);
|
||||
model.centerFrequencyProperty().set(frequency);
|
||||
model.sampleRateProperty().set(sampleRate);
|
||||
getSpectralDisplay().messageProperty().set(null);
|
||||
mDisplayedTuner.set(tuner);
|
||||
}
|
||||
});
|
||||
|
||||
updateProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops showing the current tuner.
|
||||
*/
|
||||
public void clearTuner()
|
||||
{
|
||||
synchronized(mTunerLock)
|
||||
{
|
||||
Tuner tuner = mTuner;
|
||||
|
||||
if(tuner != null)
|
||||
{
|
||||
stopProcessing();
|
||||
tuner.getTunerController().removeListener(this);
|
||||
mTuner = null;
|
||||
}
|
||||
|
||||
getSpectralDisplay().clear();
|
||||
|
||||
runOnFxThread(() -> {
|
||||
if(getTuner() == null)
|
||||
{
|
||||
SpectralDisplayModel model = getSpectralDisplay().getModel();
|
||||
model.sampleRateProperty().set(0);
|
||||
model.centerFrequencyProperty().set(0);
|
||||
model.resetZoom();
|
||||
getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER);
|
||||
mDisplayedTuner.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processingEnabledChanged()
|
||||
{
|
||||
ThreadPool.CACHED.submit(() -> {
|
||||
synchronized(mTunerLock)
|
||||
{
|
||||
updateProcessing();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or stops processing the tuner sample stream. Hold the tuner lock when calling.
|
||||
*/
|
||||
private void updateProcessing()
|
||||
{
|
||||
if(mTuner != null && isProcessingEnabled())
|
||||
{
|
||||
startProcessing();
|
||||
}
|
||||
else
|
||||
{
|
||||
stopProcessing();
|
||||
}
|
||||
}
|
||||
|
||||
private void startProcessing()
|
||||
{
|
||||
if(!mProcessing && mTuner != null)
|
||||
{
|
||||
mProcessing = true;
|
||||
startDft();
|
||||
|
||||
try
|
||||
{
|
||||
mTuner.getTunerController().addBufferListener(getDftProcessor());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOGGER.error("Error registering spectral display for tuner sample buffers", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void stopProcessing()
|
||||
{
|
||||
if(mProcessing)
|
||||
{
|
||||
mProcessing = false;
|
||||
|
||||
if(mTuner != null)
|
||||
{
|
||||
mTuner.getTunerController().removeBufferListener(getDftProcessor());
|
||||
}
|
||||
|
||||
stopDft();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives frequency and sample rate changes from the tuner.
|
||||
*/
|
||||
@Override
|
||||
public void process(SourceEvent event)
|
||||
{
|
||||
switch(event.getEvent())
|
||||
{
|
||||
case NOTIFICATION_FREQUENCY_CHANGE ->
|
||||
{
|
||||
long frequency = event.getValue().longValue();
|
||||
runOnFxThread(() -> getSpectralDisplay().getModel().centerFrequencyProperty().set(frequency));
|
||||
}
|
||||
case NOTIFICATION_SAMPLE_RATE_CHANGE ->
|
||||
{
|
||||
int sampleRate = event.getValue().intValue();
|
||||
runOnFxThread(() -> getSpectralDisplay().getModel().sampleRateProperty().set(sampleRate));
|
||||
}
|
||||
default ->
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops showing the tuner and releases resources. Call from a thread other than the JavaFX application thread
|
||||
* when a tuner is showing, since stopping the sample stream can block.
|
||||
*/
|
||||
@Override
|
||||
public void dispose()
|
||||
{
|
||||
clearTuner();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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 javafx.scene.paint.Color;
|
||||
|
||||
/**
|
||||
* Builds waterfall color palettes that map an intensity index (0 - 255) to an opaque ARGB color.
|
||||
*/
|
||||
public class WaterfallPalette
|
||||
{
|
||||
public static final int SIZE = 256;
|
||||
|
||||
private WaterfallPalette()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette of the original Swing waterfall: dark blue noise floor, a blue to yellow ramp, yellow fading to orange,
|
||||
* then red.
|
||||
*/
|
||||
public static int[] classic()
|
||||
{
|
||||
int[] palette = new int[SIZE];
|
||||
|
||||
for(int x = 0; x < 16; x++)
|
||||
{
|
||||
palette[x] = argb(0, 0, 127);
|
||||
}
|
||||
|
||||
for(int x = 16; x < 32; x++)
|
||||
{
|
||||
palette[x] = argb(0, 0, 191);
|
||||
}
|
||||
|
||||
int r = 0;
|
||||
int g = 0;
|
||||
int b = 191;
|
||||
|
||||
for(int x = 32; x < 60; x++)
|
||||
{
|
||||
palette[x] = argb(r, g, b);
|
||||
r += 9;
|
||||
g += 9;
|
||||
b -= 6;
|
||||
}
|
||||
|
||||
g = 255;
|
||||
|
||||
for(int x = 60; x < 188; x++)
|
||||
{
|
||||
palette[x] = argb(255, g, 0);
|
||||
g -= 2;
|
||||
}
|
||||
|
||||
for(int x = 188; x < SIZE; x++)
|
||||
{
|
||||
palette[x] = argb(255, 0, 0);
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* Palette that blends linearly between evenly spaced colors.
|
||||
* @param colors web color strings (for example #440154), lowest intensity first
|
||||
*/
|
||||
public static int[] gradient(String... colors)
|
||||
{
|
||||
if(colors.length < 2)
|
||||
{
|
||||
throw new IllegalArgumentException("A gradient palette needs at least two colors");
|
||||
}
|
||||
|
||||
Color[] stops = new Color[colors.length];
|
||||
|
||||
for(int x = 0; x < colors.length; x++)
|
||||
{
|
||||
stops[x] = Color.web(colors[x]);
|
||||
}
|
||||
|
||||
int[] palette = new int[SIZE];
|
||||
int segments = stops.length - 1;
|
||||
|
||||
for(int x = 0; x < SIZE; x++)
|
||||
{
|
||||
double position = (double)x / (SIZE - 1) * segments;
|
||||
int segment = Math.min(segments - 1, (int)Math.floor(position));
|
||||
Color color = stops[segment].interpolate(stops[segment + 1], position - segment);
|
||||
palette[x] = argb((int)Math.round(color.getRed() * 255), (int)Math.round(color.getGreen() * 255),
|
||||
(int)Math.round(color.getBlue() * 255));
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaFX color for an ARGB palette entry.
|
||||
*/
|
||||
public static Color toColor(int argb)
|
||||
{
|
||||
return Color.rgb((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF);
|
||||
}
|
||||
|
||||
private static int argb(int r, int g, int b)
|
||||
{
|
||||
return 0xFF000000 | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -1,801 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.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;
|
||||
import io.github.dsheirer.gui.playlist.channel.ViewChannelRequest;
|
||||
import io.github.dsheirer.gui.spectrum.ConfigurationRow;
|
||||
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.SpectralDisplayTheme;
|
||||
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;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Playlist channel layer. Draws each playlist channel, enabled (processing) or disabled, and each active traffic
|
||||
* channel (tracked from the channel processing start and stop events) as a translucent box at its frequency and bandwidth, labeled with the system, site, channel name and
|
||||
* decoder. Processing and selected channels use their own colors. When the decoder applies a frequency correction,
|
||||
* a bracket at the bottom of the box shows the corrected channel position and the PPM error.
|
||||
*
|
||||
* When the zoom level is too low for a channel's labels to fit in its box, moving the mouse over the channel shows a
|
||||
* popout card near the bottom of the spectrum (or the waterfall) with the channel name, system, site, decoder,
|
||||
* frequency and state. The popout can be turned off with the hover details setting.
|
||||
*
|
||||
* Right-clicking a channel adds a menu item to view or edit the channel in the playlist editor.
|
||||
*/
|
||||
public class ChannelLayer extends SpectralDisplayLayer
|
||||
{
|
||||
private static final Font FONT = Font.font(11);
|
||||
private static final DecimalFormat PPM_FORMAT = new DecimalFormat("0.0");
|
||||
private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000");
|
||||
private static final Font POPOUT_TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11);
|
||||
private static final double MIN_LABEL_BOX_WIDTH = 12;
|
||||
private static final double POPOUT_MIN_HIT_WIDTH = 10;
|
||||
private static final double POPOUT_PADDING = 6;
|
||||
private static final double POPOUT_GAP = 5;
|
||||
private static final double POPOUT_SWATCH = 8;
|
||||
private static final int POPOUT_MAX_CHANNELS = 4;
|
||||
|
||||
/**
|
||||
* Which channels the layer shows.
|
||||
*/
|
||||
public enum ChannelDisplay
|
||||
{
|
||||
ALL("All Channels"),
|
||||
ENABLED("Enabled Channels"),
|
||||
NONE("No Channels");
|
||||
|
||||
private final String mLabel;
|
||||
|
||||
ChannelDisplay(String label)
|
||||
{
|
||||
mLabel = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return mLabel;
|
||||
}
|
||||
}
|
||||
|
||||
private final ChannelModel mChannelModel;
|
||||
private final ChannelProcessingManager mChannelProcessingManager;
|
||||
private final Listener<ChannelEvent> mChannelEventListener = this::receive;
|
||||
//Active traffic channels. Traffic channel managers keep their temporary channels out of the channel model, so
|
||||
//they are tracked from the processing start and stop events.
|
||||
private final Set<Channel> mTrafficChannels = ConcurrentHashMap.newKeySet();
|
||||
private final ObjectProperty<ChannelDisplay> mChannelDisplay = new SimpleObjectProperty<>(ChannelDisplay.ALL);
|
||||
private final BooleanProperty mHoverDetails = new SimpleBooleanProperty(true);
|
||||
private final Text mMeasure = new Text();
|
||||
private final double mTextHeight;
|
||||
private volatile boolean mChannelsChanged = true;
|
||||
private List<Channel> mChannels = new ArrayList<>();
|
||||
private long mQueryMinimum;
|
||||
private long mQueryMaximum;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
* @param channelModel for the playlist channels and the active traffic channels
|
||||
* @param channelProcessingManager for channel processing start and stop events
|
||||
*/
|
||||
public ChannelLayer(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager)
|
||||
{
|
||||
mChannelModel = channelModel;
|
||||
mChannelProcessingManager = channelProcessingManager;
|
||||
mMeasure.setFont(FONT);
|
||||
mMeasure.setText("Ag");
|
||||
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||
mChannelDisplay.addListener((ob, ov, nv) -> requestRender());
|
||||
mHoverDetails.addListener((ob, ov, nv) -> requestRender());
|
||||
|
||||
if(mChannelModel != null)
|
||||
{
|
||||
mChannelModel.addListener(mChannelEventListener);
|
||||
}
|
||||
|
||||
if(mChannelProcessingManager != null)
|
||||
{
|
||||
mChannelProcessingManager.addChannelEventListener(mChannelEventListener);
|
||||
|
||||
//Traffic channels that were already running before this layer was created
|
||||
for(Channel channel: mChannelProcessingManager.getProcessingChannels())
|
||||
{
|
||||
if(channel.isTrafficChannel())
|
||||
{
|
||||
mTrafficChannels.add(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks active traffic channels and refreshes the channels on any channel change.
|
||||
*/
|
||||
private void receive(ChannelEvent event)
|
||||
{
|
||||
Channel channel = event.getChannel();
|
||||
|
||||
if(channel != null && channel.isTrafficChannel())
|
||||
{
|
||||
switch(event.getEvent())
|
||||
{
|
||||
case NOTIFICATION_PROCESSING_START -> mTrafficChannels.add(channel);
|
||||
case NOTIFICATION_PROCESSING_STOP, NOTIFICATION_PROCESSING_START_REJECTED, NOTIFICATION_DELETE ->
|
||||
mTrafficChannels.remove(channel);
|
||||
default ->
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channelsChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which channels to show.
|
||||
*/
|
||||
public ObjectProperty<ChannelDisplay> channelDisplayProperty()
|
||||
{
|
||||
return mChannelDisplay;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose()
|
||||
{
|
||||
if(mChannelModel != null)
|
||||
{
|
||||
mChannelModel.removeListener(mChannelEventListener);
|
||||
}
|
||||
|
||||
if(mChannelProcessingManager != null)
|
||||
{
|
||||
mChannelProcessingManager.removeChannelEventListener(mChannelEventListener);
|
||||
}
|
||||
|
||||
mChannels.clear();
|
||||
mTrafficChannels.clear();
|
||||
}
|
||||
|
||||
private void channelsChanged()
|
||||
{
|
||||
mChannelsChanged = true;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigurationTitle()
|
||||
{
|
||||
return "Channels";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ConfigurationRow> getConfigurationRows()
|
||||
{
|
||||
ComboBox<ChannelDisplay> comboBox = new ComboBox<>();
|
||||
comboBox.getItems().addAll(ChannelDisplay.values());
|
||||
comboBox.valueProperty().bindBidirectional(mChannelDisplay);
|
||||
comboBox.setMaxWidth(Double.MAX_VALUE);
|
||||
ConfigurationRow details = ConfigurationRow.toggle("Hover Details", mHoverDetails);
|
||||
Tooltip.install(details.control(), new Tooltip("Show channel details under the mouse when the channel " +
|
||||
"labels don't fit at the current zoom"));
|
||||
return List.of(new ConfigurationRow("Show", comboBox), details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a popout card with the details of the channels under the mouse when their labels don't fit.
|
||||
*/
|
||||
public BooleanProperty hoverDetailsProperty()
|
||||
{
|
||||
return mHoverDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels that overlap the source bandwidth, refreshed when the channels or the source frequency change.
|
||||
*/
|
||||
private List<Channel> getChannels(SpectralDisplayModel model)
|
||||
{
|
||||
long minimum = model.getMinFrequency();
|
||||
long maximum = model.getMaxFrequency();
|
||||
|
||||
if(mChannelsChanged || minimum != mQueryMinimum || maximum != mQueryMaximum)
|
||||
{
|
||||
try
|
||||
{
|
||||
mChannelsChanged = false;
|
||||
List<Channel> channels = mChannelModel != null ?
|
||||
mChannelModel.getChannelsInFrequencyRange(minimum, maximum) : new ArrayList<>();
|
||||
|
||||
for(Channel trafficChannel: mTrafficChannels)
|
||||
{
|
||||
if(!channels.contains(trafficChannel) && trafficChannel.isWithin(minimum, maximum))
|
||||
{
|
||||
channels.add(trafficChannel);
|
||||
}
|
||||
}
|
||||
|
||||
mChannels = channels;
|
||||
mQueryMinimum = minimum;
|
||||
mQueryMaximum = maximum;
|
||||
}
|
||||
catch(ConcurrentModificationException cme)
|
||||
{
|
||||
//The channel lists changed while reading them - try again on the next frame
|
||||
mChannelsChanged = true;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
return mChannels;
|
||||
}
|
||||
|
||||
private boolean isShown(Channel channel)
|
||||
{
|
||||
return switch(mChannelDisplay.get())
|
||||
{
|
||||
case ALL -> true;
|
||||
case ENABLED -> channel.isProcessing();
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||
|
||||
for(Channel channel: getChannels(model))
|
||||
{
|
||||
if(!isShown(channel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Color color = channel.isSelected() ? theme.getChannelSelected() :
|
||||
channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel();
|
||||
gc.setFill(color);
|
||||
|
||||
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||
{
|
||||
Box box = getBox(model, tunerChannel, area);
|
||||
|
||||
if(box != null)
|
||||
{
|
||||
gc.fillRect(box.x(), 0, box.width(), area.plotHeight());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(model == null || !model.hasSource() || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||
Color line = theme.getLine();
|
||||
gc.setFont(FONT);
|
||||
gc.setTextBaseline(VPos.TOP);
|
||||
gc.setLineWidth(1.0);
|
||||
|
||||
for(Channel channel: getChannels(model))
|
||||
{
|
||||
if(!isShown(channel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||
{
|
||||
Box box = getBox(model, tunerChannel, area);
|
||||
|
||||
if(box == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double center = Math.floor(box.center()) + 0.5;
|
||||
gc.setStroke(line);
|
||||
gc.strokeLine(center, area.plotHeight() * 0.65, center, area.plotHeight() - 1);
|
||||
|
||||
if(box.width() >= MIN_LABEL_BOX_WIDTH)
|
||||
{
|
||||
gc.save();
|
||||
gc.beginPath();
|
||||
gc.rect(box.x(), 0, box.width(), area.plotHeight());
|
||||
gc.clip();
|
||||
gc.setFill(line);
|
||||
double y = 1;
|
||||
y = drawLabel(gc, channel.hasSystem() ? channel.getSystem() : null, box, y);
|
||||
y = drawLabel(gc, channel.hasSite() ? channel.getSite() : null, box, y);
|
||||
y = drawLabel(gc, channel.getName(), box, y);
|
||||
drawLabel(gc, channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(), box, y);
|
||||
gc.restore();
|
||||
}
|
||||
|
||||
int correction = channel.getChannelFrequencyCorrection();
|
||||
|
||||
if(correction != 0)
|
||||
{
|
||||
drawCorrection(gc, model, area, tunerChannel, box, correction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderPopout(gc, area, DisplaySection.SPECTRUM);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
renderPopout(gc, area, DisplaySection.WATERFALL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a card near the bottom of the section under the mouse with the details of each channel under the mouse
|
||||
* whose labels don't fit in its box at the current zoom. The card moves to the top of the section when the mouse
|
||||
* is over the bottom of the section.
|
||||
*/
|
||||
private void renderPopout(GraphicsContext gc, RenderArea area, DisplaySection section)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
DisplayCursor cursor = getSpectralDisplay().getDisplayCursor();
|
||||
|
||||
if(!mHoverDetails.get() || model == null || !model.hasSource() || cursor == null ||
|
||||
cursor.section() != section || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<PopoutEntry> entries = getPopoutEntries(model, area, cursor.x());
|
||||
|
||||
if(entries.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||
int shown = Math.min(entries.size(), POPOUT_MAX_CHANNELS);
|
||||
String more = entries.size() > shown ? "+ " + (entries.size() - shown) + " more" : null;
|
||||
|
||||
//Measure the card
|
||||
double textWidth = 0;
|
||||
int lineCount = 0;
|
||||
|
||||
for(int x = 0; x < shown; x++)
|
||||
{
|
||||
PopoutEntry entry = entries.get(x);
|
||||
textWidth = Math.max(textWidth, measure(entry.title(), POPOUT_TITLE_FONT) + POPOUT_SWATCH + 5);
|
||||
|
||||
for(String line: entry.details())
|
||||
{
|
||||
textWidth = Math.max(textWidth, measure(line, FONT));
|
||||
}
|
||||
|
||||
lineCount += 1 + entry.details().size();
|
||||
}
|
||||
|
||||
if(more != null)
|
||||
{
|
||||
textWidth = Math.max(textWidth, measure(more, FONT));
|
||||
lineCount++;
|
||||
}
|
||||
|
||||
double width = textWidth + (POPOUT_PADDING * 2);
|
||||
double height = (lineCount * mTextHeight) + ((shown - 1) * POPOUT_GAP) + (POPOUT_PADDING * 2);
|
||||
double bottom = section == DisplaySection.SPECTRUM ? area.plotHeight() : area.height();
|
||||
double x = Math.max(4, Math.min(area.width() - width - 4, cursor.x() - (width / 2.0)));
|
||||
double y = bottom - height - 6;
|
||||
|
||||
//Keep the card out from under the mouse
|
||||
if(cursor.y() >= y - 4)
|
||||
{
|
||||
y = 6;
|
||||
}
|
||||
|
||||
if(y < 0 || width > area.width())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
gc.setFill(theme.getBackground(0.88));
|
||||
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 lineY = y + POPOUT_PADDING;
|
||||
double textX = x + POPOUT_PADDING;
|
||||
|
||||
for(int index = 0; index < shown; index++)
|
||||
{
|
||||
PopoutEntry entry = entries.get(index);
|
||||
double swatchY = lineY + ((mTextHeight - POPOUT_SWATCH) / 2.0);
|
||||
gc.setFill(opaque(entry.color()));
|
||||
gc.fillRect(textX, swatchY, POPOUT_SWATCH, POPOUT_SWATCH);
|
||||
gc.setFill(theme.getCursor());
|
||||
gc.setFont(POPOUT_TITLE_FONT);
|
||||
gc.fillText(entry.title(), textX + POPOUT_SWATCH + 5, lineY);
|
||||
lineY += mTextHeight;
|
||||
gc.setFill(opaque(theme.getLine()));
|
||||
gc.setFont(FONT);
|
||||
|
||||
for(String line: entry.details())
|
||||
{
|
||||
gc.fillText(line, textX, lineY);
|
||||
lineY += mTextHeight;
|
||||
}
|
||||
|
||||
lineY += POPOUT_GAP;
|
||||
}
|
||||
|
||||
if(more != null)
|
||||
{
|
||||
gc.fillText(more, textX, lineY - POPOUT_GAP);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels under the screen x position whose labels don't fit in the channel box. The hit area of a narrow
|
||||
* channel is widened so it can be found with the mouse.
|
||||
*/
|
||||
private List<PopoutEntry> getPopoutEntries(SpectralDisplayModel model, RenderArea area, double cursorX)
|
||||
{
|
||||
List<PopoutEntry> entries = new ArrayList<>();
|
||||
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||
|
||||
for(Channel channel: getChannels(model))
|
||||
{
|
||||
if(!isShown(channel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||
{
|
||||
Box box = getBox(model, tunerChannel, area);
|
||||
|
||||
if(box == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double hitWidth = Math.max(POPOUT_MIN_HIT_WIDTH, box.width());
|
||||
double hitStart = box.center() - (hitWidth / 2.0);
|
||||
|
||||
if(cursorX < hitStart || cursorX > hitStart + hitWidth || labelsFit(channel, box))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
List<String> details = new ArrayList<>();
|
||||
String systemSite = join(channel.hasSystem() ? channel.getSystem() : null,
|
||||
channel.hasSite() ? channel.getSite() : null);
|
||||
|
||||
if(systemSite != null)
|
||||
{
|
||||
details.add(systemSite);
|
||||
}
|
||||
|
||||
String state = channel.isProcessing() ? "Enabled" : "Disabled";
|
||||
|
||||
if(channel.isSelected())
|
||||
{
|
||||
state += ", Selected";
|
||||
}
|
||||
|
||||
details.add(join(channel.getDecodeConfiguration().getDecoderType().getShortDisplayString(),
|
||||
FREQUENCY_FORMAT.format(tunerChannel.getFrequency() / 1E6) + " MHz", state));
|
||||
|
||||
Color color = channel.isSelected() ? theme.getChannelSelected() :
|
||||
channel.isProcessing() ? theme.getChannelProcessing() : theme.getChannel();
|
||||
entries.add(new PopoutEntry(channel.getName() != null ? channel.getName() : "(unnamed)", details,
|
||||
color));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if all of the channel labels fit inside the channel box without clipping.
|
||||
*/
|
||||
private boolean labelsFit(Channel channel, Box box)
|
||||
{
|
||||
if(box.width() < MIN_LABEL_BOX_WIDTH)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] labels = {channel.hasSystem() ? channel.getSystem() : null,
|
||||
channel.hasSite() ? channel.getSite() : null, channel.getName(),
|
||||
channel.getDecodeConfiguration().getDecoderType().getShortDisplayString()};
|
||||
|
||||
for(String label: labels)
|
||||
{
|
||||
if(label != null && !label.isEmpty() && measure(label, FONT) > box.width())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private double measure(String text, Font font)
|
||||
{
|
||||
mMeasure.setFont(font);
|
||||
mMeasure.setText(text);
|
||||
double width = mMeasure.getLayoutBounds().getWidth();
|
||||
mMeasure.setFont(FONT);
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the non-empty values with a separator, or returns null when there are none.
|
||||
*/
|
||||
private static String join(String... values)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for(String value: values)
|
||||
{
|
||||
if(value != null && !value.isEmpty())
|
||||
{
|
||||
if(!sb.isEmpty())
|
||||
{
|
||||
sb.append(" \u00B7 ");
|
||||
}
|
||||
|
||||
sb.append(value);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.isEmpty() ? null : sb.toString();
|
||||
}
|
||||
|
||||
private static Color opaque(Color color)
|
||||
{
|
||||
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the label centered in the box, or left aligned and clipped when it is wider than the box.
|
||||
* @return y position for the next label
|
||||
*/
|
||||
private double drawLabel(GraphicsContext gc, String text, Box box, double y)
|
||||
{
|
||||
if(text != null && !text.isEmpty())
|
||||
{
|
||||
mMeasure.setText(text);
|
||||
double width = mMeasure.getLayoutBounds().getWidth();
|
||||
|
||||
if(width > box.width())
|
||||
{
|
||||
gc.setTextAlign(TextAlignment.LEFT);
|
||||
gc.fillText(text, box.x() + 1, y);
|
||||
}
|
||||
else
|
||||
{
|
||||
gc.setTextAlign(TextAlignment.CENTER);
|
||||
gc.fillText(text, box.center(), y);
|
||||
}
|
||||
}
|
||||
|
||||
return y + mTextHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a bracket at the bottom of the channel at the corrected frequency, labeled with the PPM error.
|
||||
*/
|
||||
private void drawCorrection(GraphicsContext gc, SpectralDisplayModel model, RenderArea area,
|
||||
TunerChannel tunerChannel, Box box, int correction)
|
||||
{
|
||||
double top = area.plotHeight() * 0.88;
|
||||
double bottom = area.plotHeight() * 0.98;
|
||||
double corrected = model.getX(tunerChannel.getFrequency() + correction, area.width());
|
||||
double start = corrected - (box.width() / 2.0);
|
||||
double stop = corrected + (box.width() / 2.0);
|
||||
|
||||
Color correctionColor = getSpectralDisplay().getTheme().getCorrection();
|
||||
gc.setStroke(correctionColor);
|
||||
gc.strokeLine(start, bottom, stop, bottom);
|
||||
gc.strokeLine(start, top, start, bottom);
|
||||
gc.strokeLine(stop, top, stop, bottom);
|
||||
|
||||
double ppm = correction / (tunerChannel.getFrequency() / 1E6);
|
||||
String label = "PPM " + PPM_FORMAT.format(ppm);
|
||||
mMeasure.setText(label);
|
||||
|
||||
if(mMeasure.getLayoutBounds().getWidth() <= box.width() && mTextHeight * 5 <= area.plotHeight())
|
||||
{
|
||||
gc.setFill(correctionColor);
|
||||
gc.setTextAlign(TextAlignment.LEFT);
|
||||
gc.setTextBaseline(VPos.BOTTOM);
|
||||
gc.fillText(label, start + 1, bottom - 2);
|
||||
gc.setTextBaseline(VPos.TOP);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen position of the tuner channel, or null when it is outside the display.
|
||||
*/
|
||||
private static Box getBox(SpectralDisplayModel model, TunerChannel tunerChannel, RenderArea area)
|
||||
{
|
||||
long minimum = (long)Math.floor(model.getDisplayMinFrequency());
|
||||
long maximum = (long)Math.ceil(model.getDisplayMaxFrequency());
|
||||
|
||||
if(tunerChannel == null || !tunerChannel.overlaps(minimum, maximum))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double center = model.getX(tunerChannel.getFrequency(), area.width());
|
||||
double width = tunerChannel.getBandwidth() / model.getDisplayBandwidth() * area.width();
|
||||
return new Box(center - (width / 2.0), width, center);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MenuItem> getContextMenuItems(DisplaySection section, long frequency)
|
||||
{
|
||||
List<MenuItem> items = new ArrayList<>();
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(model == null || mChannelDisplay.get() == ChannelDisplay.NONE)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
for(Channel channel: getChannels(model))
|
||||
{
|
||||
if(!isShown(channel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for(TunerChannel tunerChannel: channel.getTunerChannels())
|
||||
{
|
||||
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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
private record Box(double x, double width, double center)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel details for the popout card.
|
||||
*/
|
||||
private record PopoutEntry(String title, List<String> details, Color color)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.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 java.text.DecimalFormat;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.LongProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextAlignment;
|
||||
|
||||
/**
|
||||
* Channel tuning layer for a single channel's spectrum, centered on the channel frequency. Shades the channel
|
||||
* passband and marks its edges with the theme's processing channel color, and draws the estimated carrier position
|
||||
* from the decoder's frequency correction (PLL) as a line in the theme's correction color, labeled with the offset
|
||||
* from the channel center. Both markers also show on the waterfall so carrier drift is easy to follow.
|
||||
*/
|
||||
public class ChannelTuningLayer extends SpectralDisplayLayer
|
||||
{
|
||||
private static final Font FONT = Font.font(11);
|
||||
private static final DecimalFormat KHZ_FORMAT = new DecimalFormat("0.###");
|
||||
private static final DecimalFormat OFFSET_FORMAT = new DecimalFormat("+0;-0");
|
||||
|
||||
private final IntegerProperty mChannelBandwidth = new SimpleIntegerProperty(0);
|
||||
private final LongProperty mCarrierOffset = new SimpleLongProperty(0);
|
||||
private final BooleanProperty mCarrierOffsetValid = new SimpleBooleanProperty(false);
|
||||
private final Text mMeasure = new Text();
|
||||
private final double mTextHeight;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public ChannelTuningLayer()
|
||||
{
|
||||
mMeasure.setFont(FONT);
|
||||
mMeasure.setText("0123456789 kHz");
|
||||
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||
mChannelBandwidth.addListener((ob, ov, nv) -> requestRender());
|
||||
mCarrierOffset.addListener((ob, ov, nv) -> requestRender());
|
||||
mCarrierOffsetValid.addListener((ob, ov, nv) -> requestRender());
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel bandwidth in Hertz, or zero for no passband markers. JavaFX thread only.
|
||||
*/
|
||||
public IntegerProperty channelBandwidthProperty()
|
||||
{
|
||||
return mChannelBandwidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated carrier offset from the channel center in Hertz. JavaFX thread only.
|
||||
*/
|
||||
public LongProperty carrierOffsetProperty()
|
||||
{
|
||||
return mCarrierOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the decoder has reported a carrier offset. The carrier marker only shows when true.
|
||||
*/
|
||||
public BooleanProperty carrierOffsetValidProperty()
|
||||
{
|
||||
return mCarrierOffsetValid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigurationTitle()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
double[] edges = getEdges(area.width());
|
||||
|
||||
if(edges != null)
|
||||
{
|
||||
gc.setFill(fade(getTheme().getChannelProcessing(), 0.6));
|
||||
gc.fillRect(edges[0], 0, edges[1] - edges[0], area.plotHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayTheme theme = getTheme();
|
||||
double[] edges = getEdges(area.width());
|
||||
|
||||
gc.setFont(FONT);
|
||||
gc.setTextBaseline(VPos.TOP);
|
||||
gc.setLineWidth(1.0);
|
||||
|
||||
if(edges != null)
|
||||
{
|
||||
Color edge = opaque(theme.getChannelProcessing());
|
||||
gc.setStroke(edge);
|
||||
gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.plotHeight());
|
||||
gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.plotHeight());
|
||||
|
||||
String label = formatBandwidth(mChannelBandwidth.get());
|
||||
mMeasure.setText(label);
|
||||
|
||||
if(mMeasure.getLayoutBounds().getWidth() + 8 < edges[1] - edges[0])
|
||||
{
|
||||
gc.setFill(edge);
|
||||
gc.setTextAlign(TextAlignment.CENTER);
|
||||
gc.fillText(label, (edges[0] + edges[1]) / 2.0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
Double carrier = getCarrierX(area.width());
|
||||
|
||||
if(carrier != null)
|
||||
{
|
||||
Color color = theme.getCorrection();
|
||||
gc.setStroke(color);
|
||||
gc.strokeLine(snap(carrier), 0, snap(carrier), area.plotHeight());
|
||||
|
||||
String label = "PLL " + OFFSET_FORMAT.format(mCarrierOffset.get()) + " Hz";
|
||||
mMeasure.setText(label);
|
||||
double width = mMeasure.getLayoutBounds().getWidth();
|
||||
double x = carrier + 5;
|
||||
|
||||
if(x + width + 4 > area.width())
|
||||
{
|
||||
x = carrier - 5 - width;
|
||||
}
|
||||
|
||||
double y = mTextHeight + 6;
|
||||
gc.setFill(theme.getBackground(0.7));
|
||||
gc.fillRoundRect(x - 3, y - 1, width + 6, mTextHeight + 2, 4, 4);
|
||||
gc.setFill(color);
|
||||
gc.setTextAlign(TextAlignment.LEFT);
|
||||
gc.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayTheme theme = getTheme();
|
||||
double[] edges = getEdges(area.width());
|
||||
gc.setLineWidth(1.0);
|
||||
|
||||
if(edges != null)
|
||||
{
|
||||
gc.setStroke(fade(opaque(theme.getChannelProcessing()), 0.7));
|
||||
gc.setLineDashes(4.0, 4.0);
|
||||
gc.strokeLine(snap(edges[0]), 0, snap(edges[0]), area.height());
|
||||
gc.strokeLine(snap(edges[1]), 0, snap(edges[1]), area.height());
|
||||
gc.setLineDashes((double[])null);
|
||||
}
|
||||
|
||||
Double carrier = getCarrierX(area.width());
|
||||
|
||||
if(carrier != null)
|
||||
{
|
||||
gc.setStroke(fade(theme.getCorrection(), 0.6));
|
||||
gc.strokeLine(snap(carrier), 0, snap(carrier), area.height());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen x positions of the lower and upper channel edges, or null when there is no channel bandwidth.
|
||||
*/
|
||||
private double[] getEdges(double width)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
int bandwidth = mChannelBandwidth.get();
|
||||
|
||||
if(model == null || !model.hasSource() || bandwidth <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long center = model.getCenterFrequency();
|
||||
return new double[]{model.getX(center - (bandwidth / 2.0), width), model.getX(center + (bandwidth / 2.0),
|
||||
width)};
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen x position of the estimated carrier, or null when there is no carrier estimate.
|
||||
*/
|
||||
private Double getCarrierX(double width)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(model == null || !model.hasSource() || !mCarrierOffsetValid.get())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return model.getX(model.getCenterFrequency() + mCarrierOffset.get(), width);
|
||||
}
|
||||
|
||||
private SpectralDisplayTheme getTheme()
|
||||
{
|
||||
return getSpectralDisplay().getTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a bandwidth in Hz or kHz.
|
||||
*/
|
||||
static String formatBandwidth(int hertz)
|
||||
{
|
||||
return hertz >= 1000 ? KHZ_FORMAT.format(hertz / 1000.0) + " kHz" : hertz + " Hz";
|
||||
}
|
||||
|
||||
private static Color opaque(Color color)
|
||||
{
|
||||
return Color.color(color.getRed(), color.getGreen(), color.getBlue());
|
||||
}
|
||||
|
||||
private static Color fade(Color color, double fraction)
|
||||
{
|
||||
return Color.color(color.getRed(), color.getGreen(), color.getBlue(), color.getOpacity() * fraction);
|
||||
}
|
||||
|
||||
private static double snap(double x)
|
||||
{
|
||||
return Math.floor(x) + 0.5;
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.gui.spectrum.ConfigurationRow;
|
||||
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 javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.geometry.VPos;
|
||||
import javafx.scene.canvas.GraphicsContext;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextAlignment;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Decibel scale layer. Draws horizontal grid lines across the spectrum at even dBFS steps, with labels along the
|
||||
* left edge. The step size (1, 2, 5, 10 or 20 dB) adapts to the spectrum height so the labels don't crowd.
|
||||
*
|
||||
* The spectrum plot runs from 0 dBFS at the top to minus the model's dynamic range at the bottom.
|
||||
*/
|
||||
public class DecibelLayer extends SpectralDisplayLayer
|
||||
{
|
||||
private static final Font FONT = Font.font(11);
|
||||
private static final double[] STEPS = {1, 2, 5, 10, 20, 50};
|
||||
private static final double MIN_LINE_SPACING = 28;
|
||||
private static final double TOP_MARGIN = 22;
|
||||
|
||||
private final BooleanProperty mVisible = new SimpleBooleanProperty(true);
|
||||
private final Text mMeasure = new Text();
|
||||
private final double mTextHeight;
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public DecibelLayer()
|
||||
{
|
||||
mMeasure.setFont(FONT);
|
||||
mMeasure.setText("-0123456789 dBFS");
|
||||
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||
mVisible.addListener((ob, ov, nv) -> requestRender());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or hides the decibel scale.
|
||||
*/
|
||||
public BooleanProperty visibleProperty()
|
||||
{
|
||||
return mVisible;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigurationTitle()
|
||||
{
|
||||
//A single switch - list it in the Display section instead of a section of its own
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ConfigurationRow> getConfigurationRows()
|
||||
{
|
||||
return List.of(ConfigurationRow.toggle("dB Scale", mVisible));
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid line spacing in dB so the lines are at least MIN_LINE_SPACING pixels apart.
|
||||
*/
|
||||
static double getStep(double range, double plotHeight)
|
||||
{
|
||||
if(plotHeight <= 0 || range <= 0)
|
||||
{
|
||||
return STEPS[STEPS.length - 1];
|
||||
}
|
||||
|
||||
double pixelsPerDb = plotHeight / range;
|
||||
|
||||
for(double step: STEPS)
|
||||
{
|
||||
if(step * pixelsPerDb >= MIN_LINE_SPACING)
|
||||
{
|
||||
return step;
|
||||
}
|
||||
}
|
||||
|
||||
return STEPS[STEPS.length - 1];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(!mVisible.get() || model == null || !model.hasSource())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double range = model.getDynamicRange();
|
||||
double step = getStep(range, area.plotHeight());
|
||||
gc.setStroke(getSpectralDisplay().getTheme().getLine(0.35));
|
||||
gc.setLineWidth(1.0);
|
||||
gc.setLineDashes(2.0, 4.0);
|
||||
|
||||
for(double decibels = -step; decibels > -range; decibels -= step)
|
||||
{
|
||||
double y = Math.floor(getY(decibels, range, area.plotHeight())) + 0.5;
|
||||
gc.strokeLine(0, y, area.width(), y);
|
||||
}
|
||||
|
||||
gc.setLineDashes((double[])null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(!mVisible.get() || model == null || !model.hasSource())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SpectralDisplayTheme theme = getSpectralDisplay().getTheme();
|
||||
double range = model.getDynamicRange();
|
||||
double plotHeight = area.plotHeight();
|
||||
double step = getStep(range, plotHeight);
|
||||
boolean unitShown = false;
|
||||
|
||||
gc.setFont(FONT);
|
||||
gc.setTextAlign(TextAlignment.LEFT);
|
||||
gc.setTextBaseline(VPos.CENTER);
|
||||
|
||||
for(double decibels = -step; decibels > -range; decibels -= step)
|
||||
{
|
||||
double y = getY(decibels, range, plotHeight);
|
||||
|
||||
//Leave room for the zoom indicator at the top and the frequency labels at the bottom
|
||||
if(y < TOP_MARGIN || y > plotHeight - (mTextHeight / 2.0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
String label = format(decibels) + (unitShown ? "" : " dBFS");
|
||||
unitShown = true;
|
||||
mMeasure.setText(label);
|
||||
double width = mMeasure.getLayoutBounds().getWidth();
|
||||
gc.setFill(theme.getBackground(0.6));
|
||||
gc.fillRoundRect(2, y - (mTextHeight / 2.0), width + 6, mTextHeight, 4, 4);
|
||||
gc.setFill(theme.getLine());
|
||||
gc.fillText(label, 5, y);
|
||||
}
|
||||
}
|
||||
|
||||
private static double getY(double decibels, double range, double plotHeight)
|
||||
{
|
||||
return -decibels / range * plotHeight;
|
||||
}
|
||||
|
||||
private static String format(double decibels)
|
||||
{
|
||||
return decibels == Math.rint(decibels) ? String.valueOf((long)decibels) : String.valueOf(decibels);
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.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.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;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.scene.text.TextAlignment;
|
||||
|
||||
/**
|
||||
* Frequency axis layer. Draws frequency labels and tick marks along the bottom of the spectrum, faint vertical grid
|
||||
* lines at each label, and a cursor line with a frequency readout that follows the mouse across the spectrum and the
|
||||
* waterfall. Over the spectrum, the readout also shows the level under the mouse in dB.
|
||||
*/
|
||||
public class FrequencyLayer extends SpectralDisplayLayer
|
||||
{
|
||||
private static final Font FONT = Font.font(11);
|
||||
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 final Text mMeasure = new Text();
|
||||
private final double mTextHeight;
|
||||
private final DecimalFormat mLabelFormat = new DecimalFormat("0.0");
|
||||
|
||||
/**
|
||||
* Constructs an instance.
|
||||
*/
|
||||
public FrequencyLayer()
|
||||
{
|
||||
mMeasure.setFont(FONT);
|
||||
mMeasure.setText("0123456789.");
|
||||
mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigurationTitle()
|
||||
{
|
||||
return "Frequency";
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getSpectrumBottomInset()
|
||||
{
|
||||
return mTextHeight + INSET_PADDING;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumBackground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(model == null || !model.hasSource())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Scale scale = getScale(model, area.width());
|
||||
gc.setStroke(getTheme().getLine(0.35));
|
||||
gc.setLineWidth(1.0);
|
||||
gc.setLineDashes(2.0, 4.0);
|
||||
|
||||
for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.labelStep());
|
||||
frequency <= model.getDisplayMaxFrequency(); frequency += scale.labelStep())
|
||||
{
|
||||
double x = snap(model.getX(frequency, area.width()));
|
||||
gc.strokeLine(x, 0, x, area.plotHeight());
|
||||
}
|
||||
|
||||
gc.setLineDashes((double[])null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderSpectrumForeground(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(model != null && model.hasSource())
|
||||
{
|
||||
renderAxis(gc, area, model);
|
||||
}
|
||||
|
||||
renderCursor(gc, area, DisplaySection.SPECTRUM);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderWaterfall(GraphicsContext gc, RenderArea area)
|
||||
{
|
||||
renderCursor(gc, area, DisplaySection.WATERFALL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the tick marks and frequency labels in the inset below the spectrum plot.
|
||||
*/
|
||||
private void renderAxis(GraphicsContext gc, RenderArea area, SpectralDisplayModel model)
|
||||
{
|
||||
double width = area.width();
|
||||
double base = Math.floor(area.plotHeight()) + 0.5;
|
||||
Scale scale = getScale(model, width);
|
||||
Color line = getTheme().getLine();
|
||||
|
||||
gc.setStroke(line);
|
||||
gc.setLineWidth(1.0);
|
||||
|
||||
for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.minorStep());
|
||||
frequency <= model.getDisplayMaxFrequency(); frequency += scale.minorStep())
|
||||
{
|
||||
double x = snap(model.getX(frequency, width));
|
||||
double length = (frequency % scale.labelStep() == 0) ? MAJOR_TICK : MINOR_TICK;
|
||||
gc.strokeLine(x, base, x, base + length);
|
||||
}
|
||||
|
||||
gc.setFill(line);
|
||||
gc.setFont(FONT);
|
||||
gc.setTextAlign(TextAlignment.CENTER);
|
||||
gc.setTextBaseline(VPos.BOTTOM);
|
||||
setPrecision(scale.decimals());
|
||||
|
||||
for(long frequency = firstMultiple(model.getDisplayMinFrequency(), scale.labelStep());
|
||||
frequency <= model.getDisplayMaxFrequency(); frequency += scale.labelStep())
|
||||
{
|
||||
double x = model.getX(frequency, width);
|
||||
String label = mLabelFormat.format(frequency / 1E6);
|
||||
double half = measure(label) / 2.0;
|
||||
|
||||
//Skip labels that would be cut off at the edges
|
||||
if(x - half >= 0 && x + half <= width)
|
||||
{
|
||||
gc.fillText(label, x, area.height() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the cursor line in both sections and the readout in the section under the mouse.
|
||||
*/
|
||||
private void renderCursor(GraphicsContext gc, RenderArea area, DisplaySection section)
|
||||
{
|
||||
DisplayCursor cursor = getSpectralDisplay().getDisplayCursor();
|
||||
SpectralDisplayModel model = getModel();
|
||||
|
||||
if(cursor == null || model == null || !model.hasSource())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Color color = getTheme().getCursor();
|
||||
double x = snap(cursor.x());
|
||||
gc.setStroke(color);
|
||||
gc.setLineWidth(1.0);
|
||||
gc.strokeLine(x, 0, x, area.plotHeight());
|
||||
|
||||
//Another layer may show the readout lines in a larger callout
|
||||
if(cursor.section() != section || getSpectralDisplay().isCursorReadoutReplaced(cursor, area))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> lines = CursorReadout.lines(cursor, area, model);
|
||||
double textWidth = 0;
|
||||
|
||||
for(String line: lines)
|
||||
{
|
||||
textWidth = Math.max(textWidth, measure(line));
|
||||
}
|
||||
|
||||
double boxWidth = textWidth + 8;
|
||||
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);
|
||||
|
||||
for(int index = 0; index < lines.size(); index++)
|
||||
{
|
||||
gc.fillText(lines.get(index), boxX + 4, boxY + 2 + (index * mTextHeight));
|
||||
}
|
||||
}
|
||||
|
||||
private SpectralDisplayTheme getTheme()
|
||||
{
|
||||
return getSpectralDisplay().getTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the label spacing so the labels fill no more than half of the display width.
|
||||
*/
|
||||
private Scale getScale(SpectralDisplayModel model, double width)
|
||||
{
|
||||
double bandwidth = model.getDisplayBandwidth();
|
||||
int decimals = 3;
|
||||
long step = 1;
|
||||
|
||||
//Two passes: the label width depends on the number of decimals, which depends on the label spacing
|
||||
for(int pass = 0; pass < 2; pass++)
|
||||
{
|
||||
setPrecision(decimals);
|
||||
double labelWidth = measure(mLabelFormat.format(model.getDisplayMaxFrequency() / 1E6));
|
||||
double maxLabels = Math.max(1.0, width / (labelWidth * 2.0));
|
||||
step = niceStep(bandwidth / maxLabels);
|
||||
decimals = Math.max(1, Math.min(6, (int)Math.ceil(6.0 - Math.log10(step) - 1E-9)));
|
||||
}
|
||||
|
||||
long mantissa = step / (long)Math.pow(10, Math.floor(Math.log10(step)));
|
||||
long minor = Math.max(1, step / (mantissa == 2 ? 4 : 5));
|
||||
return new Scale(step, minor, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest 1, 2 or 5 times a power of ten that is at least the value, with a minimum of 1 Hertz.
|
||||
*/
|
||||
static long niceStep(double value)
|
||||
{
|
||||
if(value <= 1)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
double power = Math.pow(10, Math.floor(Math.log10(value)));
|
||||
|
||||
for(int mantissa: new int[]{1, 2, 5, 10})
|
||||
{
|
||||
if(mantissa * power >= value)
|
||||
{
|
||||
return Math.round(mantissa * power);
|
||||
}
|
||||
}
|
||||
|
||||
return Math.round(10 * power);
|
||||
}
|
||||
|
||||
/**
|
||||
* First multiple of the step at or above the frequency.
|
||||
*/
|
||||
private static long firstMultiple(double frequency, long step)
|
||||
{
|
||||
return (long)Math.ceil(frequency / step) * step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centers a line on a pixel so one pixel wide lines are sharp.
|
||||
*/
|
||||
private static double snap(double x)
|
||||
{
|
||||
return Math.floor(x) + 0.5;
|
||||
}
|
||||
|
||||
private void setPrecision(int decimals)
|
||||
{
|
||||
mLabelFormat.setMinimumFractionDigits(decimals);
|
||||
mLabelFormat.setMaximumFractionDigits(decimals);
|
||||
}
|
||||
|
||||
private double measure(String text)
|
||||
{
|
||||
mMeasure.setText(text);
|
||||
return mMeasure.getLayoutBounds().getWidth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Label and tick spacing in Hertz and the number of label decimal places (MHz).
|
||||
*/
|
||||
private record Scale(long labelStep, long minorStep, int decimals)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
@@ -19,23 +19,18 @@
|
||||
|
||||
package io.github.dsheirer.gui.symbol;
|
||||
|
||||
import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme;
|
||||
import io.github.dsheirer.module.decode.FeedbackDecoder;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import java.net.URL;
|
||||
import java.util.Arrays;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.chart.NumberAxis;
|
||||
import javafx.scene.chart.ScatterChart;
|
||||
import javafx.scene.chart.XYChart;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.paint.Color;
|
||||
|
||||
/**
|
||||
* Chart display for received QPSK symbols. Colors follow a spectral display theme: the theme background, the
|
||||
* spectrum trace color for the symbols, and the theme line color for the grid, axes and legend.
|
||||
* Chart display for received QPSK symbols
|
||||
*/
|
||||
public class SymbolView extends ChannelView implements Listener<Float>
|
||||
{
|
||||
@@ -62,14 +57,6 @@ public class SymbolView extends ChannelView implements Listener<Float>
|
||||
mSymbolSeries.getData().add(new XYChart.Data<>(x, 0));
|
||||
}
|
||||
|
||||
getStyleClass().add("symbol-view");
|
||||
URL stylesheet = SymbolView.class.getResource("/spectral_display.css");
|
||||
|
||||
if(stylesheet != null)
|
||||
{
|
||||
getStylesheets().add(stylesheet.toExternalForm());
|
||||
}
|
||||
|
||||
mSymbolChart.getData().add(mSymbolSeries);
|
||||
mSymbolChart.setMaxHeight(Double.MAX_VALUE);
|
||||
mSymbolChart.setMaxWidth(Double.MAX_VALUE);
|
||||
@@ -78,46 +65,6 @@ public class SymbolView extends ChannelView implements Listener<Float>
|
||||
getChildren().add(mSymbolChart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the theme colors now and whenever the theme changes. JavaFX thread only.
|
||||
* @param theme to follow, for example the theme shared by the spectral displays
|
||||
*/
|
||||
public void followTheme(ObservableValue<SpectralDisplayTheme> theme)
|
||||
{
|
||||
theme.addListener((ob, ov, nv) -> setTheme(nv));
|
||||
setTheme(theme.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the theme colors to the chart. JavaFX thread only.
|
||||
*/
|
||||
public void setTheme(SpectralDisplayTheme theme)
|
||||
{
|
||||
if(theme == null)
|
||||
{
|
||||
theme = SpectralDisplayTheme.getDefault();
|
||||
}
|
||||
|
||||
Color line = theme.getLine();
|
||||
Color text = Color.color(line.getRed(), line.getGreen(), line.getBlue());
|
||||
Color trace = theme.getTrace();
|
||||
Color symbol = Color.color(trace.getRed(), trace.getGreen(), trace.getBlue(),
|
||||
Math.max(0.6, trace.getOpacity()));
|
||||
|
||||
//Looked-up colors used by the symbol-view rules in spectral_display.css
|
||||
setStyle("symbol-background: " + toWeb(theme.getBackground()) + ";" +
|
||||
"symbol-color: " + toWeb(symbol) + ";" +
|
||||
"symbol-grid: " + toWeb(theme.getLine(0.35)) + ";" +
|
||||
"symbol-text: " + toWeb(text) + ";");
|
||||
}
|
||||
|
||||
private static String toWeb(Color color)
|
||||
{
|
||||
return String.format("#%02x%02x%02x%02x", Math.round(color.getRed() * 255),
|
||||
Math.round(color.getGreen() * 255), Math.round(color.getBlue() * 255),
|
||||
Math.round(color.getOpacity() * 255));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the chart axis label with the currently displayed protocol
|
||||
* @param protocol that is being processed/displayed.
|
||||
|
||||
@@ -32,7 +32,6 @@ 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;
|
||||
@@ -70,7 +69,6 @@ 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;
|
||||
@@ -137,18 +135,7 @@ 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(
|
||||
@@ -189,9 +176,8 @@ public class ThemeManager
|
||||
}
|
||||
catch(IllegalStateException ignored)
|
||||
{
|
||||
//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.
|
||||
//FX toolkit not started yet - first JFXPanel construction will start it; we
|
||||
//re-attempt the attach lazily inside applyAll() via the Window.getWindows() walk.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,9 +248,6 @@ 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
* Copyright (C) 2014-2024 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
|
||||
@@ -133,7 +133,6 @@ public class ApplicationLog
|
||||
Attributes atts = findManifestAttributes();
|
||||
if (atts != null) {
|
||||
mLog.info("SDRTrunk Version : " + atts.getValue("Implementation-Version"));
|
||||
mLog.info("Git Describe : " + atts.getValue("Git-Describe"));
|
||||
mLog.info("Gradle Version : " + atts.getValue("Created-By"));
|
||||
mLog.info("Build Timestamp : " + atts.getValue("Build-Timestamp"));
|
||||
mLog.info("Build-JDK : " + atts.getValue("Build-JDK"));
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
******************************************************************************/
|
||||
package io.github.dsheirer.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import io.github.dsheirer.settings.SettingsManager;
|
||||
import org.jdesktop.swingx.JXMapViewer;
|
||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||
|
||||
@@ -35,23 +33,14 @@ import java.awt.geom.Point2D;
|
||||
public class MapMouseListener extends MouseInputAdapter implements MouseWheelListener
|
||||
{
|
||||
private JXMapViewer mJXMapViewer;
|
||||
private Consumer<GeoPosition> mMyLocationListener;
|
||||
private Function<Point,List<JMenuItem>> mMenuItemProvider;
|
||||
private SettingsManager mSettingsManager;
|
||||
private Point mPreviousPoint;
|
||||
private Point mCurrentPoint;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param viewer map
|
||||
* @param myLocationListener receives the location chosen with the 'Set My Location Here' menu item
|
||||
* @param menuItemProvider of additional context menu items for the point that was right-clicked, listed first
|
||||
*/
|
||||
public MapMouseListener( JXMapViewer viewer, Consumer<GeoPosition> myLocationListener,
|
||||
Function<Point,List<JMenuItem>> menuItemProvider )
|
||||
public MapMouseListener( JXMapViewer viewer, SettingsManager settingsManager )
|
||||
{
|
||||
mJXMapViewer = viewer;
|
||||
mMyLocationListener = myLocationListener;
|
||||
mMenuItemProvider = menuItemProvider;
|
||||
mSettingsManager = settingsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -147,27 +136,18 @@ public class MapMouseListener extends MouseInputAdapter implements MouseWheelLis
|
||||
else if( right )
|
||||
{
|
||||
JPopupMenu popup = new JPopupMenu();
|
||||
|
||||
if( mMenuItemProvider != null )
|
||||
{
|
||||
List<JMenuItem> items = mMenuItemProvider.apply( event.getPoint() );
|
||||
|
||||
if( items != null && !items.isEmpty() )
|
||||
{
|
||||
items.forEach( popup::add );
|
||||
popup.addSeparator();
|
||||
}
|
||||
}
|
||||
|
||||
JMenuItem mapViewItem = new JMenuItem( "Set My Location Here" );
|
||||
mapViewItem.setToolTipText( "Sets your location, which is also used by the playlist editor's " +
|
||||
"Radio Reference tab, to this point" );
|
||||
JMenuItem mapViewItem = new JMenuItem( "Set Default Location & Zoom" );
|
||||
mapViewItem.addActionListener( new ActionListener()
|
||||
{
|
||||
@Override
|
||||
public void actionPerformed( ActionEvent arg0 )
|
||||
{
|
||||
mMyLocationListener.accept( mJXMapViewer.convertPointToGeoPosition( mCurrentPoint ) );
|
||||
GeoPosition position = mJXMapViewer
|
||||
.convertPointToGeoPosition( mCurrentPoint );
|
||||
|
||||
mSettingsManager.setMapViewSetting( "Default", position,
|
||||
mJXMapViewer.getZoom() );
|
||||
}
|
||||
} );
|
||||
popup.add( mapViewItem );
|
||||
|
||||
@@ -18,30 +18,17 @@
|
||||
*/
|
||||
package io.github.dsheirer.map;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import io.github.dsheirer.alias.AliasModel;
|
||||
import io.github.dsheirer.eventbus.MyEventBus;
|
||||
import io.github.dsheirer.icon.IconModel;
|
||||
import io.github.dsheirer.identifier.Form;
|
||||
import io.github.dsheirer.identifier.Identifier;
|
||||
import io.github.dsheirer.identifier.IdentifierClass;
|
||||
import io.github.dsheirer.identifier.Role;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.preference.map.MapPreference;
|
||||
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||
import io.github.dsheirer.controller.channel.Channel;
|
||||
import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions;
|
||||
import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex;
|
||||
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||
import io.github.dsheirer.settings.MapViewSetting;
|
||||
import io.github.dsheirer.settings.SettingsManager;
|
||||
import java.awt.EventQueue;
|
||||
import java.awt.Point;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import net.miginfocom.swing.MigLayout;
|
||||
import org.jdesktop.swingx.JXMapViewer;
|
||||
import org.jdesktop.swingx.OSMTileFactoryInfo;
|
||||
@@ -50,14 +37,11 @@ import org.jdesktop.swingx.input.ZoomMouseWheelListenerCursor;
|
||||
import org.jdesktop.swingx.mapviewer.DefaultTileFactory;
|
||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||
import org.jdesktop.swingx.mapviewer.TileFactoryInfo;
|
||||
import org.jdesktop.swingx.painter.CompoundPainter;
|
||||
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JSpinner;
|
||||
@@ -65,9 +49,6 @@ import javax.swing.JSplitPane;
|
||||
import javax.swing.JTable;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import javax.swing.SwingConstants;
|
||||
import javax.swing.Timer;
|
||||
import javax.swing.ToolTipManager;
|
||||
import javax.swing.event.ChangeEvent;
|
||||
import javax.swing.event.ChangeListener;
|
||||
|
||||
@@ -80,28 +61,12 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
||||
|
||||
private static final int ZOOM_MINIMUM = 1;
|
||||
private static final int ZOOM_MAXIMUM = 16;
|
||||
//Delay after the last pan or zoom before the map view is stored
|
||||
private static final int SAVE_VIEW_DELAY_MS = 500;
|
||||
|
||||
private static final String FOLLOW = "Follow";
|
||||
private static final String UNFOLLOW = "Unfollow";
|
||||
private static final String SELECT_A_TRACK = "(select a track)";
|
||||
private static final String NO_SYSTEM_NAME = "(no system name)";
|
||||
private static final String LOCATION_NOT_SET = "Not set";
|
||||
private Timer mSaveViewTimer;
|
||||
private final RadioReferenceFrequencyIndex mFrequencyIndex;
|
||||
private final RadioReferenceSitePainter mSitePainter;
|
||||
private final RadioReferencePlaylistActions mPlaylistActions;
|
||||
private JCheckBox mShowSitesCheckBox;
|
||||
private JLabel mSiteCountLabel;
|
||||
private int mDefaultTooltipDismissDelay = -1;
|
||||
private UserPreferences mUserPreferences;
|
||||
private final UserLocationPainter mUserLocationPainter = new UserLocationPainter();
|
||||
private JLabel mUserLocationLabel;
|
||||
private JButton mSetUserLocationButton;
|
||||
private JButton mCenterOnUserLocationButton;
|
||||
//Location most recently chosen on the map, which doesn't re-center the map when the preference update arrives
|
||||
private GeoPosition mMapChosenPosition;
|
||||
private SettingsManager mSettingsManager;
|
||||
private MapService mMapService;
|
||||
private JXMapViewer mMapViewer;
|
||||
private PlottableEntityPainter mMapPainter;
|
||||
@@ -128,354 +93,15 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
||||
* @param mapService for accessing entities to plot
|
||||
* @param aliasModel for alias lookup
|
||||
* @param iconModel for icon lookup
|
||||
* @param userPreferences for the map view and the user's location, which is shared with the radio reference editor
|
||||
* @param frequencyIndex of the radio reference frequencies near the user, for the trunked system sites, or null
|
||||
* @param playlistActions to add sites to the playlist or enable their channels, or null
|
||||
* @param settingsManager for user specified options/settings.
|
||||
*/
|
||||
public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, UserPreferences userPreferences,
|
||||
RadioReferenceFrequencyIndex frequencyIndex, RadioReferencePlaylistActions playlistActions)
|
||||
public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, SettingsManager settingsManager)
|
||||
{
|
||||
mUserPreferences = userPreferences;
|
||||
mFrequencyIndex = frequencyIndex;
|
||||
mPlaylistActions = playlistActions;
|
||||
mSitePainter = new RadioReferenceSitePainter(frequencyIndex != null ? frequencyIndex::getSites :
|
||||
Collections::emptyList);
|
||||
mSitePainter.setShowSites(userPreferences.getMapPreference().isShowRadioReferenceSites());
|
||||
mSettingsManager = settingsManager;
|
||||
mMapService = mapService;
|
||||
mMapPainter = new PlottableEntityPainter(aliasModel, iconModel);
|
||||
|
||||
init();
|
||||
|
||||
//Show the stored location without moving the map, which starts at the stored default location
|
||||
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||
mUserLocationPainter.setPosition(location != null ? toGeoPosition(location) : null);
|
||||
updateUserLocationLabel(location);
|
||||
|
||||
if(mFrequencyIndex != null)
|
||||
{
|
||||
//The index fills in the background
|
||||
mFrequencyIndex.addUpdateListener(() -> EventQueue.invokeLater(this::sitesUpdated));
|
||||
}
|
||||
|
||||
sitesUpdated();
|
||||
MyEventBus.getGlobalEventBus().register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the site count and repaints the sites. Call on the Swing thread.
|
||||
*/
|
||||
private void sitesUpdated()
|
||||
{
|
||||
int count = mFrequencyIndex != null ? mFrequencyIndex.getSites().size() : 0;
|
||||
getSiteCountLabel().setText(count == 1 ? "1 site" : count + " sites");
|
||||
getMapViewer().repaint();
|
||||
}
|
||||
|
||||
private JCheckBox getShowSitesCheckBox()
|
||||
{
|
||||
if(mShowSitesCheckBox == null)
|
||||
{
|
||||
mShowSitesCheckBox = new JCheckBox("Radio Reference Sites", mSitePainter.isShowSites());
|
||||
mShowSitesCheckBox.setEnabled(mFrequencyIndex != null);
|
||||
mShowSitesCheckBox.setToolTipText("Show the trunked radio system sites within " +
|
||||
(int)RadioReferenceFrequencyIndex.RADIUS_KILOMETERS + " km of your location. Requires a premium " +
|
||||
"Radio Reference login and your location.");
|
||||
mShowSitesCheckBox.addActionListener(e -> {
|
||||
boolean show = mShowSitesCheckBox.isSelected();
|
||||
mSitePainter.setShowSites(show);
|
||||
mUserPreferences.getMapPreference().setShowRadioReferenceSites(show);
|
||||
getMapViewer().repaint();
|
||||
});
|
||||
}
|
||||
|
||||
return mShowSitesCheckBox;
|
||||
}
|
||||
|
||||
private JLabel getSiteCountLabel()
|
||||
{
|
||||
if(mSiteCountLabel == null)
|
||||
{
|
||||
mSiteCountLabel = new JLabel();
|
||||
}
|
||||
|
||||
return mSiteCountLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu items for the radio reference sites under the point: add a site to the playlist and start it, or
|
||||
* enable or disable the playlist channel that already decodes it
|
||||
*/
|
||||
private List<JMenuItem> getSiteMenuItems(Point point)
|
||||
{
|
||||
List<JMenuItem> items = new ArrayList<>();
|
||||
|
||||
if(mPlaylistActions == null)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
List<SiteRecord> sites = mSitePainter.getSitesAt(getMapViewer(), point);
|
||||
|
||||
for(SiteRecord site: sites.subList(0, Math.min(sites.size(), 3)))
|
||||
{
|
||||
String label = site.systemName() + " \u00B7 Site " + site.siteNumber() +
|
||||
(site.description() != null ? " " + site.description() : "");
|
||||
String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site);
|
||||
|
||||
if(unsupported != null)
|
||||
{
|
||||
JMenuItem item = new JMenuItem(label + " \u2013 " + unsupported);
|
||||
item.setEnabled(false);
|
||||
items.add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if(RadioReferencePlaylistActions.isRepeaterSite(site))
|
||||
{
|
||||
addRepeaterSiteItems(items, site, label);
|
||||
continue;
|
||||
}
|
||||
|
||||
Channel existing = mPlaylistActions.findSiteChannel(site);
|
||||
|
||||
if(existing != null)
|
||||
{
|
||||
boolean processing = existing.isProcessing();
|
||||
JMenuItem item = new JMenuItem((processing ? "Disable: " : "Enable: ") + existing.getShortTitle());
|
||||
item.addActionListener(e -> {
|
||||
if(processing)
|
||||
{
|
||||
mPlaylistActions.disable(existing, this::showSiteError);
|
||||
}
|
||||
else
|
||||
{
|
||||
mPlaylistActions.enable(existing, this::showSiteError);
|
||||
}
|
||||
});
|
||||
items.add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
JMenuItem item = new JMenuItem("Add and Start: " + label);
|
||||
item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError));
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu items for a site whose repeaters each have a channel (LTR): add and start the repeaters that aren't in the
|
||||
* playlist, and enable or disable the site's channels
|
||||
*/
|
||||
private void addRepeaterSiteItems(List<JMenuItem> items, SiteRecord site, String label)
|
||||
{
|
||||
List<Channel> existing = mPlaylistActions.findSiteChannels(site);
|
||||
int missing = mPlaylistActions.getMissingRepeaterFrequencies(site).size();
|
||||
boolean anyDecoding = existing.stream().anyMatch(Channel::isProcessing);
|
||||
boolean anyStopped = existing.stream().anyMatch(channel -> !channel.isProcessing());
|
||||
|
||||
if(missing > 0)
|
||||
{
|
||||
JMenuItem item = new JMenuItem("Add and Start: " + label + " (" + missing + " repeater" +
|
||||
(missing > 1 ? "s" : "") + ")");
|
||||
item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError));
|
||||
items.add(item);
|
||||
}
|
||||
else if(anyStopped)
|
||||
{
|
||||
JMenuItem item = new JMenuItem("Enable: " + label + " (" + existing.size() + " channels)");
|
||||
item.addActionListener(e -> mPlaylistActions.enableAll(existing, this::showSiteError));
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
if(anyDecoding)
|
||||
{
|
||||
long decoding = existing.stream().filter(Channel::isProcessing).count();
|
||||
JMenuItem item = new JMenuItem("Disable: " + label + " (" + decoding + " channel" +
|
||||
(decoding > 1 ? "s" : "") + ")");
|
||||
item.addActionListener(e -> mPlaylistActions.disableAll(existing, this::showSiteError));
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error from a site action. Can be called from any thread.
|
||||
*/
|
||||
private void showSiteError(String message)
|
||||
{
|
||||
EventQueue.invokeLater(() -> JOptionPane.showMessageDialog(this, message, "Radio Reference",
|
||||
JOptionPane.ERROR_MESSAGE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for the radio reference sites under the point, or null when there are none
|
||||
*/
|
||||
private String getSiteCard(Point point)
|
||||
{
|
||||
List<SiteRecord> sites = mSitePainter.getSitesAt(getMapViewer(), point);
|
||||
return sites.isEmpty() ? null : RadioReferenceSiteCard.html(sites,
|
||||
mUserPreferences.getRadioReferencePreference().getLocation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user's location on the map when it changes, here or in the radio reference editor
|
||||
* @param preferenceType that was updated
|
||||
*/
|
||||
@Subscribe
|
||||
public void preferenceUpdated(PreferenceType preferenceType)
|
||||
{
|
||||
if(preferenceType == PreferenceType.RADIO_REFERENCE)
|
||||
{
|
||||
EventQueue.invokeLater(this::updateUserLocation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the user's location on the map. When the location changes, the map centers on it, unless it was chosen on
|
||||
* the map. Clearing the location removes the pin and leaves the map as is. Call on the Swing thread.
|
||||
*/
|
||||
private void updateUserLocation()
|
||||
{
|
||||
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||
GeoPosition position = location != null ? toGeoPosition(location) : null;
|
||||
updateUserLocationLabel(location);
|
||||
|
||||
if(!Objects.equals(position, mUserLocationPainter.getPosition()))
|
||||
{
|
||||
mUserLocationPainter.setPosition(position);
|
||||
|
||||
//A location chosen on the map is already in view
|
||||
if(position != null && !position.equals(mMapChosenPosition))
|
||||
{
|
||||
getMapViewer().setAddressLocation(position);
|
||||
}
|
||||
|
||||
mMapChosenPosition = null;
|
||||
|
||||
getMapViewer().repaint();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateUserLocationLabel(RadioReferencePreference.Location location)
|
||||
{
|
||||
getUserLocationLabel().setText(location != null ? location.coordinates() : LOCATION_NOT_SET);
|
||||
getCenterOnUserLocationButton().setEnabled(location != null);
|
||||
getUserLocationLabel().setToolTipText(location != null ? location.describe() :
|
||||
"Set your location with the Set Current button or in the playlist editor's Radio Reference tab");
|
||||
}
|
||||
|
||||
private JLabel getUserLocationLabel()
|
||||
{
|
||||
if(mUserLocationLabel == null)
|
||||
{
|
||||
mUserLocationLabel = new JLabel(LOCATION_NOT_SET);
|
||||
}
|
||||
|
||||
return mUserLocationLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button that sets the user's location to the center of the map
|
||||
*/
|
||||
private JButton getSetUserLocationButton()
|
||||
{
|
||||
if(mSetUserLocationButton == null)
|
||||
{
|
||||
mSetUserLocationButton = new JButton("Set Current");
|
||||
mSetUserLocationButton.setToolTipText("Sets your location, which is also used by the playlist editor's " +
|
||||
"Radio Reference tab, to the center of the map");
|
||||
mSetUserLocationButton.addActionListener(e -> setUserLocation(getMapViewer().getCenterPosition()));
|
||||
}
|
||||
|
||||
return mSetUserLocationButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button that centers the map on the user's location. Enabled when the location is set.
|
||||
*/
|
||||
private JButton getCenterOnUserLocationButton()
|
||||
{
|
||||
if(mCenterOnUserLocationButton == null)
|
||||
{
|
||||
mCenterOnUserLocationButton = new JButton("Center On");
|
||||
mCenterOnUserLocationButton.setToolTipText("Centers the map on your location");
|
||||
mCenterOnUserLocationButton.setEnabled(false);
|
||||
mCenterOnUserLocationButton.addActionListener(e -> {
|
||||
GeoPosition position = mUserLocationPainter.getPosition();
|
||||
|
||||
if(position != null)
|
||||
{
|
||||
getMapViewer().setCenterPosition(position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return mCenterOnUserLocationButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user's location to a point chosen on the map, with the Set Current button or the map's 'Set My Location
|
||||
* Here' menu item. The pin, the location label and the radio reference editor follow through the preference
|
||||
* update. The map isn't re-centered, since the point is already in view.
|
||||
* Call on the Swing thread.
|
||||
* @param position chosen on the map
|
||||
*/
|
||||
private void setUserLocation(GeoPosition position)
|
||||
{
|
||||
double latitude = round(position.getLatitude());
|
||||
double longitude = round(normalizeLongitude(position.getLongitude()));
|
||||
|
||||
if(RadioReferencePreference.Location.isValid(latitude, longitude))
|
||||
{
|
||||
mMapChosenPosition = new GeoPosition(latitude, longitude);
|
||||
mUserPreferences.getRadioReferencePreference()
|
||||
.setLocation(new RadioReferencePreference.Location(latitude, longitude, null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the map's center point and zoom level, so the map shows the same view when the application starts.
|
||||
* Call on the Swing thread.
|
||||
*/
|
||||
private void saveView()
|
||||
{
|
||||
GeoPosition center = getMapViewer().getCenterPosition();
|
||||
MapPreference preference = mUserPreferences.getMapPreference();
|
||||
|
||||
try
|
||||
{
|
||||
preference.setCenter(new MapPreference.Center(round(center.getLatitude()),
|
||||
round(normalizeLongitude(center.getLongitude()))));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
//Not a storable position - keep the previous center
|
||||
}
|
||||
|
||||
preference.setZoom(getMapViewer().getZoom());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a longitude into -180 to 180 degrees, since the map can be panned past +/-180 degrees
|
||||
*/
|
||||
private static double normalizeLongitude(double longitude)
|
||||
{
|
||||
return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds degrees to six decimal places (about 0.1 meter)
|
||||
*/
|
||||
private static double round(double degrees)
|
||||
{
|
||||
return Math.round(degrees * 1_000_000.0) / 1_000_000.0;
|
||||
}
|
||||
|
||||
private static GeoPosition toGeoPosition(RadioReferencePreference.Location location)
|
||||
{
|
||||
return new GeoPosition(location.latitude(), location.longitude());
|
||||
}
|
||||
|
||||
private void init()
|
||||
@@ -485,24 +111,16 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
||||
|
||||
JPanel cp = new JPanel();
|
||||
cp.setLayout(new MigLayout("insets 5", "[grow,fill][grow,fill][grow,fill]",
|
||||
"[][][][][grow,fill][][][][][][grow,fill][]"));
|
||||
|
||||
//The columns stretch the labels to the column width, so the label aligns its own text
|
||||
cp.add(new JLabel("My Location:", SwingConstants.RIGHT), "align right");
|
||||
cp.add(getUserLocationLabel(), "span 2, wrap");
|
||||
|
||||
cp.add(getSetUserLocationButton());
|
||||
cp.add(getCenterOnUserLocationButton(), "wrap");
|
||||
|
||||
cp.add(new JLabel("Zoom:", SwingConstants.RIGHT), "align right");
|
||||
//Spinner at its natural width, rather than filling the column
|
||||
cp.add(getMapZoomSpinner(), "wmax pref, wrap");
|
||||
|
||||
cp.add(getShowSitesCheckBox(), "span 2");
|
||||
cp.add(getSiteCountLabel(), "wrap");
|
||||
|
||||
"[grow,fill][][][][][][grow,fill][]"));
|
||||
cp.add(new JScrollPane(getPlottedTracksTable()), "span 3,wrap");
|
||||
|
||||
cp.add(getMapZoomSpinner());
|
||||
JPanel zoomCenterPanel = new JPanel();
|
||||
zoomCenterPanel.setLayout(new MigLayout("insets 0", "[]5[grow,fill]", ""));
|
||||
zoomCenterPanel.add(new JLabel("Zoom"));
|
||||
zoomCenterPanel.add(getCenterOnSelectedCheckBox());
|
||||
cp.add(zoomCenterPanel, "span 2, wrap");
|
||||
|
||||
cp.add(getDeleteTrackButton());
|
||||
cp.add(getDeleteAllTracksButton());
|
||||
cp.add(getClearMapButton(), "wrap");
|
||||
@@ -514,9 +132,7 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
||||
cp.add(getTrackHistoryLengthComboBox());
|
||||
cp.add(new JLabel("Track History Length"), "span 2, wrap");
|
||||
|
||||
cp.add(getCenterOnSelectedCheckBox(), "span 3, wrap");
|
||||
|
||||
cp.add(new JLabel("Selected System:", SwingConstants.RIGHT), "align right");
|
||||
cp.add(new JLabel("Selected System:"), "align right");
|
||||
cp.add(getSelectedTrackSystemLabel(), "span 2, wrap");
|
||||
|
||||
cp.add(new JScrollPane(getTrackHistoryTable()), "span 3,wrap");
|
||||
@@ -910,47 +526,12 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
||||
{
|
||||
if(mMapViewer == null)
|
||||
{
|
||||
//The tooltip is the card of the radio reference sites under the mouse
|
||||
mMapViewer = new JXMapViewer()
|
||||
{
|
||||
@Override
|
||||
public String getToolTipText(MouseEvent event)
|
||||
{
|
||||
return getSiteCard(event.getPoint());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Point getToolTipLocation(MouseEvent event)
|
||||
{
|
||||
return new Point(event.getX() + 16, event.getY() + 16);
|
||||
}
|
||||
};
|
||||
ToolTipManager.sharedInstance().registerComponent(mMapViewer);
|
||||
//Keep the card up long enough to read the channels while the mouse is over the map
|
||||
mMapViewer.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent event)
|
||||
{
|
||||
mDefaultTooltipDismissDelay = ToolTipManager.sharedInstance().getDismissDelay();
|
||||
ToolTipManager.sharedInstance().setDismissDelay(60_000);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent event)
|
||||
{
|
||||
if(mDefaultTooltipDismissDelay >= 0)
|
||||
{
|
||||
ToolTipManager.sharedInstance().setDismissDelay(mDefaultTooltipDismissDelay);
|
||||
}
|
||||
}
|
||||
});
|
||||
mMapViewer = new JXMapViewer();
|
||||
|
||||
/**
|
||||
* Set the entity painter as the overlay painter and register this panel to receive new messages (plots)
|
||||
*/
|
||||
//Radio reference sites under the tracks, and the user's location pin over the tracks
|
||||
mMapViewer.setOverlayPainter(new CompoundPainter<>(mSitePainter, mMapPainter, mUserLocationPainter));
|
||||
mMapViewer.setOverlayPainter(mMapPainter);
|
||||
|
||||
/**
|
||||
* Map image source
|
||||
@@ -965,46 +546,20 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener
|
||||
tileFactory.setThreadPoolSize(8);
|
||||
|
||||
/**
|
||||
* Set initial location and zoom for the map upon display: the last view, or else the user's location, or
|
||||
* else Syracuse, NY.
|
||||
* Set initial location and zoom for the map upon display
|
||||
*/
|
||||
MapPreference mapPreference = mUserPreferences.getMapPreference();
|
||||
MapPreference.Center center = mapPreference.getCenter();
|
||||
RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation();
|
||||
GeoPosition initial;
|
||||
GeoPosition syracuse = new GeoPosition(43.048, -76.147);
|
||||
int zoom = 7;
|
||||
|
||||
if(center != null)
|
||||
{
|
||||
initial = new GeoPosition(center.latitude(), center.longitude());
|
||||
}
|
||||
else if(location != null)
|
||||
{
|
||||
initial = toGeoPosition(location);
|
||||
}
|
||||
else
|
||||
{
|
||||
initial = new GeoPosition(43.048, -76.147);
|
||||
}
|
||||
MapViewSetting view = mSettingsManager.getMapViewSetting("Default", syracuse, zoom);
|
||||
|
||||
//Set the zoom on the map too: the spinner doesn't update the map when the value equals its initial value
|
||||
mMapViewer.setZoom(mapPreference.getZoom());
|
||||
mMapViewer.setAddressLocation(initial);
|
||||
mMapZoomSpinnerModel.setValue(mapPreference.getZoom());
|
||||
|
||||
//Remember the view after each pan or zoom, once the map stops moving
|
||||
mSaveViewTimer = new Timer(SAVE_VIEW_DELAY_MS, e -> saveView());
|
||||
mSaveViewTimer.setRepeats(false);
|
||||
mMapViewer.addPropertyChangeListener(event -> {
|
||||
if("centerPosition".equals(event.getPropertyName()) || "zoom".equals(event.getPropertyName()))
|
||||
{
|
||||
mSaveViewTimer.restart();
|
||||
}
|
||||
});
|
||||
mMapViewer.setAddressLocation(view.getGeoPosition());
|
||||
mMapZoomSpinnerModel.setValue(view.getZoom());
|
||||
|
||||
/**
|
||||
* Add a mouse adapter for panning and scrolling
|
||||
*/
|
||||
MapMouseListener listener = new MapMouseListener(mMapViewer, this::setUserLocation, this::getSiteMenuItems);
|
||||
MapMouseListener listener = new MapMouseListener(mMapViewer, mSettingsManager);
|
||||
mMapViewer.addMouseListener(listener);
|
||||
mMapViewer.addMouseMotionListener(listener);
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.map;
|
||||
|
||||
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||
import io.github.dsheirer.rrapi.type.Coverage;
|
||||
import io.github.dsheirer.service.radioreference.DistanceFormat;
|
||||
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HTML card with the details of radio reference trunked system sites, shown when the mouse is over a site's tower on
|
||||
* the map: the system and protocol, the site, and the site's channels.
|
||||
*/
|
||||
public final class RadioReferenceSiteCard
|
||||
{
|
||||
private static final int MAX_SITES = 3;
|
||||
private static final int CHANNEL_COLUMNS = 3;
|
||||
private static final int MAX_CHANNELS = 24;
|
||||
private static final String SEPARATOR = " · ";
|
||||
|
||||
private RadioReferenceSiteCard()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for the sites
|
||||
* @param sites under the mouse, nearest first
|
||||
* @param location of the user, for the distances, or null
|
||||
* @return HTML for a tooltip
|
||||
*/
|
||||
public static String html(List<SiteRecord> sites, RadioReferencePreference.Location location)
|
||||
{
|
||||
DecimalFormat frequencyFormat = new DecimalFormat("0.00000");
|
||||
StringBuilder sb = new StringBuilder("<html><body style='padding:3px'>");
|
||||
int shown = Math.min(sites.size(), MAX_SITES);
|
||||
|
||||
for(int index = 0; index < shown; index++)
|
||||
{
|
||||
SiteRecord site = sites.get(index);
|
||||
|
||||
if(index > 0)
|
||||
{
|
||||
sb.append("<hr>");
|
||||
}
|
||||
|
||||
sb.append("<div><b>").append(escape(site.systemName() != null ? site.systemName() : "(unnamed system)"))
|
||||
.append("</b></div>");
|
||||
appendLine(sb, site.protocol(), site.voice());
|
||||
|
||||
String siteName = "Site " + site.siteNumber();
|
||||
String distance = location != null ? DistanceFormat.format(Coverage.distanceKilometers(
|
||||
location.latitude(), location.longitude(), site.latitude(), site.longitude())) : null;
|
||||
appendLine(sb, siteName, site.description(), site.county(), distance);
|
||||
appendLine(sb, site.nac() != null ? "NAC " + site.nac() : null,
|
||||
site.rfss() > 0 ? "RFSS " + site.rfss() : null, site.zone() > 0 ? "Zone " + site.zone() : null);
|
||||
appendChannels(sb, site.channels(), frequencyFormat);
|
||||
}
|
||||
|
||||
if(sites.size() > shown)
|
||||
{
|
||||
sb.append("<hr><div>+ ").append(sites.size() - shown).append(" more site")
|
||||
.append(sites.size() - shown > 1 ? "s" : "").append(" here</div>");
|
||||
}
|
||||
|
||||
sb.append("</body></html>");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels in columns, control channels marked C and alternate control channels marked A
|
||||
*/
|
||||
private static void appendChannels(StringBuilder sb, List<SiteRecord.SiteChannel> channels,
|
||||
DecimalFormat frequencyFormat)
|
||||
{
|
||||
if(channels == null || channels.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int shown = Math.min(channels.size(), MAX_CHANNELS);
|
||||
boolean control = false;
|
||||
sb.append("<table cellspacing='0' cellpadding='1'>");
|
||||
|
||||
for(int index = 0; index < shown; index++)
|
||||
{
|
||||
if(index % CHANNEL_COLUMNS == 0)
|
||||
{
|
||||
sb.append("<tr>");
|
||||
}
|
||||
|
||||
SiteRecord.SiteChannel channel = channels.get(index);
|
||||
sb.append("<td style='padding-right:8px'>").append(frequencyFormat.format(channel.frequencyHz() / 1E6));
|
||||
|
||||
if("Control".equals(channel.use()))
|
||||
{
|
||||
sb.append(" <b>C</b>");
|
||||
control = true;
|
||||
}
|
||||
else if("Alternate control".equals(channel.use()))
|
||||
{
|
||||
sb.append(" <b>A</b>");
|
||||
control = true;
|
||||
}
|
||||
|
||||
sb.append("</td>");
|
||||
|
||||
if(index % CHANNEL_COLUMNS == CHANNEL_COLUMNS - 1)
|
||||
{
|
||||
sb.append("</tr>");
|
||||
}
|
||||
}
|
||||
|
||||
if(shown % CHANNEL_COLUMNS != 0)
|
||||
{
|
||||
sb.append("</tr>");
|
||||
}
|
||||
|
||||
sb.append("</table>");
|
||||
List<String> notes = new ArrayList<>();
|
||||
|
||||
if(channels.size() > shown)
|
||||
{
|
||||
notes.add("+ " + (channels.size() - shown) + " more channels");
|
||||
}
|
||||
|
||||
if(control)
|
||||
{
|
||||
notes.add("C control, A alternate control");
|
||||
}
|
||||
|
||||
if(!notes.isEmpty())
|
||||
{
|
||||
appendLine(sb, notes.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a line with the non-empty values separated by dots, or nothing when all are empty
|
||||
*/
|
||||
private static void appendLine(StringBuilder sb, String... values)
|
||||
{
|
||||
StringBuilder line = new StringBuilder();
|
||||
|
||||
for(String value: values)
|
||||
{
|
||||
if(value != null && !value.isBlank())
|
||||
{
|
||||
if(!line.isEmpty())
|
||||
{
|
||||
line.append(SEPARATOR);
|
||||
}
|
||||
|
||||
line.append(escape(value));
|
||||
}
|
||||
}
|
||||
|
||||
if(!line.isEmpty())
|
||||
{
|
||||
sb.append("<div>").append(line).append("</div>");
|
||||
}
|
||||
}
|
||||
|
||||
private static String escape(String text)
|
||||
{
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.map;
|
||||
|
||||
import io.github.dsheirer.service.radioreference.SiteRecord;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.Stroke;
|
||||
import java.awt.geom.Arc2D;
|
||||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.geom.Point2D;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import org.jdesktop.swingx.JXMapViewer;
|
||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||
import org.jdesktop.swingx.painter.AbstractPainter;
|
||||
|
||||
/**
|
||||
* Paints a radio tower at each radio reference trunked system site, with the base of the tower at the site location.
|
||||
*/
|
||||
public class RadioReferenceSitePainter extends AbstractPainter<JXMapViewer>
|
||||
{
|
||||
private static final Color TOWER = new Color(240, 128, 32);
|
||||
private static final Color OUTLINE = new Color(40, 40, 40, 200);
|
||||
private static final Stroke TOWER_STROKE = new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
|
||||
private static final Stroke OUTLINE_STROKE = new BasicStroke(3.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND);
|
||||
private static final double HEIGHT = 16;
|
||||
private static final double HALF_BASE = 5;
|
||||
//Hit area around the tower, in pixels from the site location
|
||||
private static final Rectangle HIT_AREA = new Rectangle(-10, -24, 20, 28);
|
||||
|
||||
private final Supplier<List<SiteRecord>> mSites;
|
||||
private volatile boolean mShowSites = true;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param sites to paint, read each time the map is painted
|
||||
*/
|
||||
public RadioReferenceSitePainter(Supplier<List<SiteRecord>> sites)
|
||||
{
|
||||
mSites = sites;
|
||||
setAntialiasing(true);
|
||||
setCacheable(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or hides the sites
|
||||
*/
|
||||
public void setShowSites(boolean show)
|
||||
{
|
||||
mShowSites = show;
|
||||
}
|
||||
|
||||
public boolean isShowSites()
|
||||
{
|
||||
return mShowSites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites whose tower is under the point, nearest first
|
||||
* @param map viewer
|
||||
* @param point in the viewer
|
||||
* @return sites, or an empty list when the sites are hidden or none is under the point
|
||||
*/
|
||||
public List<SiteRecord> getSitesAt(JXMapViewer map, Point point)
|
||||
{
|
||||
if(!mShowSites)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Rectangle viewport = map.getViewportBounds();
|
||||
List<SiteRecord> sites = new ArrayList<>();
|
||||
List<Double> distances = new ArrayList<>();
|
||||
|
||||
for(SiteRecord site: mSites.get())
|
||||
{
|
||||
Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()),
|
||||
map.getZoom());
|
||||
double dx = point.x - (pixel.getX() - viewport.getX());
|
||||
double dy = point.y - (pixel.getY() - viewport.getY());
|
||||
|
||||
if(HIT_AREA.contains(dx, dy))
|
||||
{
|
||||
//Nearest to the middle of the tower first
|
||||
double distance = Math.hypot(dx, dy + (HEIGHT / 2.0));
|
||||
int index = 0;
|
||||
|
||||
while(index < distances.size() && distances.get(index) <= distance)
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
sites.add(index, site);
|
||||
distances.add(index, distance);
|
||||
}
|
||||
}
|
||||
|
||||
return sites;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height)
|
||||
{
|
||||
if(!mShowSites)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Rectangle viewport = map.getViewportBounds();
|
||||
|
||||
for(SiteRecord site: mSites.get())
|
||||
{
|
||||
Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()),
|
||||
map.getZoom());
|
||||
double x = pixel.getX() - viewport.getX();
|
||||
double y = pixel.getY() - viewport.getY();
|
||||
|
||||
if(x >= -20 && x <= width + 20 && y >= -4 && y <= height + 30)
|
||||
{
|
||||
paintTower(g, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints a tower with its base at the point: an A-frame mast with a cross brace and radio waves at the top
|
||||
*/
|
||||
private static void paintTower(Graphics2D g, double x, double y)
|
||||
{
|
||||
double top = y - HEIGHT;
|
||||
Path2D mast = new Path2D.Double();
|
||||
mast.moveTo(x - HALF_BASE, y);
|
||||
mast.lineTo(x, top);
|
||||
mast.lineTo(x + HALF_BASE, y);
|
||||
mast.moveTo(x - (HALF_BASE * 0.55), y - (HEIGHT * 0.45));
|
||||
mast.lineTo(x + (HALF_BASE * 0.55), y - (HEIGHT * 0.45));
|
||||
|
||||
Arc2D leftInner = new Arc2D.Double(x - 5, top - 4, 10, 8, 135, 90, Arc2D.OPEN);
|
||||
Arc2D rightInner = new Arc2D.Double(x - 5, top - 4, 10, 8, -45, 90, Arc2D.OPEN);
|
||||
Arc2D leftOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, 135, 90, Arc2D.OPEN);
|
||||
Arc2D rightOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, -45, 90, Arc2D.OPEN);
|
||||
|
||||
g.setStroke(OUTLINE_STROKE);
|
||||
g.setColor(OUTLINE);
|
||||
g.draw(mast);
|
||||
g.draw(leftInner);
|
||||
g.draw(rightInner);
|
||||
g.draw(leftOuter);
|
||||
g.draw(rightOuter);
|
||||
|
||||
g.setStroke(TOWER_STROKE);
|
||||
g.setColor(TOWER);
|
||||
g.draw(mast);
|
||||
g.draw(leftInner);
|
||||
g.draw(rightInner);
|
||||
g.draw(leftOuter);
|
||||
g.draw(rightOuter);
|
||||
g.fill(new Ellipse2D.Double(x - 2, top - 2, 4, 4));
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.map;
|
||||
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.Line2D;
|
||||
import java.awt.geom.Point2D;
|
||||
import org.jdesktop.swingx.JXMapViewer;
|
||||
import org.jdesktop.swingx.mapviewer.GeoPosition;
|
||||
import org.jdesktop.swingx.painter.AbstractPainter;
|
||||
|
||||
/**
|
||||
* Paints a push-pin at the user's location, with the tip of the pin at the location.
|
||||
*/
|
||||
public class UserLocationPainter extends AbstractPainter<JXMapViewer>
|
||||
{
|
||||
private static final Color NEEDLE = new Color(90, 90, 90);
|
||||
private static final Color HEAD = new Color(220, 40, 40);
|
||||
private static final Color HEAD_OUTLINE = new Color(110, 0, 0);
|
||||
private static final Color HIGHLIGHT = new Color(255, 255, 255, 170);
|
||||
private static final Color SHADOW = new Color(0, 0, 0, 70);
|
||||
private static final double NEEDLE_LENGTH = 14.0;
|
||||
private static final double HEAD_RADIUS = 7.0;
|
||||
|
||||
private volatile GeoPosition mPosition;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
*/
|
||||
public UserLocationPainter()
|
||||
{
|
||||
setAntialiasing(true);
|
||||
setCacheable(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the location to mark
|
||||
* @param position of the user, or null to remove the pin
|
||||
*/
|
||||
public void setPosition(GeoPosition position)
|
||||
{
|
||||
mPosition = position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location that is marked
|
||||
* @return position, or null
|
||||
*/
|
||||
public GeoPosition getPosition()
|
||||
{
|
||||
return mPosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height)
|
||||
{
|
||||
GeoPosition position = mPosition;
|
||||
|
||||
if(position == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Rectangle viewportBounds = map.getViewportBounds();
|
||||
Point2D point = map.getTileFactory().geoToPixel(position, map.getZoom());
|
||||
double x = point.getX() - viewportBounds.getX();
|
||||
double y = point.getY() - viewportBounds.getY();
|
||||
double headY = y - NEEDLE_LENGTH - HEAD_RADIUS;
|
||||
|
||||
//Shadow where the needle enters the map, so the tip reads as the location
|
||||
g.setColor(SHADOW);
|
||||
g.fill(new Ellipse2D.Double(x - 4, y - 2, 8, 4));
|
||||
|
||||
g.setColor(NEEDLE);
|
||||
g.setStroke(new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
|
||||
g.draw(new Line2D.Double(x, y, x, headY));
|
||||
|
||||
Ellipse2D head = new Ellipse2D.Double(x - HEAD_RADIUS, headY - HEAD_RADIUS, HEAD_RADIUS * 2, HEAD_RADIUS * 2);
|
||||
g.setColor(HEAD);
|
||||
g.fill(head);
|
||||
g.setColor(HEAD_OUTLINE);
|
||||
g.setStroke(new BasicStroke(1.0f));
|
||||
g.draw(head);
|
||||
|
||||
g.setColor(HIGHLIGHT);
|
||||
g.fill(new Ellipse2D.Double(x - HEAD_RADIUS / 2 - 1, headY - HEAD_RADIUS / 2 - 1, 4, 4));
|
||||
}
|
||||
}
|
||||
@@ -922,7 +922,6 @@ public class DecoderFactory
|
||||
DecodeConfigP25Phase1 originalP25P1 = (DecodeConfigP25Phase1)config;
|
||||
DecodeConfigP25Phase1 copyP25P1 = new DecodeConfigP25Phase1();
|
||||
copyP25P1.setIgnoreDataCalls(originalP25P1.getIgnoreDataCalls());
|
||||
copyP25P1.setIgnoreEncryptedCalls(originalP25P1.getIgnoreEncryptedCalls());
|
||||
copyP25P1.setModulation(originalP25P1.getModulation());
|
||||
copyP25P1.setTrafficChannelPoolSize(originalP25P1.getTrafficChannelPoolSize());
|
||||
transferJMBESettings(originalP25P1, copyP25P1);
|
||||
@@ -930,9 +929,6 @@ public class DecoderFactory
|
||||
case P25_PHASE2:
|
||||
DecodeConfigP25Phase2 originalP25P2 = (DecodeConfigP25Phase2)config;
|
||||
DecodeConfigP25Phase2 copyP25P2 = new DecodeConfigP25Phase2();
|
||||
copyP25P2.setIgnoreDataCalls(originalP25P2.getIgnoreDataCalls());
|
||||
copyP25P2.setIgnoreEncryptedCalls(originalP25P2.getIgnoreEncryptedCalls());
|
||||
copyP25P2.setTrafficChannelPoolSize(originalP25P2.getTrafficChannelPoolSize());
|
||||
transferJMBESettings(originalP25P2, copyP25P2);
|
||||
|
||||
if(originalP25P2.getScrambleParameters() != null)
|
||||
|
||||
@@ -123,23 +123,25 @@ public class LRRPPacket extends Packet
|
||||
}
|
||||
else
|
||||
{
|
||||
String message = getMessage().getSubMessage(getOffset(), getOffset() + ((characterCount + 2) * 8)).toHexString();
|
||||
int x = 0;
|
||||
|
||||
RequestedTokens requestedTokens = new RequestedTokens();
|
||||
|
||||
while(characterCount > 0 && offset < getMessage().size())
|
||||
{
|
||||
String tokenId = getTokenIdentifier(offset);
|
||||
TokenType tokenType = TokenType.fromRequestValue(tokenId);
|
||||
TokenType tokenType = TokenType.fromValue(tokenId);
|
||||
|
||||
if(tokenType.isRequestParameterToken())
|
||||
{
|
||||
Token token = TokenFactory.createToken(tokenType, getMessage(), offset, characterCount);
|
||||
Token token = TokenFactory.createToken(tokenId, getMessage(), offset, characterCount);
|
||||
mTokens.add(token);
|
||||
characterCount -= token.getByteLength();
|
||||
offset += (8 * token.getByteLength());
|
||||
}
|
||||
else
|
||||
{
|
||||
//Single-byte flag naming a field that the radio should include in its location report
|
||||
if(tokenType != TokenType.UNKNOWN)
|
||||
{
|
||||
requestedTokens.add(tokenType);
|
||||
|
||||
@@ -26,7 +26,7 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
* LRRP Identity Token
|
||||
*
|
||||
* Start Token: 0x22
|
||||
* Total Length: 2 bytes plus a 1-4 byte request identifier
|
||||
* Total Length: 6 bytes
|
||||
*/
|
||||
public class Identity extends Token
|
||||
{
|
||||
@@ -34,9 +34,9 @@ public class Identity extends Token
|
||||
private static final int[] BYTE_VALUE = new int[]{0, 1, 2, 3, 4, 5, 6, 7};
|
||||
|
||||
/**
|
||||
* Constructs an instance of an identity token.
|
||||
* Constructs an instance of a heading token.
|
||||
*
|
||||
* @param message containing the identity
|
||||
* @param message containing the heading
|
||||
* @param offset to the start of the token
|
||||
*/
|
||||
public Identity(CorrectedBinaryMessage message, int offset)
|
||||
@@ -50,47 +50,47 @@ public class Identity extends Token
|
||||
return TokenType.IDENTITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request identifier bytes, as transmitted. The read is clamped to the end of the message so that a corrupted
|
||||
* length field cannot produce bytes that were never received.
|
||||
*/
|
||||
private BinaryMessage getIdentifierBytes()
|
||||
private BinaryMessage getTokenContents()
|
||||
{
|
||||
int start = getOffset() + IDENTIFIER_START;
|
||||
int available = Math.max(0, getMessage().size() - start);
|
||||
int length = Math.min(getVariableLengthPayloadSize() * 8, available);
|
||||
return getMessage().getSubMessage(start, start + length);
|
||||
return getMessage().getSubMessage(getOffset(), getOffset() + 48);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request identifier assembled big-endian from the 1-4 payload bytes. Moto.Net (LRRPPacket.cs) and node-dmr-lib
|
||||
* (LRRP.js) both read the identifier this way.
|
||||
* Extracts the payload contents.
|
||||
*
|
||||
* @return identifier, or zero when the payload length is not 1-4 bytes.
|
||||
* Note: assumes that the values are 1-4 bytes in big-endian format? Not sure if this is correct because the
|
||||
* values that I observed only had value in the LSB and the 3 MSBs were all zeros.
|
||||
*/
|
||||
public int getID()
|
||||
{
|
||||
int length = getVariableLengthPayloadSize();
|
||||
|
||||
if(length < 1 || length > 4)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int payloadOffset = 16;
|
||||
int value = 0;
|
||||
|
||||
for(int x = 0; x < length; x++)
|
||||
while(0 < length && length <= 4)
|
||||
{
|
||||
value = (value << 8) | getMessage().getInt(BYTE_VALUE, getOffset() + IDENTIFIER_START + (x * 8));
|
||||
int byteValue = getMessage().getInt(BYTE_VALUE, getOffset() + payloadOffset);
|
||||
|
||||
byteValue = Integer.rotateLeft(byteValue, payloadOffset - 16);
|
||||
value += byteValue;
|
||||
payloadOffset += 8;
|
||||
length--;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
//Hexadecimal, as transmitted (e.g. ID:FFFFEE)
|
||||
return "ID:" + getIdentifierBytes().toHexString();
|
||||
int id = getID();
|
||||
|
||||
if(id != 0)
|
||||
{
|
||||
return "ID:" + id;
|
||||
}
|
||||
|
||||
return "ID:" + getTokenContents().toHexString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.module.decode.ip.mototrbo.lrrp.token;
|
||||
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
|
||||
/**
|
||||
* LRRP request parameter of unknown meaning that carries a 7-bit variable length integer value.
|
||||
*
|
||||
* Start Tokens: 0x42 and 0x44
|
||||
* Total Length: 1 byte identifier plus a variable length integer value (0-5 bytes)
|
||||
*
|
||||
* Observed in Motorola P25 triggered location start requests as 0x44 0x64 (100) and 0x42 0x9C 0x10 (3600), placed
|
||||
* among the requested-field flags. Moto.Net emits a bare 0x42 as a "trigger on GPIO" flag, so the value is treated
|
||||
* as optional and the token is rendered without a value when none follows.
|
||||
*/
|
||||
public class RequestParameter extends VarIntToken
|
||||
{
|
||||
private TokenType mTokenType;
|
||||
|
||||
/**
|
||||
* Constructs an instance of a request parameter token.
|
||||
*
|
||||
* @param tokenType of the parameter (REQUEST_PARAMETER_42 or REQUEST_PARAMETER_44)
|
||||
* @param message containing the token
|
||||
* @param offset to the start of the token
|
||||
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
|
||||
*/
|
||||
public RequestParameter(TokenType tokenType, CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
|
||||
{
|
||||
super(message, offset, remainingCharacterCount);
|
||||
mTokenType = tokenType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenType getTokenType()
|
||||
{
|
||||
return mTokenType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
if(hasValue())
|
||||
{
|
||||
return getTokenType() + ":" + getValue();
|
||||
}
|
||||
|
||||
return getTokenType().toString();
|
||||
}
|
||||
}
|
||||
@@ -63,28 +63,12 @@ public class RequestedTokens extends Token
|
||||
Collections.sort(mUnrecognizedRequestedTokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognized requested token types, in the order that they appeared in the packet.
|
||||
*/
|
||||
public List<TokenType> getRequestedTokens()
|
||||
{
|
||||
return mRequestedTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrecognized requested token identifiers (hexadecimal), sorted.
|
||||
*/
|
||||
public List<String> getUnrecognizedRequestedTokens()
|
||||
{
|
||||
return mUnrecognizedRequestedTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this object has requested tokens.
|
||||
*/
|
||||
public boolean hasRequestedTokens()
|
||||
{
|
||||
return !mRequestedTokens.isEmpty() || !mUnrecognizedRequestedTokens.isEmpty();
|
||||
return !mRequestedTokens.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -28,7 +28,7 @@ import java.util.TimeZone;
|
||||
/**
|
||||
* LRRP Timestamp Token
|
||||
*
|
||||
* Start Token: 0x34 (response packets; in a request packet 0x34 is the Periodic Trigger token)
|
||||
* Start Token: 0x34
|
||||
* Total Length: 6 bytes
|
||||
*/
|
||||
public class Timestamp extends Token
|
||||
|
||||
@@ -88,11 +88,6 @@ public abstract class Token
|
||||
{
|
||||
return getVariableLengthPayloadSize() + 2; //Variable Length Payload + Id + Length Field
|
||||
}
|
||||
else if(getTokenType().isVarInt())
|
||||
{
|
||||
//Id + 7-bit variable length integer. VarIntToken overrides this to bound the read by the payload.
|
||||
return VarInt.length(getMessage(), getOffset() + 8, VarInt.MAX_BYTES) + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
return getTokenType().getLength() + 1; //Fixed Length Payload + Id
|
||||
|
||||
@@ -26,32 +26,10 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
*/
|
||||
public class TokenFactory
|
||||
{
|
||||
/**
|
||||
* Creates a token using the response packet meaning of the token identifier.
|
||||
*
|
||||
* @param id hexadecimal token identifier
|
||||
* @param message containing the token
|
||||
* @param offset to the start of the token identifier
|
||||
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
|
||||
* @return token
|
||||
*/
|
||||
public static Token createToken(String id, CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
|
||||
{
|
||||
return createToken(TokenType.fromValue(id), message, offset, remainingCharacterCount);
|
||||
}
|
||||
TokenType tokenType = TokenType.fromValue(id);
|
||||
|
||||
/**
|
||||
* Creates a token of the specified type.
|
||||
*
|
||||
* @param tokenType resolved by the caller, using TokenType.fromRequestValue() for request packets
|
||||
* @param message containing the token
|
||||
* @param offset to the start of the token identifier
|
||||
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
|
||||
* @return token
|
||||
*/
|
||||
public static Token createToken(TokenType tokenType, CorrectedBinaryMessage message, int offset,
|
||||
int remainingCharacterCount)
|
||||
{
|
||||
switch(tokenType)
|
||||
{
|
||||
case HEADING:
|
||||
@@ -74,17 +52,14 @@ public class TokenFactory
|
||||
return new Success(message, offset);
|
||||
case TIMESTAMP:
|
||||
return new Timestamp(message, offset);
|
||||
case TRIGGER_CONDITION:
|
||||
return new TriggerCondition(message, offset);
|
||||
case TRIGGER_DISTANCE:
|
||||
return new TriggerDistance(message, offset, remainingCharacterCount);
|
||||
case TRIGGER_INTERVAL:
|
||||
return new TriggerInterval(message, offset, remainingCharacterCount);
|
||||
return new TriggerDistance(message, offset);
|
||||
case TRIGGER_GPIO:
|
||||
return new TriggerGpio(message, offset);
|
||||
case TRIGGER_ON_MOVE:
|
||||
return new TriggerOnMove(message, offset);
|
||||
case TRIGGER_PERIODIC:
|
||||
return new TriggerPeriodic(message, offset);
|
||||
case REQUEST_PARAMETER_42:
|
||||
case REQUEST_PARAMETER_44:
|
||||
return new RequestParameter(tokenType, message, offset, remainingCharacterCount);
|
||||
case VERSION:
|
||||
return new Version(message, offset);
|
||||
case UNKNOWN_23:
|
||||
|
||||
@@ -26,37 +26,21 @@ import java.util.Map;
|
||||
/**
|
||||
* LRRP Token Types
|
||||
*
|
||||
* Fixed length tokens consist of an identifier followed by a fixed number of value bytes. Variable length tokens
|
||||
* consist of an identifier, a length indicator, and then a sequence of bytes equal in length to the length indicator.
|
||||
* Varint tokens consist of an identifier followed by a 7-bit variable length integer (see VarInt).
|
||||
*
|
||||
* A few identifiers have a different meaning in a request than in a response (0x34 is the periodic trigger flag in a
|
||||
* request and the timestamp in a response). Use fromRequestValue() for request packets and fromValue() otherwise.
|
||||
*
|
||||
* Note: the declaration order (ordinal) controls the order that tokens are listed when a packet is rendered as text.
|
||||
* Variable length tokens consist of an identifier, a length indicator, and then a sequence of bytes equal in length
|
||||
* to the length indicator.
|
||||
*/
|
||||
public enum TokenType
|
||||
{
|
||||
//Tokens common to request and response packets
|
||||
IDENTITY("22", -1, "IDENTITY"),
|
||||
UNKNOWN_23("23", 1, "UNKNOWN 23"),
|
||||
|
||||
//Request parameter tokens. Integer values are 7-bit variable length integers (varints).
|
||||
TRIGGER_PERIODIC("34", 0, "PERIODIC", true), //Request meaning of 0x34. See TIMESTAMP for the response meaning.
|
||||
TRIGGER_INTERVAL("31", -2, "TRIGGER INTERVAL"),
|
||||
TRIGGER_CONDITION("4A", 1, "TRIGGER CONDITION"),
|
||||
TRIGGER_DISTANCE("78", -2, "TRIGGER DISTANCE"),
|
||||
REQUEST_PARAMETER_42("42", -2, "PARAM-42"), //Meaning unknown. Observed value: 3600
|
||||
REQUEST_PARAMETER_44("44", -2, "PARAM-44"), //Meaning unknown. Observed value: 100
|
||||
REQUEST_61("61", 1, "REQUEST-61"),
|
||||
REQUEST_73("73", 1, "REQUEST-73"),
|
||||
|
||||
//Response tokens and single-byte requested field flags
|
||||
TRIGGER_PERIODIC("31", 1, "TRIGGER PERIODIC"),
|
||||
TIMESTAMP("34", 5, "TIMESTAMP"),
|
||||
VERSION("36", 1, "VERSION"),
|
||||
RESPONSE("37", -1, "RESPONSE"),
|
||||
SUCCESS( "38", 0, "SUCCESS"),
|
||||
REQUEST_3A("3A", 0,"REQUEST-3A"),
|
||||
TRIGGER_GPIO("42", 0, "TRIGGER GPIO"),
|
||||
TRIGGER_DISTANCE("4A", 1, "TRIGGER DISTANCE"), //Guess - not sure
|
||||
ALTITUDE_ACCURACY("50", 0,"ALTITUDE ACCURACY"),
|
||||
CIRCLE_2D("51", 10, "CIRCLE 2D"),
|
||||
TIME("52", 0,"TIME"),
|
||||
@@ -64,55 +48,37 @@ public enum TokenType
|
||||
CIRCLE_3D("55", 15, "CIRCLE 3D"),
|
||||
HEADING("56", 1, "HEADING"),
|
||||
HORIZONTAL_DIRECTION("57", 0,"HORIZONTAL DIRECTION"),
|
||||
HORIZONTAL_SPEED("62", 0,"HORIZONTAL SPEED"),
|
||||
REQUEST_61("61", 1, "REQUEST-61"),
|
||||
REQUEST_62("62", 0,"REQUEST-62"),
|
||||
REQUEST_64("64", 0,"REQUEST-64"),
|
||||
POINT_2D("66", 8, "POINT 2D"),
|
||||
POINT_3D("69", 11, "POINT 3D"),
|
||||
SPEED("6C", 2, "SPEED"),
|
||||
REQUEST_73("73", 1, "REQUEST-73"),
|
||||
TRIGGER_ON_MOVE("78", 1, "TRIGGER ON MOVE"),
|
||||
REQUESTED_TOKENS("000", 0, "REQUESTED TOKENS"),
|
||||
UNKNOWN("0",0, "UNKNOWN");
|
||||
|
||||
private static final int VARIABLE_LENGTH = -1;
|
||||
private static final int VARINT_LENGTH = -2;
|
||||
|
||||
private String mValue;
|
||||
private int mLength;
|
||||
private String mLabel;
|
||||
private boolean mRequestOnly;
|
||||
|
||||
/**
|
||||
* Tokens that have parameters in request packet messages
|
||||
*/
|
||||
private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_PERIODIC,
|
||||
TRIGGER_INTERVAL, TRIGGER_CONDITION, TRIGGER_DISTANCE, REQUEST_PARAMETER_42, REQUEST_PARAMETER_44,
|
||||
REQUEST_61, REQUEST_73);
|
||||
private static EnumSet<TokenType> REQUEST_PARAMETER_TOKENS = EnumSet.of(IDENTITY, TRIGGER_DISTANCE, TRIGGER_GPIO,
|
||||
TRIGGER_ON_MOVE, TRIGGER_PERIODIC, REQUEST_61, REQUEST_73);
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param value of the token
|
||||
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token and -2
|
||||
* indicates a 7-bit variable length integer (varint) value.
|
||||
* @param label for pretty printing
|
||||
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token.
|
||||
*/
|
||||
TokenType(String value, int length, String label)
|
||||
{
|
||||
this(value, length, label, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param value of the token
|
||||
* @param length of the token, exclusive of the identifier, where -1 indicates a variable length token and -2
|
||||
* indicates a 7-bit variable length integer (varint) value.
|
||||
* @param label for pretty printing
|
||||
* @param requestOnly true when this meaning of the value applies to request packets only
|
||||
*/
|
||||
TokenType(String value, int length, String label, boolean requestOnly)
|
||||
{
|
||||
mValue = value;
|
||||
mLength = length;
|
||||
mLabel = label;
|
||||
mRequestOnly = requestOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,23 +94,7 @@ public enum TokenType
|
||||
*/
|
||||
public boolean isVariableLength()
|
||||
{
|
||||
return mLength == VARIABLE_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this token's value is a 7-bit variable length integer (varint).
|
||||
*/
|
||||
public boolean isVarInt()
|
||||
{
|
||||
return mLength == VARINT_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this meaning of the token value applies to request packets only.
|
||||
*/
|
||||
public boolean isRequestOnly()
|
||||
{
|
||||
return mRequestOnly;
|
||||
return mLength == -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,26 +116,17 @@ public enum TokenType
|
||||
|
||||
|
||||
private static final Map<String,TokenType> LOOKUP_MAP = new HashMap<>();
|
||||
private static final Map<String,TokenType> REQUEST_LOOKUP_MAP = new HashMap<>();
|
||||
|
||||
static
|
||||
{
|
||||
for(TokenType tokenType: TokenType.values())
|
||||
{
|
||||
if(tokenType.isRequestOnly())
|
||||
{
|
||||
REQUEST_LOOKUP_MAP.put(tokenType.getValue(), tokenType);
|
||||
}
|
||||
else
|
||||
{
|
||||
LOOKUP_MAP.put(tokenType.getValue(), tokenType);
|
||||
REQUEST_LOOKUP_MAP.putIfAbsent(tokenType.getValue(), tokenType);
|
||||
}
|
||||
LOOKUP_MAP.put(tokenType.getValue(), tokenType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a token from a 2-character string value, using the response packet meaning of the value.
|
||||
* Lookup a token from a 2-character string value
|
||||
* @param value that represents the token
|
||||
* @return matching token or UNKNOWN.
|
||||
*/
|
||||
@@ -199,21 +140,6 @@ public enum TokenType
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a token from a 2-character string value, using the request packet meaning of the value.
|
||||
* @param value that represents the token
|
||||
* @return matching token or UNKNOWN.
|
||||
*/
|
||||
public static TokenType fromRequestValue(String value)
|
||||
{
|
||||
if(value != null && REQUEST_LOOKUP_MAP.containsKey(value))
|
||||
{
|
||||
return REQUEST_LOOKUP_MAP.get(value);
|
||||
}
|
||||
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.module.decode.ip.mototrbo.lrrp.token;
|
||||
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
|
||||
/**
|
||||
* LRRP Trigger Condition Token
|
||||
*
|
||||
* Start Token: 0x4A
|
||||
* Total Length: 2 bytes
|
||||
*
|
||||
* Event that causes the radio to send a triggered location report. Condition values are from node-dmr-lib
|
||||
* (src/DMRConst.js) and match captured requests: 0x4A 0x02 emergency, 0x4A 0x42 pin status change (telemetry),
|
||||
* 0x4A 0x50 PTT and 0x4A 0x51 PTT emergency.
|
||||
*/
|
||||
public class TriggerCondition extends Token
|
||||
{
|
||||
private static final int[] CONDITION = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
|
||||
|
||||
/**
|
||||
* Trigger condition values
|
||||
*/
|
||||
public enum Condition
|
||||
{
|
||||
EMERGENCY(0x02, "EMERGENCY"),
|
||||
PIN_STATUS_CHANGE(0x42, "PIN STATUS CHANGE"),
|
||||
PTT(0x50, "PTT"),
|
||||
PTT_EMERGENCY(0x51, "PTT EMERGENCY"),
|
||||
UNKNOWN(-1, "UNKNOWN");
|
||||
|
||||
private int mValue;
|
||||
private String mLabel;
|
||||
|
||||
Condition(int value, String label)
|
||||
{
|
||||
mValue = value;
|
||||
mLabel = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric value of the condition
|
||||
*/
|
||||
public int getValue()
|
||||
{
|
||||
return mValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a condition from a value
|
||||
* @param value to lookup
|
||||
* @return matching condition or UNKNOWN
|
||||
*/
|
||||
public static Condition fromValue(int value)
|
||||
{
|
||||
for(Condition condition: Condition.values())
|
||||
{
|
||||
if(condition.getValue() == value)
|
||||
{
|
||||
return condition;
|
||||
}
|
||||
}
|
||||
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return mLabel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an instance of a trigger condition token.
|
||||
*
|
||||
* @param message containing the token
|
||||
* @param offset to the start of the token
|
||||
*/
|
||||
public TriggerCondition(CorrectedBinaryMessage message, int offset)
|
||||
{
|
||||
super(message, offset);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenType getTokenType()
|
||||
{
|
||||
return TokenType.TRIGGER_CONDITION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw condition value
|
||||
*/
|
||||
public int getConditionValue()
|
||||
{
|
||||
return getMessage().getInt(CONDITION, getOffset());
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger condition
|
||||
*/
|
||||
public Condition getCondition()
|
||||
{
|
||||
return Condition.fromValue(getConditionValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
Condition condition = getCondition();
|
||||
|
||||
if(condition == Condition.UNKNOWN)
|
||||
{
|
||||
return "TRIGGER CONDITION:UNKNOWN[0x" + String.format("%02X", getConditionValue()) + "]";
|
||||
}
|
||||
|
||||
return "TRIGGER CONDITION:" + condition;
|
||||
}
|
||||
}
|
||||
@@ -24,23 +24,21 @@ import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
/**
|
||||
* LRRP Trigger Distance Token
|
||||
*
|
||||
* Start Token: 0x78
|
||||
* Total Length: 1 byte identifier plus a 7-bit variable length integer value (1-5 bytes)
|
||||
*
|
||||
* Distance in meters that the radio must move before it sends a triggered location report, e.g. 0x78 0x83 0x74 = 500.
|
||||
* Total Length: 2 bytes
|
||||
*/
|
||||
public class TriggerDistance extends VarIntToken
|
||||
public class TriggerDistance extends Token
|
||||
{
|
||||
private static final int[] DISTANCE = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
|
||||
|
||||
/**
|
||||
* Constructs an instance of a trigger distance token.
|
||||
* Constructs an instance of a heading token.
|
||||
*
|
||||
* @param message containing the token
|
||||
* @param message containing the heading
|
||||
* @param offset to the start of the token
|
||||
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
|
||||
*/
|
||||
public TriggerDistance(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
|
||||
public TriggerDistance(CorrectedBinaryMessage message, int offset)
|
||||
{
|
||||
super(message, offset, remainingCharacterCount);
|
||||
super(message, offset);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -54,12 +52,12 @@ public class TriggerDistance extends VarIntToken
|
||||
*/
|
||||
public int getDistance()
|
||||
{
|
||||
return getValue();
|
||||
return getMessage().getInt(DISTANCE, getOffset());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "TRIGGER DISTANCE:" + getDistance() + " METERS";
|
||||
return "TRIGGER DISTANCE:" + getDistance();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
* Copyright (C) 2014-2023 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
|
||||
@@ -16,49 +16,38 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
|
||||
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
|
||||
/**
|
||||
* LRRP Trigger Interval Token
|
||||
* LRRP Trigger on GPIO Token
|
||||
*
|
||||
* Start Token: 0x31
|
||||
* Total Length: 1 byte identifier plus a 7-bit variable length integer value (1-5 bytes)
|
||||
*
|
||||
* Reporting interval in seconds for a periodic triggered location request, e.g. 0x31 0x84 0x58 = 600 seconds.
|
||||
* Total Length: 1 byte
|
||||
*/
|
||||
public class TriggerInterval extends VarIntToken
|
||||
public class TriggerGpio extends Token
|
||||
{
|
||||
/**
|
||||
* Constructs an instance of a trigger interval token.
|
||||
* Constructs an instance of a heading token.
|
||||
*
|
||||
* @param message containing the token
|
||||
* @param message containing the heading
|
||||
* @param offset to the start of the token
|
||||
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
|
||||
*/
|
||||
public TriggerInterval(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
|
||||
public TriggerGpio(CorrectedBinaryMessage message, int offset)
|
||||
{
|
||||
super(message, offset, remainingCharacterCount);
|
||||
super(message, offset);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenType getTokenType()
|
||||
{
|
||||
return TokenType.TRIGGER_INTERVAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval in seconds
|
||||
*/
|
||||
public int getSeconds()
|
||||
{
|
||||
return getValue();
|
||||
return TokenType.TRIGGER_GPIO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "INTERVAL:" + getSeconds() + " SECS";
|
||||
return "TRIGGER ON GPIO";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
* Copyright (C) 2014-2023 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
|
||||
@@ -17,36 +17,37 @@
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.gui.playlist.streaming;
|
||||
package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
|
||||
|
||||
import io.github.dsheirer.audio.broadcast.BroadcastServerType;
|
||||
import io.github.dsheirer.playlist.PlaylistManager;
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
|
||||
/**
|
||||
* Thinline Radio call upload configuration editor.
|
||||
* LRRP Trigger on Move Token
|
||||
*
|
||||
* The form and upload path match the Rdio Scanner editor.
|
||||
* Total Length: 1 byte
|
||||
*/
|
||||
public class ThinlineRadioEditor extends RdioScannerEditor
|
||||
public class TriggerOnMove extends Token
|
||||
{
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param playlistManager for accessing the broadcast model
|
||||
* Constructs an instance of a heading token.
|
||||
*
|
||||
* @param message containing the heading
|
||||
* @param offset to the start of the token
|
||||
*/
|
||||
public ThinlineRadioEditor(PlaylistManager playlistManager)
|
||||
public TriggerOnMove(CorrectedBinaryMessage message, int offset)
|
||||
{
|
||||
super(playlistManager);
|
||||
super(message, offset);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BroadcastServerType getBroadcastServerType()
|
||||
public TokenType getTokenType()
|
||||
{
|
||||
return BroadcastServerType.THINLINE_RADIO;
|
||||
return TokenType.TRIGGER_ON_MOVE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getHostLabel()
|
||||
public String toString()
|
||||
{
|
||||
return "Thinline Radio URL";
|
||||
return "TRIGGER ON MOVE";
|
||||
}
|
||||
}
|
||||
@@ -22,20 +22,18 @@ package io.github.dsheirer.module.decode.ip.mototrbo.lrrp.token;
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
|
||||
/**
|
||||
* LRRP Periodic Trigger Token (request packets)
|
||||
* LRRP Trigger Periodic Token
|
||||
*
|
||||
* Start Token: 0x34 (in a response packet 0x34 is the Timestamp token)
|
||||
* Total Length: 1 byte
|
||||
*
|
||||
* Flags a triggered location request as periodic. The reporting interval, when present, follows in a separate
|
||||
* Trigger Interval (0x31) token, and a Trigger Condition (0x4A) or Trigger Distance (0x78) token can follow instead.
|
||||
* Total Length: 2 bytes
|
||||
*/
|
||||
public class TriggerPeriodic extends Token
|
||||
{
|
||||
private static final int[] SECONDS = new int[]{8, 9, 10, 11, 12, 13, 14, 15};
|
||||
|
||||
/**
|
||||
* Constructs an instance of a periodic trigger token.
|
||||
* Constructs an instance of a heading token.
|
||||
*
|
||||
* @param message containing the token
|
||||
* @param message containing the heading
|
||||
* @param offset to the start of the token
|
||||
*/
|
||||
public TriggerPeriodic(CorrectedBinaryMessage message, int offset)
|
||||
@@ -46,12 +44,20 @@ public class TriggerPeriodic extends Token
|
||||
@Override
|
||||
public TokenType getTokenType()
|
||||
{
|
||||
return TokenType.TRIGGER_PERIODIC;
|
||||
return TokenType.TRIGGER_DISTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds for trigger
|
||||
*/
|
||||
public int getSeconds()
|
||||
{
|
||||
return getMessage().getInt(SECONDS, getOffset());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "PERIODIC";
|
||||
return "TRIGGER EVERY:" + getSeconds() + " SECS";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.module.decode.ip.mototrbo.lrrp.token;
|
||||
|
||||
import io.github.dsheirer.bits.BinaryMessage;
|
||||
|
||||
/**
|
||||
* LRRP variable length integer (varint) reader.
|
||||
*
|
||||
* LRRP encodes integer token values as one or more bytes where each byte carries seven value bits, most significant
|
||||
* group first, and the high bit of a byte indicates that another byte follows. For example 0x84 0x58 decodes to
|
||||
* (0x04 << 7) | 0x58 = 600, 0x9C 0x10 decodes to (0x1C << 7) | 0x10 = 3600 and a value smaller than 128 occupies a
|
||||
* single byte (0x64 = 100).
|
||||
*
|
||||
* References:
|
||||
* - Moto.Net, LRRPPacket.cs: "looks like this stuff is using VLQ ... for most of the fields"
|
||||
* - node-dmr-lib, src/Protocols/LRRP.js: parseLRRPInt() and toLRRPInt()
|
||||
*/
|
||||
public final class VarInt
|
||||
{
|
||||
/**
|
||||
* Maximum number of bytes consumed for a single value. Five 7-bit groups hold 35 bits, which covers any 32-bit
|
||||
* value, and the cap keeps a corrupted run of continuation bits from consuming the rest of a packet.
|
||||
*/
|
||||
public static final int MAX_BYTES = 5;
|
||||
|
||||
private static final int[] BYTE_VALUE = {0, 1, 2, 3, 4, 5, 6, 7};
|
||||
private static final int CONTINUATION_MASK = 0x80;
|
||||
private static final int VALUE_MASK = 0x7F;
|
||||
|
||||
private VarInt()
|
||||
{
|
||||
//Static utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of bytes occupied by the varint that starts at the bit offset.
|
||||
*
|
||||
* @param message containing the value
|
||||
* @param offset bit offset to the first byte of the value
|
||||
* @param limit maximum number of bytes available for the value (ie bytes remaining in the packet payload)
|
||||
* @return byte count from 0 to the lesser of limit and MAX_BYTES. Zero indicates that no value is present.
|
||||
*/
|
||||
public static int length(BinaryMessage message, int offset, int limit)
|
||||
{
|
||||
int maximum = Math.min(limit, MAX_BYTES);
|
||||
int count = 0;
|
||||
|
||||
while(count < maximum)
|
||||
{
|
||||
int b = message.getInt(BYTE_VALUE, offset + (count * 8));
|
||||
count++;
|
||||
|
||||
if((b & CONTINUATION_MASK) == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the varint that starts at the bit offset.
|
||||
*
|
||||
* @param message containing the value
|
||||
* @param offset bit offset to the first byte of the value
|
||||
* @param limit maximum number of bytes available for the value (ie bytes remaining in the packet payload)
|
||||
* @return decoded value, or zero when no bytes are available.
|
||||
*/
|
||||
public static int value(BinaryMessage message, int offset, int limit)
|
||||
{
|
||||
int maximum = Math.min(limit, MAX_BYTES);
|
||||
int value = 0;
|
||||
|
||||
for(int count = 0; count < maximum; count++)
|
||||
{
|
||||
int b = message.getInt(BYTE_VALUE, offset + (count * 8));
|
||||
value = (value << 7) | (b & VALUE_MASK);
|
||||
|
||||
if((b & CONTINUATION_MASK) == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.module.decode.ip.mototrbo.lrrp.token;
|
||||
|
||||
import io.github.dsheirer.bits.CorrectedBinaryMessage;
|
||||
|
||||
/**
|
||||
* Base class for LRRP tokens whose value is a 7-bit variable length integer (see VarInt).
|
||||
*
|
||||
* The value starts in the byte that follows the token identifier and occupies one or more bytes. The read is
|
||||
* bounded by the number of payload bytes that remain, so a token at the end of the payload cannot consume bytes
|
||||
* that belong to the next packet.
|
||||
*/
|
||||
public abstract class VarIntToken extends Token
|
||||
{
|
||||
private static final int VALUE_START = 8;
|
||||
private int mRemainingCharacterCount;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
*
|
||||
* @param message containing the token and value
|
||||
* @param offset to the start of the token identifier
|
||||
* @param remainingCharacterCount number of payload bytes remaining, counting from the token identifier
|
||||
*/
|
||||
public VarIntToken(CorrectedBinaryMessage message, int offset, int remainingCharacterCount)
|
||||
{
|
||||
super(message, offset);
|
||||
mRemainingCharacterCount = remainingCharacterCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of value bytes that can follow the token identifier.
|
||||
*/
|
||||
private int getValueLimit()
|
||||
{
|
||||
return Math.max(0, mRemainingCharacterCount - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of bytes occupied by the value, exclusive of the token identifier.
|
||||
*/
|
||||
public int getValueLength()
|
||||
{
|
||||
return VarInt.length(getMessage(), getOffset() + VALUE_START, getValueLimit());
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if a value follows the token identifier.
|
||||
*/
|
||||
public boolean hasValue()
|
||||
{
|
||||
return getValueLength() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded integer value, or zero when the token has no value.
|
||||
*/
|
||||
public int getValue()
|
||||
{
|
||||
return VarInt.value(getMessage(), getOffset() + VALUE_START, getValueLimit());
|
||||
}
|
||||
|
||||
/**
|
||||
* Total token length: identifier plus the variable length value.
|
||||
*/
|
||||
@Override
|
||||
public int getByteLength()
|
||||
{
|
||||
return 1 + getValueLength();
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ import io.github.dsheirer.module.decode.event.ChannelEventTracker;
|
||||
*/
|
||||
public class P25ChannelEventTracker extends ChannelEventTracker<P25ChannelGrantEvent>
|
||||
{
|
||||
private boolean mEncrypted;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
*
|
||||
@@ -39,24 +37,4 @@ public class P25ChannelEventTracker extends ChannelEventTracker<P25ChannelGrantE
|
||||
{
|
||||
super(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this call is known to be encrypted. Once a call is flagged as encrypted, the flag persists for the
|
||||
* lifetime of this tracker because many channel grant update messages don't carry service options and those
|
||||
* updates are processed with empty (ie unencrypted) service options.
|
||||
* @return true if the call is known to be encrypted.
|
||||
*/
|
||||
public boolean isEncrypted()
|
||||
{
|
||||
return mEncrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags this call as encrypted.
|
||||
* @param encrypted true if encrypted.
|
||||
*/
|
||||
public void setEncrypted(boolean encrypted)
|
||||
{
|
||||
mEncrypted = encrypted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
private ScrambleParameters mPhase2ScrambleParameters;
|
||||
private Listener<IMessage> mMessageListener;
|
||||
private boolean mIgnoreDataCalls;
|
||||
private boolean mIgnoreEncryptedCalls;
|
||||
//Used only for data calls
|
||||
private final DecodeEventDuplicateDetector mDuplicateDetector = new DecodeEventDuplicateDetector();
|
||||
private final TalkerAliasManager mTalkerAliasManager = new TalkerAliasManager();
|
||||
@@ -140,7 +139,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
if(parentChannel.getDecodeConfiguration() instanceof DecodeConfigP25Phase1 phase1)
|
||||
{
|
||||
mIgnoreDataCalls = phase1.getIgnoreDataCalls();
|
||||
mIgnoreEncryptedCalls = phase1.getIgnoreEncryptedCalls();
|
||||
createPhase1TrafficChannels(phase1.getTrafficChannelPoolSize(), phase1);
|
||||
DecodeConfigP25Phase2 phase2 = new DecodeConfigP25Phase2();
|
||||
DecoderFactory.transferJMBESettings(phase1, phase2);
|
||||
@@ -149,7 +147,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
else if(parentChannel.getDecodeConfiguration() instanceof DecodeConfigP25Phase2 phase2)
|
||||
{
|
||||
mIgnoreDataCalls = phase2.getIgnoreDataCalls();
|
||||
mIgnoreEncryptedCalls = phase2.getIgnoreEncryptedCalls();
|
||||
DecodeConfigP25Phase1 phase1 = new DecodeConfigP25Phase1();
|
||||
DecoderFactory.transferJMBESettings(phase2, phase1);
|
||||
createPhase1TrafficChannels(phase2.getTrafficChannelPoolSize(), phase1);
|
||||
@@ -622,7 +619,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
|
||||
//update the ending timestamp so that the duration value is correctly calculated
|
||||
trackerTS2.updateDurationTraffic(timestamp);
|
||||
broadcast(trackerTS2);
|
||||
broadcast(trackerTS1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1152,30 +1149,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
return completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the service options identify the call as encrypted.
|
||||
* @param serviceOptions to check, optional null
|
||||
* @return true if encrypted
|
||||
*/
|
||||
private static boolean isEncrypted(ServiceOptions serviceOptions)
|
||||
{
|
||||
return serviceOptions != null && serviceOptions.isEncrypted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the tracker's encrypted flag from the current service options. When the talker changed (ie a new
|
||||
* tracker was created from a channel grant that carries service options) the tracker takes the current value.
|
||||
* Otherwise, the encrypted flag is only ever set (never cleared) since many update messages carry empty service
|
||||
* options, which would otherwise make a known-encrypted call look unencrypted.
|
||||
*/
|
||||
private static void updateEncrypted(P25ChannelEventTracker tracker, ServiceOptions serviceOptions)
|
||||
{
|
||||
if(isEncrypted(serviceOptions))
|
||||
{
|
||||
tracker.setEncrypted(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a channel start request to the ChannelProcessingManager.
|
||||
*
|
||||
@@ -1265,8 +1238,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
|
||||
}
|
||||
|
||||
updateEncrypted(tracker, serviceOptions);
|
||||
|
||||
//The tracked event can have an empty FROM identifier at start of call ... update here
|
||||
tracker.addIdentifierIfMissing(from);
|
||||
|
||||
@@ -1277,8 +1248,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
|
||||
//Even though we have a tracked event, the initial channel grant may have been rejected. Check to see if there
|
||||
//is a traffic channel allocated. If not, allocate one and update the event description.
|
||||
if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant) &&
|
||||
!(mIgnoreEncryptedCalls && tracker.isEncrypted()))
|
||||
if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant))
|
||||
{
|
||||
Channel trafficChannel = mAvailablePhase1TrafficChannelQueue.poll();
|
||||
|
||||
@@ -1323,22 +1293,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
return;
|
||||
}
|
||||
|
||||
//Don't allocate a traffic channel for an encrypted call, unless a traffic channel is already allocated for the
|
||||
//frequency in which case the call will be processed by the running traffic channel.
|
||||
if(mIgnoreEncryptedCalls && isEncrypted(serviceOptions) && !mAllocatedTrafficChannelMap.containsKey(frequency))
|
||||
{
|
||||
P25ChannelGrantEvent event = P25ChannelGrantEvent.builder(decodeEventType, timestamp, serviceOptions)
|
||||
.channelDescriptor(apco25Channel)
|
||||
.details("IGNORED: ENCRYPTED - PHASE 1 CHANNEL GRANT " + serviceOptions)
|
||||
.identifiers(ic)
|
||||
.build();
|
||||
tracker = new P25ChannelEventTracker(event);
|
||||
tracker.setEncrypted(true);
|
||||
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
|
||||
broadcast(tracker);
|
||||
return;
|
||||
}
|
||||
|
||||
String details = isDataChannelGrant ? "PHASE 1 DATA CHANNEL GRANT " : "PHASE 1 CHANNEL GRANT " +
|
||||
(serviceOptions != null ? serviceOptions : "");
|
||||
|
||||
@@ -1348,7 +1302,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
.identifiers(ic)
|
||||
.build();
|
||||
tracker = new P25ChannelEventTracker(event);
|
||||
updateEncrypted(tracker, serviceOptions);
|
||||
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
|
||||
|
||||
//Allocate a traffic channel for the downlink frequency if one isn't already allocated
|
||||
@@ -1414,19 +1367,14 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
broadcast(tracker);
|
||||
}
|
||||
|
||||
updateEncrypted(tracker, serviceOptions);
|
||||
|
||||
//update the ending timestamp so that the duration value is correctly calculated
|
||||
tracker.updateDurationControl(timestamp);
|
||||
broadcast(tracker);
|
||||
|
||||
//Even though we have a tracked event, the initial channel grant may have been rejected. Check to see if there
|
||||
//is a traffic channel allocated. If not, allocate one and update the event description.
|
||||
//Note: a phase 2 traffic channel carries two timeslots. An encrypted call on this timeslot doesn't cause
|
||||
//the channel to be allocated, but a clear call on the other timeslot will allocate the channel through
|
||||
//its own tracker and then both timeslots are processed by the traffic channel.
|
||||
if(!mAllocatedTrafficChannelMap.containsKey(frequency) && !(mIgnoreDataCalls && isDataChannelGrant) &&
|
||||
!(mIgnoreEncryptedCalls && tracker.isEncrypted()) && (getCurrentControlFrequency() != frequency))
|
||||
(getCurrentControlFrequency() != frequency))
|
||||
{
|
||||
Channel trafficChannel = mAvailablePhase2TrafficChannelQueue.poll();
|
||||
|
||||
@@ -1456,25 +1404,7 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
.build();
|
||||
|
||||
tracker = new P25ChannelEventTracker(event);
|
||||
addTracker(tracker, frequency, timeslot);
|
||||
broadcast(tracker);
|
||||
return;
|
||||
}
|
||||
|
||||
//Don't allocate a traffic channel for an encrypted call. If the frequency is already allocated (ie there's a
|
||||
//call on the other timeslot), the running traffic channel will process this timeslot as well.
|
||||
if(mIgnoreEncryptedCalls && isEncrypted(serviceOptions) && !mAllocatedTrafficChannelMap.containsKey(frequency))
|
||||
{
|
||||
P25ChannelGrantEvent event = P25ChannelGrantEvent.builder(decodeEventType, timestamp, serviceOptions)
|
||||
.channelDescriptor(apco25Channel)
|
||||
.details("IGNORED: ENCRYPTED - PHASE 2 CHANNEL GRANT " + serviceOptions)
|
||||
.identifiers(ic)
|
||||
.timeslot(timeslot)
|
||||
.build();
|
||||
|
||||
tracker = new P25ChannelEventTracker(event);
|
||||
tracker.setEncrypted(true);
|
||||
addTracker(tracker, frequency, timeslot);
|
||||
addTracker(tracker, frequency, P25P1Message.TIMESLOT_1);
|
||||
broadcast(tracker);
|
||||
return;
|
||||
}
|
||||
@@ -1487,7 +1417,6 @@ public class P25TrafficChannelManager extends TrafficChannelManager implements I
|
||||
.build();
|
||||
|
||||
tracker = new P25ChannelEventTracker(event);
|
||||
updateEncrypted(tracker, serviceOptions);
|
||||
addTracker(tracker, frequency, timeslot);
|
||||
|
||||
//Allocate a traffic channel for the downlink frequency if one isn't already allocated
|
||||
|
||||
@@ -34,7 +34,6 @@ public abstract class DecodeConfigP25 extends DecodeConfigJMBE
|
||||
{
|
||||
private int mTrafficChannelPoolSize = TRAFFIC_CHANNEL_LIMIT_DEFAULT;
|
||||
private boolean mIgnoreDataCalls = false;
|
||||
private boolean mIgnoreEncryptedCalls = false;
|
||||
|
||||
public DecodeConfigP25()
|
||||
{
|
||||
@@ -51,25 +50,6 @@ public abstract class DecodeConfigP25 extends DecodeConfigJMBE
|
||||
mIgnoreDataCalls = ignore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the traffic channel manager should not allocate (ie tune) a traffic channel for a channel grant
|
||||
* where the service options indicate the call is encrypted.
|
||||
*/
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "ignore_encrypted_calls")
|
||||
public boolean getIgnoreEncryptedCalls()
|
||||
{
|
||||
return mIgnoreEncryptedCalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ignore encrypted calls option.
|
||||
* @param ignore true to not allocate traffic channels for encrypted calls.
|
||||
*/
|
||||
public void setIgnoreEncryptedCalls(boolean ignore)
|
||||
{
|
||||
mIgnoreEncryptedCalls = ignore;
|
||||
}
|
||||
|
||||
|
||||
@JacksonXmlProperty(isAttribute = true, localName = "traffic_channel_pool_size")
|
||||
public int getTrafficChannelPoolSize()
|
||||
|
||||
@@ -192,14 +192,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
||||
private final P25TrafficChannelManager mTrafficChannelManager;
|
||||
private ServiceOptions mCurrentServiceOptions;
|
||||
|
||||
/**
|
||||
* Encryption state of the current call as signalled by the algorithm ID in the HDU header or LDU2 encryption sync
|
||||
* parameters (null when not yet known for this call). Some radios set the encryption flag in the service options of
|
||||
* the LDU1 voice channel user link control while transmitting clear voice (HDU and LDU2 report ALGID 0x80), so the
|
||||
* algorithm ID takes precedence over the link control service options when it is known.
|
||||
*/
|
||||
private Boolean mCallEncryptedByAlgorithm = null;
|
||||
|
||||
/**
|
||||
* Constructs an APCO-25 decoder state with an optional traffic channel manager.
|
||||
* @param channel with configuration details
|
||||
@@ -427,12 +419,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
||||
if(lcw instanceof IServiceOptionsProvider sop)
|
||||
{
|
||||
serviceOptions = sop.getServiceOptions();
|
||||
|
||||
//Trust the HDU/LDU2 algorithm ID over the link control service options encryption flag when known.
|
||||
if(serviceOptions != null && serviceOptions.isEncrypted() && Boolean.FALSE.equals(mCallEncryptedByAlgorithm))
|
||||
{
|
||||
serviceOptions = VoiceServiceOptions.createUnencrypted(serviceOptions);
|
||||
}
|
||||
}
|
||||
else if(lcw.isEncrypted())
|
||||
{
|
||||
@@ -852,7 +838,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
||||
|
||||
Identifier<?> radio = getIdentifierCollection().getFromIdentifier();
|
||||
|
||||
mCallEncryptedByAlgorithm = headerData.isEncryptedAudio();
|
||||
mTrafficChannelManager.processP1TrafficCallStart(getCurrentFrequency(), talkgroup, radio,
|
||||
headerData.getEncryptionKey(), mCurrentServiceOptions, getCurrentChannel(), message.getTimestamp());
|
||||
|
||||
@@ -895,7 +880,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
||||
if(esp != null && esp.isValid())
|
||||
{
|
||||
getIdentifierCollection().update(esp.getIdentifiers());
|
||||
mCallEncryptedByAlgorithm = esp.isEncryptedAudio();
|
||||
|
||||
if(esp.isEncryptedAudio())
|
||||
{
|
||||
@@ -919,7 +903,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
||||
*/
|
||||
private void processTDU(P25P1Message message)
|
||||
{
|
||||
mCallEncryptedByAlgorithm = null;
|
||||
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDU:" + message);
|
||||
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
|
||||
}
|
||||
@@ -938,7 +921,6 @@ public class P25P1DecoderState extends DecoderState implements IChannelEventList
|
||||
|
||||
if(lcw != null && lcw.isValid())
|
||||
{
|
||||
mCallEncryptedByAlgorithm = null;
|
||||
mTrafficChannelManager.processP1TrafficCallEnd(getCurrentFrequency(), message.getTimestamp(), "TDULC:" + message);
|
||||
broadcast(new DecoderStateEvent(this, Event.DECODE, State.ACTIVE));
|
||||
processLC(lcw, message.getTimestamp(), true);
|
||||
|
||||
@@ -34,7 +34,7 @@ public class HDUMessage extends P25P1Message
|
||||
private final static Logger mLog = LoggerFactory.getLogger(HDUMessage.class);
|
||||
|
||||
private static final int[] GOLAY_WORD_STARTS = {0, 18, 36, 54, 72, 90, 108, 126, 144, 162, 180, 198, 216, 234, 252,
|
||||
270, 288, 306, 324, 342, 360, 378, 396, 414, 432, 450, 468, 486, 504, 522, 540, 558, 576, 594, 612, 630};
|
||||
270, 288, 306, 324, 342, 360, 278, 396, 414, 432, 450, 468, 486, 504, 522, 540, 558, 576, 594, 612, 630};
|
||||
|
||||
private static final int[] CW_HEX_0 = {0, 1, 2, 3, 4, 5};
|
||||
private static final int[] CW_HEX_1 = {18, 19, 20, 21, 22, 23};
|
||||
|
||||
@@ -24,7 +24,6 @@ import io.github.dsheirer.module.decode.p25.phase1.message.pdu.ambtc.AMBTCHeader
|
||||
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.packet.PacketHeader;
|
||||
import io.github.dsheirer.module.decode.p25.phase1.message.pdu.response.ResponseHeader;
|
||||
import io.github.dsheirer.module.decode.p25.reference.PDUFormat;
|
||||
import io.github.dsheirer.module.decode.p25.reference.ServiceAccessPoint;
|
||||
|
||||
public class PDUHeaderFactory
|
||||
{
|
||||
@@ -42,41 +41,8 @@ public class PDUHeaderFactory
|
||||
case RESPONSE_PACKET_HEADER_FORMAT:
|
||||
return new ResponseHeader(correctedBinaryMessage, passesCRC);
|
||||
case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL:
|
||||
//Format 0x15 is the generic unconfirmed data header. Unconfirmed multi-block trunking control is
|
||||
//only the trunking control SAP (61/63) use of it. With a packet data SAP the header has the same
|
||||
//field layout as the confirmed packet data header and the data blocks carry an (IP) packet.
|
||||
if(isPacketData(getServiceAccessPoint(correctedBinaryMessage)))
|
||||
{
|
||||
return new PacketHeader(correctedBinaryMessage, passesCRC);
|
||||
}
|
||||
|
||||
return new PDUHeader(correctedBinaryMessage, passesCRC);
|
||||
default:
|
||||
return new PDUHeader(correctedBinaryMessage, passesCRC);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Access Point (SAP) from a packet data or unconfirmed data header message.
|
||||
*/
|
||||
public static ServiceAccessPoint getServiceAccessPoint(CorrectedBinaryMessage correctedBinaryMessage)
|
||||
{
|
||||
return ServiceAccessPoint.fromValue(correctedBinaryMessage.getInt(PacketHeader.SAP_ID));
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the SAP identifies packet data that is processed by the PDUMessageFactory as a packet message.
|
||||
*/
|
||||
public static boolean isPacketData(ServiceAccessPoint sap)
|
||||
{
|
||||
switch(sap)
|
||||
{
|
||||
case UNENCRYPTED_USER_DATA:
|
||||
case PACKET_DATA:
|
||||
case SNDCP_PACKET_DATA_CONTROL:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,13 +173,6 @@ public class PDUMessageFactory
|
||||
case RESPONSE_PACKET_HEADER_FORMAT:
|
||||
return new ResponseMessage(pduSequence, nac, timestamp);
|
||||
case UNCONFIRMED_MULTI_BLOCK_TRUNKING_CONTROL:
|
||||
//The header factory creates a packet header when the unconfirmed data header carries a packet data
|
||||
//SAP, so that unconfirmed IP packets are processed the same as confirmed (PACKET_DATA) packets.
|
||||
if(pduSequence.getHeader() instanceof PacketHeader)
|
||||
{
|
||||
return createPacketData(pduSequence, nac, timestamp);
|
||||
}
|
||||
|
||||
return createUMBTC(pduSequence, nac, timestamp);
|
||||
default:
|
||||
return new PDUSequenceMessage(pduSequence, nac, timestamp);
|
||||
@@ -236,7 +229,7 @@ public class PDUMessageFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a packet data message from a confirmed or unconfirmed packet data sequence.
|
||||
* Creates a packet data message
|
||||
*
|
||||
* @param pduSequence containing a packet data header and zero or more data blocks
|
||||
* @param nac network access code
|
||||
|
||||
@@ -80,13 +80,4 @@ public class VoiceServiceOptions extends ServiceOptions
|
||||
{
|
||||
return new VoiceServiceOptions(4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of the supplied service options with the encryption flag cleared and all other options unchanged.
|
||||
* @param serviceOptions to copy
|
||||
*/
|
||||
public static VoiceServiceOptions createUnencrypted(ServiceOptions serviceOptions)
|
||||
{
|
||||
return new VoiceServiceOptions(serviceOptions.mServiceOptions & ~ENCRYPTION_FLAG);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
* Copyright (C) 2014-2024 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
|
||||
@@ -247,7 +247,6 @@ public class DiagnosticMonitor
|
||||
Attributes atts = findManifestAttributes();
|
||||
if (atts != null) {
|
||||
sb.append("\nVersion : " + atts.getValue("Implementation-Version"));
|
||||
sb.append("\nGit Describe : " + atts.getValue("Git-Describe"));
|
||||
sb.append("\nGradle Version : " + atts.getValue("Created-By"));
|
||||
sb.append("\nBuild Timestamp : " + atts.getValue("Build-Timestamp"));
|
||||
sb.append("\nBuild-JDK : " + atts.getValue("Build-JDK"));
|
||||
|
||||
@@ -30,7 +30,6 @@ public enum PreferenceType
|
||||
DIRECTORY,
|
||||
DUPLICATE_CALL_DETECTION,
|
||||
JMBE_LIBRARY,
|
||||
MAP,
|
||||
MP3,
|
||||
MULTI_FREQUENCY,
|
||||
PLAYLIST,
|
||||
|
||||
@@ -28,7 +28,6 @@ import io.github.dsheirer.preference.duplicate.CallManagementPreference;
|
||||
import io.github.dsheirer.preference.event.DecodeEventPreference;
|
||||
import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference;
|
||||
import io.github.dsheirer.preference.javafx.JavaFxPreferences;
|
||||
import io.github.dsheirer.preference.map.MapPreference;
|
||||
import io.github.dsheirer.preference.mp3.MP3Preference;
|
||||
import io.github.dsheirer.preference.playback.PlaybackPreference;
|
||||
import io.github.dsheirer.preference.playlist.PlaylistPreference;
|
||||
@@ -63,7 +62,6 @@ public class UserPreferences implements Listener<PreferenceType>
|
||||
private DirectoryPreference mDirectoryPreference;
|
||||
private CallManagementPreference mCallManagementPreference;
|
||||
private JmbeLibraryPreference mJmbeLibraryPreference;
|
||||
private MapPreference mMapPreference;
|
||||
private MP3Preference mMP3Preference;
|
||||
private PlaybackPreference mPlaybackPreference;
|
||||
private PlaylistPreference mPlaylistPreference;
|
||||
@@ -207,14 +205,6 @@ public class UserPreferences implements Listener<PreferenceType>
|
||||
return mSwingPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map view preferences: the last center point and zoom level
|
||||
*/
|
||||
public MapPreference getMapPreference()
|
||||
{
|
||||
return mMapPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call management and duplicate call detection preferences
|
||||
*/
|
||||
@@ -234,7 +224,6 @@ public class UserPreferences implements Listener<PreferenceType>
|
||||
mDirectoryPreference = new DirectoryPreference(this::receive);
|
||||
mCallManagementPreference = new CallManagementPreference(this::receive);
|
||||
mJmbeLibraryPreference = new JmbeLibraryPreference(this::receive);
|
||||
mMapPreference = new MapPreference(this::receive);
|
||||
mMP3Preference = new MP3Preference(this::receive);
|
||||
mPlaybackPreference = new PlaybackPreference(this::receive);
|
||||
mPlaylistPreference = new PlaylistPreference(this::receive, mDirectoryPreference);
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.preference.map;
|
||||
|
||||
import io.github.dsheirer.preference.Preference;
|
||||
import io.github.dsheirer.preference.PreferenceType;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import java.util.Objects;
|
||||
import java.util.prefs.Preferences;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Map preferences: the last center point and zoom level of the map, restored when the application starts, and the
|
||||
* map layers to show. The user's own location is a radio reference preference, shown on the map as a pin.
|
||||
*/
|
||||
public class MapPreference extends Preference
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(MapPreference.class);
|
||||
|
||||
/**
|
||||
* Zoom level when none is stored
|
||||
*/
|
||||
public static final int DEFAULT_ZOOM = 7;
|
||||
|
||||
private static final String CENTER = "center";
|
||||
private static final String ZOOM = "zoom";
|
||||
private static final String SHOW_RADIO_REFERENCE_SITES = "show.radioreference.sites";
|
||||
private final Preferences mPreferences = Preferences.userNodeForPackage(MapPreference.class);
|
||||
|
||||
/**
|
||||
* Map center point
|
||||
* @param latitude in decimal degrees, -90 to 90
|
||||
* @param longitude in decimal degrees, -180 to 180
|
||||
*/
|
||||
public record Center(double latitude, double longitude)
|
||||
{
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @throws IllegalArgumentException if the coordinates are out of range
|
||||
*/
|
||||
public Center
|
||||
{
|
||||
if(!Double.isFinite(latitude) || !Double.isFinite(longitude) || Math.abs(latitude) > 90.0 ||
|
||||
Math.abs(longitude) > 180.0)
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid map center: " + latitude + ", " + longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param updateListener to receive notifications that a preference has been updated
|
||||
*/
|
||||
public MapPreference(Listener<PreferenceType> updateListener)
|
||||
{
|
||||
super(updateListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PreferenceType getPreferenceType()
|
||||
{
|
||||
return PreferenceType.MAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last center point of the map. Can be called from any thread.
|
||||
* @return center, or null if none is stored
|
||||
*/
|
||||
public Center getCenter()
|
||||
{
|
||||
String value = mPreferences.get(CENTER, null);
|
||||
|
||||
if(value != null)
|
||||
{
|
||||
String[] parts = value.split(",");
|
||||
|
||||
if(parts.length == 2)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Center(Double.parseDouble(parts[0]), Double.parseDouble(parts[1]));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
mLog.warn("Ignoring invalid stored map center [" + value + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the center point of the map
|
||||
* @param center to store
|
||||
*/
|
||||
public void setCenter(Center center)
|
||||
{
|
||||
if(center != null && !Objects.equals(center, getCenter()))
|
||||
{
|
||||
//Stored as one value so that a reader never sees the latitude of one point with the longitude of another
|
||||
mPreferences.put(CENTER, center.latitude() + "," + center.longitude());
|
||||
notifyPreferenceUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last zoom level of the map. Can be called from any thread.
|
||||
* @return zoom level, or {@link #DEFAULT_ZOOM} if none is stored
|
||||
*/
|
||||
public int getZoom()
|
||||
{
|
||||
return mPreferences.getInt(ZOOM, DEFAULT_ZOOM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the zoom level of the map
|
||||
* @param zoom level
|
||||
*/
|
||||
public void setZoom(int zoom)
|
||||
{
|
||||
if(zoom != getZoom())
|
||||
{
|
||||
mPreferences.putInt(ZOOM, zoom);
|
||||
notifyPreferenceUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the map shows the radio reference trunked system sites near the user
|
||||
*/
|
||||
public boolean isShowRadioReferenceSites()
|
||||
{
|
||||
return mPreferences.getBoolean(SHOW_RADIO_REFERENCE_SITES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or hides the radio reference trunked system sites on the map
|
||||
*/
|
||||
public void setShowRadioReferenceSites(boolean show)
|
||||
{
|
||||
if(show != isShowRadioReferenceSites())
|
||||
{
|
||||
mPreferences.putBoolean(SHOW_RADIO_REFERENCE_SITES, show);
|
||||
notifyPreferenceUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the map view stored in the settings file by older versions of the application into this preference.
|
||||
* Does nothing once a center point is stored here.
|
||||
* @param latitude of the stored map view
|
||||
* @param longitude of the stored map view
|
||||
* @param zoom of the stored map view
|
||||
*/
|
||||
public void migrateLegacyView(double latitude, double longitude, int zoom)
|
||||
{
|
||||
if(getCenter() == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
setCenter(new Center(latitude, longitude));
|
||||
setZoom(zoom);
|
||||
mLog.info("Moved the map view from the settings file to the map preferences");
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
mLog.warn("Ignoring invalid map view from the settings file: " + latitude + ", " + longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import io.github.dsheirer.service.radioreference.RadioReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.prefs.Preferences;
|
||||
|
||||
/**
|
||||
@@ -59,8 +58,6 @@ public class RadioReferencePreference extends Preference
|
||||
private static final String SHOW_CHANNEL_EDITOR_COUNTY = "create.and.show.editor.county";
|
||||
private static final String ENCRYPTED_TALKGROUP_DO_NOT_MONITOR = "encrypted.talkgroup.import.do.not.monitor";
|
||||
private static final String CREATE_AND_SHOW_CHANNEL_EDITOR = "create.and.show.channel.editor";
|
||||
private static final String LOCATION = "location";
|
||||
private static final String LOCATION_LABEL = "location.label";
|
||||
|
||||
private String mUserName;
|
||||
private String mPassword;
|
||||
@@ -516,116 +513,4 @@ public class RadioReferencePreference extends Preference
|
||||
mPreferences.putBoolean(CREATE_AND_SHOW_CHANNEL_EDITOR, show);
|
||||
notifyPreferenceUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* User's location, for ranking radio reference results by distance.
|
||||
* @param latitude in decimal degrees, -90 to 90
|
||||
* @param longitude in decimal degrees, -180 to 180
|
||||
* @param label describing the location, e.g. the city and zip code that it was looked up from, or null when the
|
||||
* coordinates were entered by hand
|
||||
*/
|
||||
public record Location(double latitude, double longitude, String label)
|
||||
{
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @throws IllegalArgumentException if the coordinates are out of range
|
||||
*/
|
||||
public Location
|
||||
{
|
||||
if(!isValid(latitude, longitude))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid location: " + latitude + ", " + longitude);
|
||||
}
|
||||
|
||||
label = label == null || label.isBlank() ? null : label.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the coordinates are within range
|
||||
* @param latitude in decimal degrees
|
||||
* @param longitude in decimal degrees
|
||||
* @return true if valid
|
||||
*/
|
||||
public static boolean isValid(double latitude, double longitude)
|
||||
{
|
||||
return Double.isFinite(latitude) && Double.isFinite(longitude) && Math.abs(latitude) <= 90.0 &&
|
||||
Math.abs(longitude) <= 180.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates for display
|
||||
* @return text such as "43.0481, -76.1474"
|
||||
*/
|
||||
public String coordinates()
|
||||
{
|
||||
return String.format(Locale.ROOT, "%.4f, %.4f", latitude, longitude);
|
||||
}
|
||||
|
||||
/**
|
||||
* Location for display
|
||||
* @return text such as "Syracuse, New York 13202 (43.0481, -76.1474)", or the coordinates when there's no label
|
||||
*/
|
||||
public String describe()
|
||||
{
|
||||
return label != null ? label + " (" + coordinates() + ")" : coordinates();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User's location, for ranking radio reference results by distance. Can be called from any thread.
|
||||
* @return location, or null if the user hasn't set one
|
||||
*/
|
||||
public Location getLocation()
|
||||
{
|
||||
String value = mPreferences.get(LOCATION, null);
|
||||
|
||||
if(value != null)
|
||||
{
|
||||
String[] parts = value.split(",");
|
||||
|
||||
if(parts.length == 2)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Location(Double.parseDouble(parts[0]), Double.parseDouble(parts[1]),
|
||||
mPreferences.get(LOCATION_LABEL, null));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
mLog.warn("Ignoring invalid stored radio reference location [" + value + "]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user's location
|
||||
* @param location to store, or null to clear the location
|
||||
*/
|
||||
public void setLocation(Location location)
|
||||
{
|
||||
if(location != null)
|
||||
{
|
||||
//Stored as one value so that a reader never sees the latitude of one location with the longitude of another
|
||||
mPreferences.put(LOCATION, location.latitude() + "," + location.longitude());
|
||||
|
||||
if(location.label() != null)
|
||||
{
|
||||
mPreferences.put(LOCATION_LABEL, location.label());
|
||||
}
|
||||
else
|
||||
{
|
||||
mPreferences.remove(LOCATION_LABEL);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mPreferences.remove(LOCATION);
|
||||
mPreferences.remove(LOCATION_LABEL);
|
||||
}
|
||||
|
||||
notifyPreferenceUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
* Copyright (C) 2014-2024 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
|
||||
@@ -48,7 +48,6 @@ public class SystemProperties
|
||||
private static String PROPERTIES_FILENAME = "SDRTrunk.properties";
|
||||
private static String MANIFEST_VERSION = "Implementation-Version";
|
||||
private static String BUILD_TIMESTAMP = "Build-Timestamp";
|
||||
private static String GIT_DESCRIBE = "Git-Describe";
|
||||
|
||||
private static SystemProperties INSTANCE;
|
||||
private static Properties mProperties;
|
||||
@@ -218,18 +217,12 @@ public class SystemProperties
|
||||
{
|
||||
String version = manifest.getMainAttributes().getValue(MANIFEST_VERSION);
|
||||
String timestamp = manifest.getMainAttributes().getValue(BUILD_TIMESTAMP);
|
||||
String gitDescribe = manifest.getMainAttributes().getValue(GIT_DESCRIBE);
|
||||
|
||||
if(version != null)
|
||||
{
|
||||
if(version.contains("nightly") && timestamp != null)
|
||||
{
|
||||
sb.append( " nightly - ").append(timestamp);
|
||||
|
||||
if(gitDescribe != null && !gitDescribe.equals("unknown"))
|
||||
{
|
||||
sb.append(" (").append(gitDescribe).append(")");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.
|
||||
* * ******************************************************************************
|
||||
* * Copyright (C) 2014-2020 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/>
|
||||
* * *****************************************************************************
|
||||
*
|
||||
* 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.service.radioreference;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import com.google.common.cache.CacheLoader;
|
||||
import com.google.common.cache.LoadingCache;
|
||||
import com.google.common.util.concurrent.ExecutionError;
|
||||
import com.google.common.util.concurrent.UncheckedExecutionException;
|
||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||
import io.github.dsheirer.rrapi.RadioReferenceService;
|
||||
import io.github.dsheirer.rrapi.type.AgencyInfo;
|
||||
@@ -35,39 +35,21 @@ import io.github.dsheirer.rrapi.type.Site;
|
||||
import io.github.dsheirer.rrapi.type.StateInfo;
|
||||
import io.github.dsheirer.rrapi.type.SystemInformation;
|
||||
import io.github.dsheirer.rrapi.type.Talkgroup;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
/**
|
||||
* Provides local caching for frequently retrieved items from the radio reference service.
|
||||
*
|
||||
* Cached entries expire after {@link #CACHE_EXPIRATION} so that a long running application sees changes to the radio
|
||||
* reference database. Failed calls aren't cached: the error is thrown to the caller and the next call tries again.
|
||||
* Cached lists are read-only because they are shared by all callers.
|
||||
* Provides local caching for frequently retrieved items from the radio reference service
|
||||
*/
|
||||
public class CachingRadioReferenceService extends RadioReferenceService
|
||||
{
|
||||
/**
|
||||
* Limit for receiving each response. The JDK HTTP client applies the request timeout until the response body is
|
||||
* fully received, so this is longer than the library's 30 second default to allow the talkgroup list of a large
|
||||
* system to download on a slow connection.
|
||||
*/
|
||||
public static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(2);
|
||||
|
||||
/**
|
||||
* Time that a cached entry is kept after it is retrieved
|
||||
*/
|
||||
public static final Duration CACHE_EXPIRATION = Duration.ofHours(1);
|
||||
|
||||
private final LoadingCache<Integer,AgencyInfo> mAgencyInfoCache;
|
||||
private final LoadingCache<Integer,CountyInfo> mCountyInfoCache;
|
||||
private final LoadingCache<Integer,StateInfo> mStateInfoCache;
|
||||
private final LoadingCache<Integer,SystemInformation> mSystemInfoCache;
|
||||
private final LoadingCache<Integer,List<Site>> mSystemSitesCache;
|
||||
private final LoadingCache<Integer,List<Frequency>> mSubCategoryFrequencyCache;
|
||||
private final LoadingCache<Integer,List<Talkgroup>> mTalkgroupCache;
|
||||
private LoadingCache<Integer,AgencyInfo> mAgencyInfoCache;
|
||||
private LoadingCache<Integer,CountyInfo> mCountyInfoCache;
|
||||
private LoadingCache<Integer,StateInfo> mStateInfoCache;
|
||||
private LoadingCache<Integer,SystemInformation> mSystemInfoCache;
|
||||
private LoadingCache<Integer,List<Site>> mSystemSitesCache;
|
||||
private LoadingCache<Integer,List<Frequency>> mSubCategoryFrequencyCache;
|
||||
private LoadingCache<Integer,List<Talkgroup>> mTalkgroupCache;
|
||||
|
||||
/**
|
||||
* Constructs an instance of the service
|
||||
@@ -77,164 +59,175 @@ public class CachingRadioReferenceService extends RadioReferenceService
|
||||
*/
|
||||
public CachingRadioReferenceService(AuthorizationInformation authorizationInformation) throws RadioReferenceException
|
||||
{
|
||||
super(authorizationInformation, DEFAULT_CONNECT_TIMEOUT, REQUEST_TIMEOUT);
|
||||
super(authorizationInformation);
|
||||
|
||||
mAgencyInfoCache = cache(1000, super::getAgencyInfo);
|
||||
mCountyInfoCache = cache(1000, super::getCountyInfo);
|
||||
mStateInfoCache = cache(100, super::getStateInfo);
|
||||
mSystemInfoCache = cache(100, super::getSystemInformation);
|
||||
mSystemSitesCache = cache(1000, id -> readOnly(super.getSites(id)));
|
||||
mSubCategoryFrequencyCache = cache(1000, id -> readOnly(super.getSubCategoryFrequencies(id)));
|
||||
mTalkgroupCache = cache(100, id -> readOnly(super.getTalkgroups(id)));
|
||||
mAgencyInfoCache = CacheBuilder.newBuilder()
|
||||
.maximumSize(1000)
|
||||
.build(new CacheLoader<Integer,AgencyInfo>()
|
||||
{
|
||||
@Override
|
||||
public AgencyInfo load(Integer key) throws Exception
|
||||
{
|
||||
return CachingRadioReferenceService.super.getAgencyInfo(key);
|
||||
}
|
||||
});
|
||||
|
||||
mCountyInfoCache = CacheBuilder.newBuilder()
|
||||
.maximumSize(1000)
|
||||
.build(new CacheLoader<Integer,CountyInfo>()
|
||||
{
|
||||
@Override
|
||||
public CountyInfo load(Integer key) throws Exception
|
||||
{
|
||||
return CachingRadioReferenceService.super.getCountyInfo(key);
|
||||
}
|
||||
});
|
||||
|
||||
mStateInfoCache = CacheBuilder.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build(new CacheLoader<Integer,StateInfo>()
|
||||
{
|
||||
@Override
|
||||
public StateInfo load(Integer key) throws Exception
|
||||
{
|
||||
return CachingRadioReferenceService.super.getStateInfo(key);
|
||||
}
|
||||
});
|
||||
|
||||
mSystemInfoCache = CacheBuilder.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build(new CacheLoader<Integer,SystemInformation>()
|
||||
{
|
||||
@Override
|
||||
public SystemInformation load(Integer key) throws Exception
|
||||
{
|
||||
return CachingRadioReferenceService.super.getSystemInformation(key);
|
||||
}
|
||||
});
|
||||
|
||||
mSystemSitesCache = CacheBuilder.newBuilder()
|
||||
.maximumSize(1000)
|
||||
.build(new CacheLoader<Integer,List<Site>>()
|
||||
{
|
||||
@Override
|
||||
public List<Site> load(Integer key) throws Exception
|
||||
{
|
||||
return CachingRadioReferenceService.super.getSites(key);
|
||||
}
|
||||
});
|
||||
|
||||
mSubCategoryFrequencyCache = CacheBuilder.newBuilder()
|
||||
.maximumSize(1000)
|
||||
.build(new CacheLoader<Integer,List<Frequency>>()
|
||||
{
|
||||
@Override
|
||||
public List<Frequency> load(Integer key) throws Exception
|
||||
{
|
||||
return CachingRadioReferenceService.super.getSubCategoryFrequencies(key);
|
||||
}
|
||||
});
|
||||
|
||||
mTalkgroupCache = CacheBuilder.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build(new CacheLoader<Integer,List<Talkgroup>>()
|
||||
{
|
||||
@Override
|
||||
public List<Talkgroup> load(Integer key) throws Exception
|
||||
{
|
||||
return CachingRadioReferenceService.super.getTalkgroups(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgencyInfo getAgencyInfo(int agencyId) throws RadioReferenceException
|
||||
{
|
||||
return get(mAgencyInfoCache, agencyId);
|
||||
try
|
||||
{
|
||||
return mAgencyInfoCache.get(agencyId);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return CachingRadioReferenceService.super.getAgencyInfo(agencyId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CountyInfo getCountyInfo(final int countyId) throws RadioReferenceException
|
||||
{
|
||||
return get(mCountyInfoCache, countyId);
|
||||
try
|
||||
{
|
||||
return mCountyInfoCache.get(countyId);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return CachingRadioReferenceService.super.getCountyInfo(countyId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public StateInfo getStateInfo(final int stateId) throws RadioReferenceException
|
||||
{
|
||||
return get(mStateInfoCache, stateId);
|
||||
try
|
||||
{
|
||||
return mStateInfoCache.get(stateId);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return CachingRadioReferenceService.super.getStateInfo(stateId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SystemInformation getSystemInformation(int systemId) throws RadioReferenceException
|
||||
{
|
||||
return get(mSystemInfoCache, systemId);
|
||||
try
|
||||
{
|
||||
return mSystemInfoCache.get(systemId);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return CachingRadioReferenceService.super.getSystemInformation(systemId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites for the system
|
||||
* @param systemId to query
|
||||
* @return read-only list of sites
|
||||
* @throws RadioReferenceException if there is an error
|
||||
*/
|
||||
@Override
|
||||
public List<Site> getSites(int systemId) throws RadioReferenceException
|
||||
{
|
||||
return get(mSystemSitesCache, systemId);
|
||||
try
|
||||
{
|
||||
return mSystemSitesCache.get(systemId);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return CachingRadioReferenceService.super.getSites(systemId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequencies for the subcategory
|
||||
* @param subCategoryId to query
|
||||
* @return read-only list of frequencies
|
||||
* @throws RadioReferenceException if there is an error
|
||||
*/
|
||||
@Override
|
||||
public List<Frequency> getSubCategoryFrequencies(int subCategoryId) throws RadioReferenceException
|
||||
{
|
||||
return get(mSubCategoryFrequencyCache, subCategoryId);
|
||||
try
|
||||
{
|
||||
return mSubCategoryFrequencyCache.get(subCategoryId);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return CachingRadioReferenceService.super.getSubCategoryFrequencies(subCategoryId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Talkgroups for the system
|
||||
* @param systemId to query
|
||||
* @return read-only list of talkgroups
|
||||
* @throws RadioReferenceException if there is an error
|
||||
*/
|
||||
|
||||
@Override
|
||||
public List<Talkgroup> getTalkgroups(int systemId) throws RadioReferenceException
|
||||
{
|
||||
return get(mTalkgroupCache, systemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a value from the web service
|
||||
*/
|
||||
@FunctionalInterface
|
||||
@VisibleForTesting
|
||||
interface Loader<V>
|
||||
{
|
||||
V load(int id) throws RadioReferenceException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cache that loads values with the loader
|
||||
* @param maximumSize of the cache
|
||||
* @param loader to call the web service
|
||||
* @return cache
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static <V> LoadingCache<Integer,V> cache(int maximumSize, Loader<V> loader)
|
||||
{
|
||||
return CacheBuilder.newBuilder()
|
||||
.maximumSize(maximumSize)
|
||||
.expireAfterWrite(CACHE_EXPIRATION)
|
||||
.build(new CacheLoader<Integer,V>()
|
||||
{
|
||||
@Override
|
||||
public V load(Integer id) throws RadioReferenceException
|
||||
{
|
||||
return loader.load(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cached value, or loads it from the web service.
|
||||
* @param cache to query
|
||||
* @param id of the value
|
||||
* @return value, or null if the web service didn't provide one. Null values aren't cached.
|
||||
* @throws RadioReferenceException thrown by the web service call
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static <V> V get(LoadingCache<Integer,V> cache, int id) throws RadioReferenceException
|
||||
{
|
||||
try
|
||||
{
|
||||
return cache.get(id);
|
||||
return mTalkgroupCache.get(systemId);
|
||||
}
|
||||
catch(CacheLoader.InvalidCacheLoadException e)
|
||||
catch(Exception e)
|
||||
{
|
||||
//The loader returned null, which the cache doesn't store
|
||||
return null;
|
||||
return CachingRadioReferenceService.super.getTalkgroups(systemId);
|
||||
}
|
||||
catch(ExecutionException e)
|
||||
{
|
||||
if(e.getCause() instanceof RadioReferenceException rre)
|
||||
{
|
||||
throw rre;
|
||||
}
|
||||
|
||||
throw new RadioReferenceException("Error retrieving value from radio reference service", e.getCause());
|
||||
}
|
||||
catch(UncheckedExecutionException e)
|
||||
{
|
||||
if(e.getCause() instanceof RuntimeException re)
|
||||
{
|
||||
throw re;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
catch(ExecutionError e)
|
||||
{
|
||||
if(e.getCause() instanceof Error error)
|
||||
{
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the list
|
||||
* @param list to wrap, or null
|
||||
* @return read-only list, or null if the list is null
|
||||
*/
|
||||
private static <T> List<T> readOnly(List<T> list)
|
||||
{
|
||||
return list != null ? Collections.unmodifiableList(list) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.service.radioreference;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Formats distances for display, in miles where the locale's country measures road distances in miles, otherwise in
|
||||
* kilometers.
|
||||
*/
|
||||
public final class DistanceFormat
|
||||
{
|
||||
private static final double KILOMETERS_PER_MILE = 1.609344;
|
||||
private static final boolean MILES = usesMiles(Locale.getDefault());
|
||||
|
||||
private DistanceFormat()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance for display in the default locale's unit, e.g. 12 mi or < 1 km
|
||||
*/
|
||||
public static String format(double kilometers)
|
||||
{
|
||||
return format(kilometers, MILES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance for display, in miles or kilometers
|
||||
*/
|
||||
public static String format(double kilometers, boolean miles)
|
||||
{
|
||||
double value = miles ? kilometers / KILOMETERS_PER_MILE : kilometers;
|
||||
String unit = miles ? " mi" : " km";
|
||||
return value < 1.0 ? "< 1" + unit : Math.round(value) + unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the locale's country measures road distances in miles
|
||||
*/
|
||||
public static boolean usesMiles(Locale locale)
|
||||
{
|
||||
String country = locale.getCountry();
|
||||
return "US".equals(country) || "GB".equals(country) || "LR".equals(country) || "MM".equals(country);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* 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.service.radioreference;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
/**
|
||||
* Radio reference frequency near the user, a candidate for a signal at that frequency. Stored in the frequency index
|
||||
* file, so changing the fields needs a new index file version.
|
||||
*
|
||||
* @param frequencyHz downlink frequency in Hertz
|
||||
* @param title of the frequency: the description or alpha tag of a conventional frequency, or the system name of a
|
||||
* trunked site frequency
|
||||
* @param alphaTag of a conventional frequency, or null
|
||||
* @param mode of a conventional frequency (e.g. FMN) or the system type of a trunked site frequency, or null
|
||||
* @param tone of a conventional frequency as radio reference lists it, or null
|
||||
* @param use of a trunked site frequency (e.g. Control), or null
|
||||
* @param encrypted true if radio reference lists the frequency as encrypted
|
||||
* @param source where the frequency is listed: county or agency, category and subcategory, or system site
|
||||
* @param latitude of the frequency's coverage (subcategory, county, agency or site), or null when unknown
|
||||
* @param longitude of the frequency's coverage, or null when unknown
|
||||
* @param systemId radio reference id of the trunked system of a site frequency, or null for a conventional frequency
|
||||
* @param siteId radio reference id of the site of a site frequency, or null for a conventional frequency
|
||||
*/
|
||||
public record FrequencyCandidate(long frequencyHz, String title, String alphaTag, String mode, String tone, String use,
|
||||
boolean encrypted, String source, Double latitude, Double longitude, Integer systemId,
|
||||
Integer siteId)
|
||||
{
|
||||
/**
|
||||
* Indicates if this is a trunked system site frequency. Not stored in the index file.
|
||||
*/
|
||||
@JsonIgnore
|
||||
public boolean isSiteFrequency()
|
||||
{
|
||||
return systemId != null && siteId != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the candidate has a location
|
||||
*/
|
||||
public boolean hasLocation()
|
||||
{
|
||||
return latitude != null && longitude != null;
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,9 @@ package io.github.dsheirer.service.radioreference;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.github.dsheirer.preference.UserPreferences;
|
||||
import io.github.dsheirer.preference.radioreference.RadioReferencePreference;
|
||||
import io.github.dsheirer.rrapi.RadioReferenceException;
|
||||
import io.github.dsheirer.rrapi.RadioReferenceService;
|
||||
import io.github.dsheirer.rrapi.response.Fault;
|
||||
import io.github.dsheirer.rrapi.type.AuthorizationInformation;
|
||||
import io.github.dsheirer.rrapi.type.CountryInfo;
|
||||
import io.github.dsheirer.rrapi.type.County;
|
||||
@@ -32,8 +32,6 @@ import io.github.dsheirer.rrapi.type.Flavor;
|
||||
import io.github.dsheirer.rrapi.type.State;
|
||||
import io.github.dsheirer.rrapi.type.StateInfo;
|
||||
import io.github.dsheirer.rrapi.type.UserInfo;
|
||||
import io.github.dsheirer.rrapi.type.ZipInfo;
|
||||
import io.github.dsheirer.util.ThreadPool;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
@@ -41,9 +39,6 @@ import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Scanner;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
@@ -62,14 +57,12 @@ public class RadioReference
|
||||
private RadioReferenceService mRadioReferenceService;
|
||||
private UserPreferences mUserPreferences;
|
||||
private AuthorizationInformation mAuthorizationInformation;
|
||||
private final AtomicLong mLoginRequest = new AtomicLong();
|
||||
private final RadioReferenceFrequencyIndex mFrequencyIndex;
|
||||
private StringProperty mUserName = new SimpleStringProperty();
|
||||
private StringProperty mPassword = new SimpleStringProperty();
|
||||
private StringProperty mAccountExpiresProperty = new SimpleStringProperty();
|
||||
private BooleanProperty mAvailable = new SimpleBooleanProperty();
|
||||
private BooleanProperty mPremiumAccount = new SimpleBooleanProperty();
|
||||
private volatile LoginStatus mLoginStatus = LoginStatus.UNKNOWN;
|
||||
private LoginStatus mLoginStatus = LoginStatus.UNKNOWN;
|
||||
|
||||
/**
|
||||
* Enum to determine "validity" of a RR Login
|
||||
@@ -89,47 +82,10 @@ public class RadioReference
|
||||
public RadioReference(UserPreferences userPreferences)
|
||||
{
|
||||
mUserPreferences = userPreferences;
|
||||
mFrequencyIndex = new RadioReferenceFrequencyIndex(this, userPreferences);
|
||||
}
|
||||
|
||||
/**
|
||||
* User preferences, for the location, credentials and talkgroup formats
|
||||
*/
|
||||
public UserPreferences getUserPreferences()
|
||||
{
|
||||
return mUserPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the radio reference frequencies near the user's location. Idle until started.
|
||||
*/
|
||||
public RadioReferenceFrequencyIndex getFrequencyIndex()
|
||||
{
|
||||
return mFrequencyIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in with the credentials stored in the user preferences, if there are any and no login has been made yet.
|
||||
* Logs in on a background thread and updates the properties on the FX thread, so the JavaFX toolkit must be
|
||||
* running.
|
||||
*/
|
||||
public void loginWithStoredCredentials()
|
||||
{
|
||||
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
|
||||
|
||||
if(mLoginRequest.get() == 0 && preference.hasStoredCredentials())
|
||||
{
|
||||
AuthorizationInformation credentials = preference.getAuthorizationInformation();
|
||||
|
||||
if(credentials != null)
|
||||
{
|
||||
setAuthorizationInformation(credentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Externally monitored and controlled status indicator for the service logged-on state. Updated on the FX thread.
|
||||
* Externally monitored and controlled status indicator for the service logged-on state
|
||||
*/
|
||||
public BooleanProperty availableProperty()
|
||||
{
|
||||
@@ -137,7 +93,7 @@ public class RadioReference
|
||||
}
|
||||
|
||||
/**
|
||||
* Externally monitored and controlled status indicator for a Premium RR Account. Updated on the FX thread.
|
||||
* Externally monitored and controlled status indicator for a Premium RR Account
|
||||
*/
|
||||
public BooleanProperty premiumAccountProperty()
|
||||
{
|
||||
@@ -185,7 +141,7 @@ public class RadioReference
|
||||
* @return service
|
||||
* @throws RadioReferenceException if there is an issue creating the service (ie login credentials missing)
|
||||
*/
|
||||
public synchronized RadioReferenceService getService() throws RadioReferenceException
|
||||
public RadioReferenceService getService() throws RadioReferenceException
|
||||
{
|
||||
if(mRadioReferenceService == null)
|
||||
{
|
||||
@@ -206,102 +162,6 @@ public class RadioReference
|
||||
return mRadioReferenceService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location to rank radio reference results by distance from the user: the location set in the preferences, or
|
||||
* else the center of the preferred county. Calls the web service for the preferred county, so don't call this
|
||||
* on the FX thread.
|
||||
* @return location, or null if the user hasn't set a location and there's no preferred county with a location, or
|
||||
* the account can't access the service
|
||||
* @throws RadioReferenceException if the preferred county lookup fails
|
||||
*/
|
||||
public UserLocation getUserLocation() throws RadioReferenceException
|
||||
{
|
||||
RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference();
|
||||
RadioReferencePreference.Location location = preference.getLocation();
|
||||
|
||||
if(location != null)
|
||||
{
|
||||
return new UserLocation(location.latitude(), location.longitude(), location.label(),
|
||||
UserLocation.Source.USER);
|
||||
}
|
||||
|
||||
int countyId = preference.getPreferredCountyId();
|
||||
|
||||
if(countyId > 0 && premiumAccountProperty().get())
|
||||
{
|
||||
CountyInfo countyInfo = getService().getCountyInfo(countyId);
|
||||
|
||||
if(countyInfo != null && countyInfo.getCoverage().hasLocation())
|
||||
{
|
||||
return new UserLocation(countyInfo.getLatitude(), countyInfo.getLongitude(), countyInfo.getName(),
|
||||
UserLocation.Source.PREFERRED_COUNTY);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the location of a United States zip code
|
||||
* @param zipcode five digit zip code
|
||||
* @return location with a label naming the city, state and zip code
|
||||
* @throws RadioReferenceException if the lookup fails or the service has no location for the zip code
|
||||
*/
|
||||
public RadioReferencePreference.Location lookupZipcode(int zipcode) throws RadioReferenceException
|
||||
{
|
||||
RadioReferenceService service = getService();
|
||||
ZipInfo zipInfo = service.getZipcodeInfo(zipcode);
|
||||
String zip = String.format("%05d", zipcode);
|
||||
|
||||
if(zipInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
double latitude = Double.parseDouble(zipInfo.getLatitude().trim());
|
||||
double longitude = Double.parseDouble(zipInfo.getLongitude().trim());
|
||||
|
||||
//The service returns zeros for some unknown zip codes
|
||||
if(RadioReferencePreference.Location.isValid(latitude, longitude) && (latitude != 0.0 || longitude != 0.0))
|
||||
{
|
||||
String state = null;
|
||||
|
||||
try
|
||||
{
|
||||
StateInfo stateInfo = service.getStateInfo(zipInfo.getStateId());
|
||||
state = stateInfo != null ? stateInfo.getName() : null;
|
||||
}
|
||||
catch(RadioReferenceException rre)
|
||||
{
|
||||
//The state name is only for the label
|
||||
mLog.debug("Unable to look up state [" + zipInfo.getStateId() + "] for zip code " + zip);
|
||||
}
|
||||
|
||||
StringBuilder label = new StringBuilder();
|
||||
|
||||
if(zipInfo.getCity() != null && !zipInfo.getCity().isBlank())
|
||||
{
|
||||
label.append(zipInfo.getCity().trim()).append(state != null ? ", " : " ");
|
||||
}
|
||||
|
||||
if(state != null)
|
||||
{
|
||||
label.append(state).append(" ");
|
||||
}
|
||||
|
||||
label.append(zip);
|
||||
return new RadioReferencePreference.Location(latitude, longitude, label.toString());
|
||||
}
|
||||
}
|
||||
catch(NullPointerException | NumberFormatException e)
|
||||
{
|
||||
//Fall through - no usable coordinates
|
||||
}
|
||||
}
|
||||
|
||||
throw new RadioReferenceException(RadioReferenceException.ErrorType.INVALID_REQUEST,
|
||||
"Radio Reference has no location for zip code " + zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the connection to radio reference service using the provided credentials.
|
||||
* @param userName for radio reference account
|
||||
@@ -347,12 +207,23 @@ public class RadioReference
|
||||
{
|
||||
mLog.error("Radio Reference Test Connection Failed - user [" + userName + "] error [" + rre.getMessage() + "]");
|
||||
|
||||
if(rre.getErrorType() == RadioReferenceException.ErrorType.AUTHENTICATION)
|
||||
if (rre.hasFault())
|
||||
{
|
||||
return LoginStatus.INVALID_LOGIN;
|
||||
}
|
||||
Fault fault = rre.getFault();
|
||||
|
||||
throw rre;
|
||||
if (fault.getFaultCode() != null && fault.getFaultCode().contentEquals("AUTH"))
|
||||
{
|
||||
return LoginStatus.INVALID_LOGIN;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw rre;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw rre;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,27 +274,47 @@ public class RadioReference
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in with the current credentials on the calling thread, then updates the login status and the properties on
|
||||
* the FX thread, unless a newer login request has started in the meantime.
|
||||
* @param request number of this login request
|
||||
* @param result to complete with the login status once the properties are updated
|
||||
* Login with credentials, and update properties based on the login status
|
||||
*/
|
||||
private void login(long request, CompletableFuture<LoginStatus> result)
|
||||
private void login()
|
||||
{
|
||||
UserInfo userInfo = null;
|
||||
LoginStatus loginStatus;
|
||||
|
||||
try
|
||||
{
|
||||
userInfo = getService().getUserInfo();
|
||||
loginStatus = userInfo != null ? CheckExpDate(userInfo.getExpirationDate()) : LoginStatus.UNKNOWN;
|
||||
UserInfo userInfo = getService().getUserInfo();
|
||||
|
||||
if(userInfo != null)
|
||||
{
|
||||
accountExpiresProperty().setValue(userInfo.getExpirationDate());
|
||||
mLoginStatus = CheckExpDate(userInfo.getExpirationDate());
|
||||
}
|
||||
else
|
||||
{
|
||||
accountExpiresProperty().setValue(null);
|
||||
mLoginStatus = LoginStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
if(mLoginStatus == LoginStatus.VALID_PREMIUM)
|
||||
{
|
||||
availableProperty().set(true);
|
||||
premiumAccountProperty().set(true);
|
||||
}
|
||||
else if (mLoginStatus == LoginStatus.EXPIRED_PREMIUM)
|
||||
{
|
||||
availableProperty().set(true);
|
||||
premiumAccountProperty().set(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
availableProperty().set(false);
|
||||
premiumAccountProperty().set(false);
|
||||
}
|
||||
|
||||
if(userInfo != null)
|
||||
{
|
||||
mLog.info("Radio Reference Account - User:" + userInfo.getUserName() +
|
||||
" Expires:" + userInfo.getExpirationDate() +
|
||||
" Login Status:" + loginStatus +
|
||||
" Premium Access Enabled:" + (loginStatus == LoginStatus.VALID_PREMIUM));
|
||||
" Login Status:" + mLoginStatus +
|
||||
" Premium Access Enabled:" + premiumAccountProperty().get());
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -432,32 +323,12 @@ public class RadioReference
|
||||
}
|
||||
catch(RadioReferenceException rre)
|
||||
{
|
||||
loginStatus = rre.getErrorType() == RadioReferenceException.ErrorType.AUTHENTICATION ?
|
||||
LoginStatus.INVALID_LOGIN : LoginStatus.UNKNOWN;
|
||||
accountExpiresProperty().setValue(null);
|
||||
mLoginStatus = LoginStatus.UNKNOWN;
|
||||
availableProperty().set(false);
|
||||
premiumAccountProperty().set(false);
|
||||
mLog.error("Error checking radio reference premium account status. Disabling premium access. Error: " + rre.getMessage());
|
||||
}
|
||||
catch(RuntimeException re)
|
||||
{
|
||||
//Always update the properties and complete the result, whatever the error
|
||||
loginStatus = LoginStatus.UNKNOWN;
|
||||
mLog.error("Error checking radio reference premium account status. Disabling premium access.", re);
|
||||
}
|
||||
|
||||
final String expirationDate = userInfo != null ? userInfo.getExpirationDate() : null;
|
||||
final LoginStatus status = loginStatus;
|
||||
|
||||
Platform.runLater(() -> {
|
||||
//A newer login request supersedes this one
|
||||
if(mLoginRequest.get() == request)
|
||||
{
|
||||
mLoginStatus = status;
|
||||
accountExpiresProperty().setValue(expirationDate);
|
||||
availableProperty().set(status == LoginStatus.VALID_PREMIUM || status == LoginStatus.EXPIRED_PREMIUM);
|
||||
premiumAccountProperty().set(status == LoginStatus.VALID_PREMIUM);
|
||||
}
|
||||
|
||||
result.complete(status);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,36 +346,28 @@ public class RadioReference
|
||||
* Authorization Information object for use with radio reference service
|
||||
* @return instance or null if the user credentials are not set
|
||||
*/
|
||||
private synchronized AuthorizationInformation getAuthorizationInformation()
|
||||
private AuthorizationInformation getAuthorizationInformation()
|
||||
{
|
||||
return mAuthorizationInformation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the credentials and logs in to the radio reference service in the background. The properties are
|
||||
* updated on the FX thread, so the JavaFX toolkit must be running. Can be called from any thread.
|
||||
* @param authorizationInformation credentials
|
||||
* @return future that completes with the login status, on the FX thread, once the properties are updated
|
||||
* Applies the credentials and attempts to login to the radio reference service
|
||||
* @param authorizationInformation
|
||||
*/
|
||||
public CompletableFuture<LoginStatus> setAuthorizationInformation(AuthorizationInformation authorizationInformation)
|
||||
public void setAuthorizationInformation(AuthorizationInformation authorizationInformation)
|
||||
{
|
||||
synchronized(this)
|
||||
mAuthorizationInformation = authorizationInformation;
|
||||
mUserName.setValue(authorizationInformation.getUserName());
|
||||
mPassword.setValue(authorizationInformation.getPassword());
|
||||
|
||||
//Set the service to null so that the next call to getService() will recreate it
|
||||
if(mRadioReferenceService != null)
|
||||
{
|
||||
mAuthorizationInformation = authorizationInformation;
|
||||
//Set the service to null so that the next call to getService() will recreate it with the new credentials
|
||||
mRadioReferenceService = null;
|
||||
}
|
||||
|
||||
long request = mLoginRequest.incrementAndGet();
|
||||
CompletableFuture<LoginStatus> result = new CompletableFuture<>();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
mUserName.setValue(authorizationInformation.getUserName());
|
||||
mPassword.setValue(authorizationInformation.getPassword());
|
||||
});
|
||||
|
||||
ThreadPool.CACHED.execute(() -> login(request, result));
|
||||
return result;
|
||||
login();
|
||||
}
|
||||
|
||||
public static void main(String[] args)
|
||||
@@ -524,9 +387,7 @@ public class RadioReference
|
||||
credentials = getAuthorizatonInformation(username, password);
|
||||
}
|
||||
|
||||
//Login updates the properties on the FX thread
|
||||
Platform.startup(() -> {});
|
||||
radioReference.setAuthorizationInformation(credentials).join();
|
||||
radioReference.setAuthorizationInformation(credentials);
|
||||
|
||||
if(radioReference.availableProperty().get())
|
||||
{
|
||||
@@ -577,7 +438,5 @@ public class RadioReference
|
||||
mLog.error("Error", rre);
|
||||
}
|
||||
}
|
||||
|
||||
Platform.exit();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user