Author SHA1 Message Date
Rsclub2_2 e2a327c730 Pipelines back to 1ce468145c 2026-06-30 14:49:15 +02:00
Rsclub2_2 521ea18782 Fix Pipelines Again 2026-06-30 14:39:11 +02:00
Marc Fröhlich 6afcee8df5 Fix #39 and JDK 21 Upgrade an Grid Square Coloring
* Fixed Map: leaflet rendering in Java 21 Webview (disabling 3D in leaflet)

* Added grid square coloring function and fixed bug of message categorizing after entering the chat and not selected a chatmember

---------

Authored-by: Marc Froehlich <praktimarc@gmail.com>
2026-06-30 14:36:20 +02:00
Rsclub2_2 1ce468145c i dont know any message for this. Still would like that audio will work in Flatpak 2026-06-30 00:17:31 +02:00
Rsclub2_2 2f4aac8cf7 fix CI/CD Java 21 with more upgrade to JDK21 2026-06-30 00:02:03 +02:00
Rsclub2_2 94a5f6a9f7 hopefully fix CI/CD 2026-06-29 23:44:33 +02:00
Rsclub2_2 3070d03893 JavaFX21 2026-06-29 23:35:01 +02:00
Rsclub2_2 a53e6a420f fix Pacman Package and try to fix audio.
Audio did not work, but better debug handling.
2026-06-29 23:27:29 +02:00
Rsclub2_2 57deda4b94 Flatpak Audio 2026-06-29 22:06:47 +02:00
Rsclub2_2 3632a0fcc9 Options do not exist, fix 2026-06-29 21:37:00 +02:00
Rsclub2_2 ff6b8b75a6 JavaFX Media Build Process to fix Problems with Packaging
thx DB2DY for the info.

Dependencies for native Linux Packages added.
2026-06-29 21:28:37 +02:00
Rsclub2_2 eee1594d30 Flatpak add Filesystem for persistent Storage 2026-06-29 17:49:52 +02:00
Rsclub2_2andMarc Froehlich ba3ef41fa0 Reduced opentopo api calls // added 4 char locator new-filter // added new band and tropo filter and priority sorter // decreased map load (#43)
* Added a map to show where other stn are // refactored message adding to tables for performance, max 30.000 msg now

* debugging map failure

* debugging map failure

* debugging map failure

* fix Pipeline Modules n map Linux

* changed UI design to save some space. Made the UI more reactive for smaller screens. Maximized resolution at startup to the possible maximum of the current display of the user

* Docs added for new Features

* Added filters and sorters for Tropo reachability, AS availability and new big fields / locators. Added reachability band selector for tropo calculation based on a choosen band

* Fixed worked-23cm-tag when working with dxlog/n1mm logger

* delete not needed files from repo

* Reduced opentopo api calls // added 4 char locator new-filter // added new band and tropo filter and priority sorter // decreased map load

---------

Co-authored-by: Marc Froehlich <praktimarc@gmail.com>
2026-06-28 19:29:25 +02:00
Rsclub2_2 631b661265 fix flatpak key again. 2026-06-27 21:11:13 +02:00
Rsclub2_2 42d397ffad feat(flatpak): multi-OSTree pipeline — nightly, beta, stable
fix(flatpak): regenerate summary in publish jobs, add flatpak tooling + gpg key

Author: Philipp Wagner <philipp@wagnersnetz.de>
2026-06-27 20:19:01 +02:00
Rsclub2_2andMarc Froehlich 8413a1296d Feature/map view and filters (#40)
* Added a map to show where other stn are // refactored message adding to tables for performance, max 30.000 msg now

* debugging map failure

* debugging map failure

* debugging map failure

* fix Pipeline Modules n map Linux

---------

Co-authored-by: Marc Froehlich <praktimarc@gmail.com>
2026-06-23 01:12:02 +02:00
24 changed files with 1908 additions and 530 deletions
+150 -38
View File
@@ -12,7 +12,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions: permissions:
contents: read contents: write
packages: write packages: write
jobs: jobs:
@@ -34,11 +34,11 @@ jobs:
Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha" Add-Content -Path $env:GITHUB_ENV -Value "SHORT_SHA=$shortSha"
Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha" Add-Content -Path $env:GITHUB_ENV -Value "ASSET_BASENAME=praktiKST-$version-$shortSha"
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install WiX Toolset - name: Install WiX Toolset
shell: pwsh shell: pwsh
@@ -89,11 +89,11 @@ jobs:
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
@@ -169,11 +169,11 @@ jobs:
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install packaging dependencies - name: Install packaging dependencies
run: | run: |
@@ -199,6 +199,7 @@ jobs:
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist --dest dist
DEB="$(ls dist/*.deb | head -n 1)" DEB="$(ls dist/*.deb | head -n 1)"
if [ -z "$DEB" ]; then if [ -z "$DEB" ]; then
@@ -227,11 +228,11 @@ jobs:
SHORT_SHA="${GITHUB_SHA::7}" SHORT_SHA="${GITHUB_SHA::7}"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install packaging dependencies - name: Install packaging dependencies
run: | run: |
@@ -257,6 +258,7 @@ jobs:
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist --dest dist
RPM="$(ls dist/*.rpm | head -n 1)" RPM="$(ls dist/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then if [ -z "$RPM" ]; then
@@ -287,11 +289,11 @@ jobs:
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install packaging dependencies - name: Install packaging dependencies
run: | run: |
@@ -346,21 +348,42 @@ jobs:
if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then
cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png" cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
fi fi
INSTALLED_SIZE=$(du -sk "$PKGROOT" | cut -f1) INSTALLED_SIZE=$(du -sb "$PKGROOT" | cut -f1)
cat > "$PKGROOT/.PKGINFO" << EOF BUILDDATE=$(date +%s)
pkgname = kst4contest WORKFLOW_SHA256=$(sha256sum "$GITHUB_WORKSPACE/.github/workflows/nightly-artifacts.yml" | awk '{print $1}')
pkgbase = kst4contest {
pkgver = ${PKGVER}-1 echo "pkgname = kst4contest"
pkgdesc = KST4Contest amateur radio contest logger echo "pkgbase = kst4contest"
url = https://github.com/${{ github.repository }} echo "xdata = pkgtype=pkg"
builddate = $(date +%s) echo "pkgver = ${PKGVER}-1"
packager = GitHub Actions echo "pkgdesc = KST4Contest amateur radio contest logger"
size = ${INSTALLED_SIZE} echo "url = https://github.com/${{ github.repository }}"
arch = ${ARCH} echo "builddate = ${BUILDDATE}"
license = custom echo "packager = GitHub Actions"
depend = java-runtime echo "size = ${INSTALLED_SIZE}"
EOF echo "arch = ${ARCH}"
tar --zstd -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" . echo "license = custom"
echo "depend = java-runtime"
echo "depend = gst-plugins-base"
echo "depend = gst-plugins-good"
} > "$PKGROOT/.PKGINFO"
{
echo "format = 2"
echo "pkgname = kst4contest"
echo "pkgbase = kst4contest"
echo "pkgver = ${PKGVER}-1"
echo "pkgarch = ${ARCH}"
echo "pkgbuild_sha256sum = ${WORKFLOW_SHA256}"
echo "packager = GitHub Actions"
echo "builddate = ${BUILDDATE}"
echo "builddir = /build"
echo "startdir = /build"
echo "buildtool = makepkg"
echo "buildtoolver = 7.0.0-1-x86_64"
echo "buildenv = !distcc !color !ccache check !sign"
echo "options = !strip docs libtool staticlibs emptydirs zipman purge !debug !lto"
} > "$PKGROOT/.BUILDINFO"
tar --zstd --transform 's|^\./||' -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
- name: Upload Arch Linux artifact - name: Upload Arch Linux artifact
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
@@ -385,11 +408,11 @@ jobs:
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
@@ -428,14 +451,18 @@ jobs:
finish-args: finish-args:
- --socket=wayland - --socket=wayland
- --socket=x11 - --socket=x11
- --socket=pulseaudio
- --share=network - --share=network
- --share=ipc - --share=ipc
- --device=dri - --device=dri
- --filesystem=~/.praktiKST
- --env=ALSA_CONFIG_PATH=/app/share/alsa/asound.conf
modules: modules:
- name: kst4contest - name: kst4contest
buildsystem: simple buildsystem: simple
build-commands: build-commands:
- install -d /app/lib/KST4Contest /app/bin /app/share/applications - install -d /app/lib/KST4Contest /app/bin /app/share/applications /app/share/alsa
- printf '@include /usr/share/alsa/alsa.conf\npcm.!default { type pulse }\nctl.!default { type pulse }\n' > /app/share/alsa/asound.conf
- cp -a . /app/lib/KST4Contest/ - cp -a . /app/lib/KST4Contest/
- printf '#!/bin/sh\nexec /app/lib/KST4Contest/bin/KST4Contest "$@"\n' > /app/bin/KST4Contest - printf '#!/bin/sh\nexec /app/lib/KST4Contest/bin/KST4Contest "$@"\n' > /app/bin/KST4Contest
- chmod 755 /app/bin/KST4Contest - chmod 755 /app/bin/KST4Contest
@@ -453,18 +480,103 @@ jobs:
path: flatpak-src/KST4Contest path: flatpak-src/KST4Contest
EOF EOF
- name: Build Flatpak bundle - name: Import Flatpak signing key
run: |
echo "${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}" | gpg --batch --import
FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')
echo "FLATPAK_GPG_KEY_ID=$FLATPAK_GPG_KEY_ID" >> "$GITHUB_ENV"
- name: Build Flatpak repo (nightly)
run: | run: |
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
flatpak build-export target/flatpak-repo target/flatpak-build flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build nightly
flatpak build-bundle target/flatpak-repo "dist/${ASSET_BASENAME}-linux-x86_64.flatpak" de.x08.KST4Contest flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
- name: Upload Flatpak artifact - name: Create flatpakref (nightly)
run: |
REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > "dist/de.x08.KST4Contest.nightly.flatpakref" << EOF
[Flatpak Ref]
Name=de.x08.KST4Contest
Branch=nightly
Title=KST4Contest (Nightly) ON4KST Chat Client
Url=${PAGES_URL}
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
GPGKey=${GPG_KEY_B64}
IsRuntime=false
EOF
- name: Upload flatpakref
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
with: with:
name: linux-flatpak name: flatpakref
path: dist/KST4Contest-*-linux-x86_64.flatpak path: dist/de.x08.KST4Contest.nightly.flatpakref
retention-days: 14
- name: Upload Flatpak OSTree repo
uses: actions/upload-artifact@v4.3.4
with:
name: flatpak-ostree-repo
path: target/flatpak-repo/
publish-flatpak-repo:
name: Publish Flatpak OSTree Repo (nightly)
runs-on: ubuntu-latest
needs: build-flatpak
steps:
- name: Install Flatpak tooling
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends flatpak
- name: Import Flatpak signing key
run: |
echo "${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}" | gpg --batch --import
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
- name: Download OSTree repo artifact
uses: actions/download-artifact@v4.1.3
with:
name: flatpak-ostree-repo
path: flatpak-ostree-repo/
- name: Checkout existing flatpak-repo branch
uses: actions/checkout@v4.1.7
with:
ref: flatpak-repo
path: existing-flatpak-repo
- name: Merge nightly build into flatpak-repo
run: |
cd existing-flatpak-repo
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
# Copy the new OSTree build into the repo
rsync -a ../flatpak-ostree-repo/ ./
# Regenerate summary with all branches
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > kst4contest.flatpakrepo << EOF
[Flatpak Repo]
Title=KST4Contest
Url=${PAGES_URL}
Homepage=https://github.com/${GITHUB_REPOSITORY}
Comment=KST4Contest ON4KST Chat Client for VHF/UHF contests
GPGKey=${GPG_KEY_B64}
EOF
# Stage all changes (new/updated refs, summary, objects in OSTree)
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "Nightly flatpak: $(echo ${{ github.sha }} | cut -c1-7)"
git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git HEAD:flatpak-repo
fi
build-macos-dmg: build-macos-dmg:
name: Build macOS DMG (${{ matrix.os }}) name: Build macOS DMG (${{ matrix.os }})
@@ -487,11 +599,11 @@ jobs:
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
echo "ARCH=$ARCH" >> "$GITHUB_ENV" echo "ARCH=$ARCH" >> "$GITHUB_ENV"
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
+3 -3
View File
@@ -11,18 +11,18 @@ env:
jobs: jobs:
compile: compile:
name: Compile (Java 17) name: Compile (Java 21)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
+107 -44
View File
@@ -22,11 +22,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install WiX Toolset - name: Install WiX Toolset
shell: pwsh shell: pwsh
@@ -70,11 +70,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
@@ -143,11 +143,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install packaging dependencies - name: Install packaging dependencies
run: | run: |
@@ -173,6 +173,7 @@ jobs:
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
--dest dist --dest dist
DEB="$(ls dist/*.deb | head -n 1)" DEB="$(ls dist/*.deb | head -n 1)"
if [ -z "$DEB" ]; then if [ -z "$DEB" ]; then
@@ -194,11 +195,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install packaging dependencies - name: Install packaging dependencies
run: | run: |
@@ -224,6 +225,7 @@ jobs:
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
--dest dist --dest dist
RPM="$(ls dist/*.rpm | head -n 1)" RPM="$(ls dist/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then if [ -z "$RPM" ]; then
@@ -245,11 +247,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Install packaging dependencies - name: Install packaging dependencies
run: | run: |
@@ -304,21 +306,42 @@ jobs:
if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then
cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png" cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
fi fi
INSTALLED_SIZE=$(du -sk "$PKGROOT" | cut -f1) INSTALLED_SIZE=$(du -sb "$PKGROOT" | cut -f1)
cat > "$PKGROOT/.PKGINFO" << EOF BUILDDATE=$(date +%s)
pkgname = kst4contest WORKFLOW_SHA256=$(sha256sum "$GITHUB_WORKSPACE/.github/workflows/tagged-release.yml" | awk '{print $1}')
pkgbase = kst4contest {
pkgver = ${PKGVER}-1 echo "pkgname = kst4contest"
pkgdesc = KST4Contest amateur radio contest logger echo "pkgbase = kst4contest"
url = https://github.com/${{ github.repository }} echo "xdata = pkgtype=pkg"
builddate = $(date +%s) echo "pkgver = ${PKGVER}-1"
packager = GitHub Actions echo "pkgdesc = KST4Contest amateur radio contest logger"
size = ${INSTALLED_SIZE} echo "url = https://github.com/${{ github.repository }}"
arch = ${ARCH} echo "builddate = ${BUILDDATE}"
license = custom echo "packager = GitHub Actions"
depend = java-runtime echo "size = ${INSTALLED_SIZE}"
EOF echo "arch = ${ARCH}"
tar --zstd -cf "dist/KST4Contest-${{ github.ref_name }}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" . echo "license = custom"
echo "depend = java-runtime"
echo "depend = gst-plugins-base"
echo "depend = gst-plugins-good"
} > "$PKGROOT/.PKGINFO"
{
echo "format = 2"
echo "pkgname = kst4contest"
echo "pkgbase = kst4contest"
echo "pkgver = ${PKGVER}-1"
echo "pkgarch = ${ARCH}"
echo "pkgbuild_sha256sum = ${WORKFLOW_SHA256}"
echo "packager = GitHub Actions"
echo "builddate = ${BUILDDATE}"
echo "builddir = /build"
echo "startdir = /build"
echo "buildtool = makepkg"
echo "buildtoolver = 7.0.0-1-x86_64"
echo "buildenv = !distcc !color !ccache check !sign"
echo "options = !strip docs libtool staticlibs emptydirs zipman purge !debug !lto"
} > "$PKGROOT/.BUILDINFO"
tar --zstd --transform 's|^\./||' -cf "dist/KST4Contest-${{ github.ref_name }}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
- name: Upload Arch Linux artifact - name: Upload Arch Linux artifact
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
@@ -334,11 +357,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
@@ -377,14 +400,18 @@ jobs:
finish-args: finish-args:
- --socket=wayland - --socket=wayland
- --socket=x11 - --socket=x11
- --socket=pulseaudio
- --share=network - --share=network
- --share=ipc - --share=ipc
- --device=dri - --device=dri
- --filesystem=~/.praktiKST
- --env=ALSA_CONFIG_PATH=/app/share/alsa/asound.conf
modules: modules:
- name: kst4contest - name: kst4contest
buildsystem: simple buildsystem: simple
build-commands: build-commands:
- install -d /app/lib/KST4Contest /app/bin /app/share/applications - install -d /app/lib/KST4Contest /app/bin /app/share/applications /app/share/alsa
- printf '@include /usr/share/alsa/alsa.conf\npcm.!default { type pulse }\nctl.!default { type pulse }\n' > /app/share/alsa/asound.conf
- cp -a . /app/lib/KST4Contest/ - cp -a . /app/lib/KST4Contest/
- printf '#!/bin/sh\nexec /app/lib/KST4Contest/bin/KST4Contest "$@"\n' > /app/bin/KST4Contest - printf '#!/bin/sh\nexec /app/lib/KST4Contest/bin/KST4Contest "$@"\n' > /app/bin/KST4Contest
- chmod 755 /app/bin/KST4Contest - chmod 755 /app/bin/KST4Contest
@@ -408,10 +435,18 @@ jobs:
FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}') FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')
echo "FLATPAK_GPG_KEY_ID=$FLATPAK_GPG_KEY_ID" >> "$GITHUB_ENV" echo "FLATPAK_GPG_KEY_ID=$FLATPAK_GPG_KEY_ID" >> "$GITHUB_ENV"
- name: Determine OSTree branch
run: |
if [[ "${{ github.ref_name }}" == beta-* ]]; then
echo "OSTREE_BRANCH=beta" >> "$GITHUB_ENV"
else
echo "OSTREE_BRANCH=stable" >> "$GITHUB_ENV"
fi
- name: Build Flatpak repo - name: Build Flatpak repo
run: | run: |
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build stable flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build "$OSTREE_BRANCH"
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
- name: Create flatpakref - name: Create flatpakref
@@ -419,11 +454,11 @@ jobs:
REPO_NAME="${GITHUB_REPOSITORY#*/}" REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/" PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0) GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > "dist/de.x08.KST4Contest.flatpakref" << EOF cat > "dist/de.x08.KST4Contest.${OSTREE_BRANCH}.flatpakref" << EOF
[Flatpak Ref] [Flatpak Ref]
Name=de.x08.KST4Contest Name=de.x08.KST4Contest
Branch=stable Branch=${OSTREE_BRANCH}
Title=KST4Contest ON4KST Chat Client Title=KST4Contest (${{ startsWith(github.ref_name, 'beta-') && 'Beta' || 'Release' }}) ON4KST Chat Client
Url=${PAGES_URL} Url=${PAGES_URL}
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
GPGKey=${GPG_KEY_B64} GPGKey=${GPG_KEY_B64}
@@ -434,7 +469,7 @@ jobs:
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
with: with:
name: flatpakref name: flatpakref
path: dist/de.x08.KST4Contest.flatpakref path: dist/de.x08.KST4Contest.*.flatpakref
- name: Upload Flatpak OSTree repo - name: Upload Flatpak OSTree repo
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.4
@@ -453,11 +488,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
@@ -576,32 +611,60 @@ jobs:
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf
publish-flatpak-repo: publish-flatpak-repo:
name: Publish Flatpak OSTree Repo to GitHub Pages name: Publish Flatpak OSTree Repo (${{ github.ref_name }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-flatpak needs: build-flatpak
steps: steps:
- name: Install Flatpak tooling
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends flatpak
- name: Import Flatpak signing key
run: |
echo "${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}" | gpg --batch --import
echo "FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')" >> "$GITHUB_ENV"
- name: Download OSTree repo artifact - name: Download OSTree repo artifact
uses: actions/download-artifact@v4.1.3 uses: actions/download-artifact@v4.1.3
with: with:
name: flatpak-ostree-repo name: flatpak-ostree-repo
path: flatpak-ostree-repo/ path: flatpak-ostree-repo/
- name: Download flatpakref - name: Checkout existing flatpak-repo branch
uses: actions/download-artifact@v4.1.3 uses: actions/checkout@v4.1.7
with: with:
name: flatpakref ref: flatpak-repo
path: flatpak-ostree-repo/ path: existing-flatpak-repo
- name: Push to flatpak-repo branch - name: Merge build into flatpak-repo
run: | run: |
cd flatpak-ostree-repo cd existing-flatpak-repo
git init
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
rsync -a ../flatpak-ostree-repo/ ./
# Regenerate summary with all branches (not just the one just built)
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" .
# Generate .flatpakrepo with embedded GPG key so users can do remote-add without errors
REPO_NAME="${GITHUB_REPOSITORY#*/}"
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
cat > kst4contest.flatpakrepo << EOF
[Flatpak Repo]
Title=KST4Contest
Url=${PAGES_URL}
Homepage=https://github.com/${GITHUB_REPOSITORY}
Comment=KST4Contest ON4KST Chat Client for VHF/UHF contests
GPGKey=${GPG_KEY_B64}
EOF
git add -A git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "Flatpak repo: ${{ github.ref_name }}" git commit -m "Flatpak repo: ${{ github.ref_name }}"
git push --force https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git HEAD:flatpak-repo git push https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git HEAD:flatpak-repo
fi
release-tag: release-tag:
name: Publish Tagged Release name: Publish Tagged Release
-2
View File
@@ -1,2 +0,0 @@
S53MM
PA9R
+37 -2
View File
@@ -116,12 +116,47 @@ Die Datei `de.x08.KST4Contest.flatpakref` aus dem [aktuellen Release](https://gi
flatpak install de.x08.KST4Contest.flatpakref flatpak install de.x08.KST4Contest.flatpakref
``` ```
Oder den Remote manuell hinzufügen: Oder den Remote manuell hinzufügen (empfohlen für Nightly/Beta-Zugriff):
```bash ```bash
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/ flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
flatpak install kst4contest de.x08.KST4Contest flatpak install kst4contest de.x08.KST4Contest
``` ```
#### Flatpak-Kanäle (nightly / beta / stable)
Das Flatpak-Repository enthält drei Kanäle, die alle im selben Remote liegen:
| Kanal | Inhalt | Wird gebaut bei |
|---|---|---|
| `stable` | Aktuelle stabile Version | Jedem normalen Release-Tag |
| `beta` | Vorab-Version | Tags mit `beta-` Präfix |
| `nightly` | Entwicklungsstand (main-Branch) | Täglich / jedem Push auf main |
Remote einmalig hinzufügen, dann den gewünschten Kanal installieren:
```bash
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
# Stable (Standard)
flatpak install kst4contest de.x08.KST4Contest
# Beta
flatpak install kst4contest de.x08.KST4Contest//beta
# Nightly
flatpak install kst4contest de.x08.KST4Contest//nightly
```
> **Hinweis:** Flatpak erlaubt nur eine installierte Version pro App-ID gleichzeitig. Um den Kanal zu wechseln, zuerst die aktuelle Version deinstallieren:
> ```bash
> flatpak uninstall de.x08.KST4Contest
> flatpak install kst4contest de.x08.KST4Contest//nightly
> ```
Updates erfolgen wie gewohnt mit `flatpak update`.
Die `flatpakref`-Dateien für Beta und Stable liegen jeweils im entsprechenden [GitHub-Release](https://github.com/praktimarc/kst4contest/releases). Das Nightly-`flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) ist als Artifact im [GitHub Actions](https://github.com/praktimarc/kst4contest/actions)-Tab verfügbar (wird 14 Tage aufbewahrt) der Remote-Weg oben ist für Nightly daher meist einfacher.
### macOS ### macOS
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel). 1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
2. DMG-Datei öffnen. 2. DMG-Datei öffnen.
+37 -2
View File
@@ -116,12 +116,47 @@ Download `de.x08.KST4Contest.flatpakref` from the [latest release](https://githu
flatpak install de.x08.KST4Contest.flatpakref flatpak install de.x08.KST4Contest.flatpakref
``` ```
Or add the remote manually: Or add the remote manually (recommended for nightly/beta access):
```bash ```bash
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/ flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
flatpak install kst4contest de.x08.KST4Contest flatpak install kst4contest de.x08.KST4Contest
``` ```
#### Flatpak Channels (nightly / beta / stable)
The Flatpak repository provides three channels, all served from the same remote:
| Channel | Content | Built on |
|---|---|---|
| `stable` | Current stable version | Every normal release tag |
| `beta` | Pre-release version | Tags prefixed with `beta-` |
| `nightly` | Development build (main branch) | Every push to main / daily |
Add the remote once, then install the channel you want:
```bash
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
# Stable (default)
flatpak install kst4contest de.x08.KST4Contest
# Beta
flatpak install kst4contest de.x08.KST4Contest//beta
# Nightly
flatpak install kst4contest de.x08.KST4Contest//nightly
```
> **Note:** Flatpak only allows one installed version per App-ID at a time. To switch channels, uninstall the current version first:
> ```bash
> flatpak uninstall de.x08.KST4Contest
> flatpak install kst4contest de.x08.KST4Contest//nightly
> ```
Updates work as usual with `flatpak update`.
The `flatpakref` files for beta and stable are attached to their respective [GitHub Releases](https://github.com/praktimarc/kst4contest/releases). The nightly `flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) is available as an artifact on the [GitHub Actions](https://github.com/praktimarc/kst4contest/actions) tab (kept for 14 days) — for nightly builds, the manual remote approach above is usually more convenient.
### macOS ### macOS
1. Download the DMG file for your architecture (Apple Silicon or Intel). 1. Download the DMG file for your architecture (Apple Silicon or Intel).
2. Open the DMG file. 2. Open the DMG file.
+12 -27
View File
@@ -24,12 +24,12 @@
<launcher>${project.artifactId}</launcher> <launcher>${project.artifactId}</launcher>
<appName>${project.artifactId}</appName> <appName>${project.artifactId}</appName>
<main.class>kst4contest.view.Kst4ContestApplication</main.class> <main.class>kst4contest.view.Kst4ContestApplication</main.class>
<java.version>17</java.version> <java.version>21</java.version>
<required.maven.version>3.6.3</required.maven.version> <required.maven.version>3.6.3</required.maven.version>
<jar.filename>${project.artifactId}-${project.version}</jar.filename> <jar.filename>${project.artifactId}-${project.version}</jar.filename>
<!-- Dependency versions --> <!-- Dependency versions -->
<javafx.version>19.0.2.1</javafx.version> <javafx.version>21.0.5</javafx.version>
<jetbrains.annotations.version>24.0.1</jetbrains.annotations.version> <jetbrains.annotations.version>24.0.1</jetbrains.annotations.version>
<junit.version>5.10.1</junit.version> <junit.version>5.10.1</junit.version>
<lombok.version>1.18.44</lombok.version> <lombok.version>1.18.44</lombok.version>
@@ -50,8 +50,8 @@
<maven.wrapper.plugin>3.2.0</maven.wrapper.plugin> <maven.wrapper.plugin>3.2.0</maven.wrapper.plugin>
<moditect.maven.plugin>1.0.0.RC2</moditect.maven.plugin> <moditect.maven.plugin>1.0.0.RC2</moditect.maven.plugin>
<jpackage.maven.plugin>0.1.3</jpackage.maven.plugin> <jpackage.maven.plugin>0.1.3</jpackage.maven.plugin>
<maven.pmd.version>3.21.2</maven.pmd.version> <maven.pmd.version>3.28.0</maven.pmd.version>
<pmd.version>6.55.0</pmd.version> <pmd.version>7.17.0</pmd.version>
<codehaus.version.plugin>2.16.1</codehaus.version.plugin> <codehaus.version.plugin>2.16.1</codehaus.version.plugin>
<javafx.maven.plugin>0.0.8</javafx.maven.plugin> <javafx.maven.plugin>0.0.8</javafx.maven.plugin>
<spotbugs.maven.plugin>4.9.8.2</spotbugs.maven.plugin> <spotbugs.maven.plugin>4.9.8.2</spotbugs.maven.plugin>
@@ -93,6 +93,13 @@
<version>${javafx.version}</version> <version>${javafx.version}</version>
</dependency> </dependency>
<!-- JLayer: pure-Java MP3 decoder, used instead of JavaFX Media for Flatpak audio compatibility -->
<dependency>
<groupId>javazoom</groupId>
<artifactId>jlayer</artifactId>
<version>1.0.1</version>
</dependency>
<!-- SQLite --> <!-- SQLite -->
<dependency> <dependency>
<groupId>org.xerial</groupId> <groupId>org.xerial</groupId>
@@ -280,30 +287,7 @@
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId> <artifactId>maven-pmd-plugin</artifactId>
<version>${maven.pmd.version}</version> <version>${maven.pmd.version}</version>
<dependencies>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-core</artifactId>
<version>${pmd.version}</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-java</artifactId>
<version>${pmd.version}</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-javascript</artifactId>
<version>${pmd.version}</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-jsp</artifactId>
<version>${pmd.version}</version>
</dependency>
</dependencies>
<configuration> <configuration>
<sourceEncoding>${project.build.sourceEncoding}</sourceEncoding>
<minimumTokens>100</minimumTokens> <minimumTokens>100</minimumTokens>
<targetJdk>${java.version}</targetJdk> <targetJdk>${java.version}</targetJdk>
<linkXRef>false</linkXRef> <linkXRef>false</linkXRef>
@@ -436,6 +420,7 @@
<addmodule>javafx.graphics</addmodule> <addmodule>javafx.graphics</addmodule>
<addmodule>javafx.fxml</addmodule> <addmodule>javafx.fxml</addmodule>
<addmodule>javafx.web</addmodule> <addmodule>javafx.web</addmodule>
<addmodule>javafx.media</addmodule>
<addmodule>java.sql</addmodule> <addmodule>java.sql</addmodule>
<addmodule>java.net.http</addmodule> <addmodule>java.net.http</addmodule>
<addmodule>jdk.crypto.ec</addmodule> <addmodule>jdk.crypto.ec</addmodule>
@@ -1205,10 +1205,24 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
/**
* Notifies the UI that station-list derived values have changed.
*
* <p>This method may be called from parser threads, UDP listener threads and
* reachability worker threads. Therefore the listener is always invoked on the
* JavaFX application thread.</p>
*
* @param reason short debug reason for the UI log
*/
public void fireUserListUpdate(String reason) { public void fireUserListUpdate(String reason) {
if (statusListener != null) { if (statusListener == null) {
// Da UI Updates im JavaFX Thread passieren müssen, hier oder im Listener Platform.runLater nutzen return;
}
if (Platform.isFxApplicationThread()) {
statusListener.onUserListUpdated(reason); statusListener.onUserListUpdated(reason);
} else {
Platform.runLater(() -> statusListener.onUserListUpdated(reason));
} }
} }
@@ -2165,32 +2179,39 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
/** /**
* Returns true when the member's gross field is not worked on at least one enabled * Returns true when the member's four-character grid square has already been
* station band. This is the predicate behind the "new locator" filter. * worked on any band.
*
* <p>This method intentionally ignores the currently enabled own bands. The grid
* status in the user list is meant to be a simple and robust any-band indicator.</p>
* *
* @param member member to inspect * @param member member to inspect
* @return true if the station is still a new locator on any enabled band * @return true if the gross grid square was worked on any band
*/ */
public boolean isNewLocatorOnAnyEnabledBand(ChatMember member) { public boolean isGridSquareWorkedAny(ChatMember member) {
if (member == null || member.getQra() == null) { if (member == null || member.getQra() == null) {
return false; return false;
} }
EnumSet<Band> enabledBands = reachabilityService == null return workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
? getMyEnabledBandsFromPrefs(chatPreferences) }
: reachabilityService.getEnabledStationBands();
if (enabledBands.isEmpty()) { /**
* Returns true when the member's four-character grid square has not been worked
* on any band yet.
*
* <p>This is the predicate behind the optional "Only new grids" filter. It no
* longer depends on active station band settings.</p>
*
* @param member member to inspect
* @return true if the gross grid square is still new
*/
public boolean isNewGridSquare(ChatMember member) {
if (member == null || member.getQra() == null) {
return false; return false;
} }
for (Band band : enabledBands) { return !workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
if (!workedGrossFieldCache.isGrossFieldWorked(band, member.getQra())) {
return true;
}
}
return false;
} }
public long getCurrentEpochTime() { public long getCurrentEpochTime() {
@@ -2471,6 +2492,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return; return;
} }
rebuildWorkedGrossFieldCacheFromDatabase();
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase; HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -682,8 +682,7 @@ public class DBController {
String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore); String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore);
if (workedBandColumnName == null) { if (workedBandColumnName == null) {
System.out.println("[DBCtrl, Error]: unknown at which band the qso had been!"); return helper_updateWorkedAnyOnChatMember(chatMemberToStore);
return false;
} }
// String updateWorkedSql = // String updateWorkedSql =
@@ -717,6 +716,47 @@ public class DBController {
} }
} }
/**
* Updates only the global worked-any flag of a stored chatmember row.
*
* <p>This is used when an external logger confirms that a station was worked,
* but the software cannot reliably map the QSO to one of the persisted band
* columns. The UI status "x" is based on this global worked flag, so this method
* makes no-band or unsupported-band log entries persistent as worked-any.</p>
*
* @param chatMemberToStore chatmember that contains the worked call and optional locator
* @return true if an existing database row was updated
* @throws SQLException if the database write fails
*/
private synchronized boolean helper_updateWorkedAnyOnChatMember(ChatMember chatMemberToStore) throws SQLException {
if (chatMemberToStore == null
|| chatMemberToStore.getCallSignRaw() == null
|| chatMemberToStore.getCallSignRaw().isBlank()) {
return false;
}
String updateWorkedAnySql =
"UPDATE ChatMember SET worked = 1, "
+ "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, "
+ "lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedAnySql)) {
String qra = chatMemberToStore.getQra();
preparedStatement.setString(1, qra);
preparedStatement.setString(2, qra);
preparedStatement.setString(3, qra);
preparedStatement.setString(4, qra);
preparedStatement.setLong(5, System.currentTimeMillis());
preparedStatement.setString(6, chatMemberToStore.getCallSignRaw());
int affectedRows = preparedStatement.executeUpdate();
return affectedRows > 0;
}
}
/** /**
* Updates all not-QRV flags for a chatmember row. The method uses the normalized * Updates all not-QRV flags for a chatmember row. The method uses the normalized
* raw callsign and updates the timestamp so that automatic contest cleanup can * raw callsign and updates the timestamp so that automatic contest cleanup can
@@ -327,7 +327,8 @@ public class MessageBusManagementThread extends Thread {
// 1. Store in the new Map (for future context/history) // 1. Store in the new Map (for future context/history)
sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
client.getReachabilityService().ensureTropoMarginCalculated(sender, finalDetectedBand); // No automatic full terrain analysis here.
// The frequency is stored for later map/manual reachability requests.
//propagate known frequency to all instances of the same callsign (callRaw may exist multiple times) //propagate known frequency to all instances of the same callsign (callRaw may exist multiple times)
try { try {
@@ -336,7 +337,9 @@ public class MessageBusManagementThread extends Thread {
ChatMember cm = client.getLst_chatMemberList().get(idx); ChatMember cm = client.getLst_chatMemberList().get(idx);
if (cm != null && cm != sender) { if (cm != null && cm != sender) {
cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
client.getReachabilityService().ensureTropoMarginCalculated(cm, finalDetectedBand); } // No automatic full terrain analysis here.
// Avoids exhausting the online terrain API when many stations mention QRGs.
}
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage()); System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage());
@@ -588,7 +591,8 @@ public class MessageBusManagementThread extends Thread {
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) { if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
this.client.getLst_chatMemberList().add(newMember); //the own call will not be in the list this.client.getLst_chatMemberList().add(newMember); //the own call will not be in the list
this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember); // this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
// Reachability is calculated on demand only: map click, selected station, or manual request.
} }
@@ -634,7 +638,7 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember); newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.getLst_chatMemberList().add(newMember); this.client.getLst_chatMemberList().add(newMember);
this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember); // this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember); this.client.getDbHandler().storeChatMember(newMember);
} }
@@ -1,4 +1,12 @@
package kst4contest.controller; package kst4contest.controller;
import kst4contest.view.map.MapCallsignRawSnapshot;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
import javafx.application.Platform; import javafx.application.Platform;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
@@ -7,12 +15,12 @@ import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences; import kst4contest.model.ChatPreferences;
import kst4contest.view.map.GeometryOnlyPathAnalysisService; import kst4contest.view.map.GeometryOnlyPathAnalysisService;
import kst4contest.view.map.OpenMeteoTerrainProfileProvider; import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
import kst4contest.view.map.PathAnalysisRequest; import kst4contest.view.map.PathAnalysisRequest;
import kst4contest.view.map.PathAnalysisResult; import kst4contest.view.map.PathAnalysisResult;
import kst4contest.view.map.PathAnalysisService; import kst4contest.view.map.PathAnalysisService;
import kst4contest.view.map.PathGeometryUtils; import kst4contest.view.map.PathGeometryUtils;
import java.util.Comparator; import java.util.Comparator;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.Objects; import java.util.Objects;
@@ -21,26 +29,57 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadFactory;
import java.util.function.Consumer;
/** /**
* Background service for station reachability values used by the station table. * Central service for tropo/path reachability calculations.
* *
* <p>The service performs the expensive full path analysis off the JavaFX thread, * <p>This service is now the single calculation path for:
* stores the bidirectional SSB margin in the ChatMember, and leaves the UI with a * <ul>
* simple numeric value for sorting/filtering.</p> * <li>the station table Tropo column</li>
* <li>the Tropo filter/sorter</li>
* <li>the station map path-analysis detail panel</li>
* </ul>
*
* <p>The service always calculates a full {@link PathAnalysisResult}. When the
* result contains a usable link budget, the bidirectional SSB margin is copied
* into all matching {@link ChatMember} objects. The UI can then sort/filter by
* a simple number while the map still receives the full path result.</p>
*/ */
public final class ReachabilityService { public final class ReachabilityService {
private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
private final ChatController chatController; private final ChatController chatController;
private final PathAnalysisService pathAnalysisService; private final PathAnalysisService pathAnalysisService;
private final ExecutorService executor; private final ExecutorService executor;
/** /**
* Prevents duplicate jobs for the same logical station/band while a calculation * Prevents duplicate jobs for the same path while a calculation is already
* is already queued or running. * queued or running.
*/ */
private final Set<String> queuedCalculationKeys = ConcurrentHashMap.newKeySet(); private final Set<String> queuedCalculationKeys = ConcurrentHashMap.newKeySet();
/**
* Stores completed usable path-analysis results. Failed/no-budget results are
* not permanently cached so transient terrain/network problems can be retried.
*/
private final Map<String, PathAnalysisResult> pathAnalysisResultCache = new ConcurrentHashMap<>();
/**
* Temporary callback list for map requests waiting for an already running
* background calculation.
*/
private final Map<String, List<Consumer<PathAnalysisResult>>> pendingMapCallbacksByKey = new ConcurrentHashMap<>();
/**
* Prevents immediate retry loops for failed/no-budget analyses, especially
* when a TableView cell repeatedly asks for the same value.
*/
private final Map<String, Long> failedCalculationRetryAfterEpochMs = new ConcurrentHashMap<>();
public ReachabilityService(ChatController chatController) { public ReachabilityService(ChatController chatController) {
this.chatController = Objects.requireNonNull(chatController, "chatController"); this.chatController = Objects.requireNonNull(chatController, "chatController");
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider()); this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
@@ -57,8 +96,12 @@ public final class ReachabilityService {
} }
/** /**
* Ensures that a station/band SSB-margin value exists. Existing finite values * Ensures that a station/band SSB-margin value exists if it is already cached.
* and existing NaN failure markers are not recalculated automatically. *
* <p>This method deliberately does not start online terrain analysis anymore.
* It is safe to call from table cells and filters because it will not consume
* API quota. Full calculations are now started only by explicit map/manual
* requests.</p>
* *
* @param member chatmember to evaluate * @param member chatmember to evaluate
* @param band band to evaluate * @param band band to evaluate
@@ -68,36 +111,110 @@ public final class ReachabilityService {
return; return;
} }
if (member.hasTropoSsbMarginDb(band)) { if (member.hasFiniteTropoSsbMarginDb(band)) {
return; return;
} }
String calculationKey = buildCalculationKey(member, band); PathAnalysisRequest request = buildRequestForChatMember(member, band);
if (!queuedCalculationKeys.add(calculationKey)) { if (request == null) {
return; return;
} }
executor.submit(() -> { PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
double marginDb = Double.NaN; if (cachedResult != null) {
try { storePathAnalysisResultInChatMembers(member, band, cachedResult);
marginDb = calculateSsbMarginDb(member, band); }
} catch (Exception exception) {
System.out.println("[ReachabilityService, warning]: analysis failed for "
+ member.getCallSignRaw() + " @" + band + ": " + exception.getMessage());
} }
final double finalMarginDb = marginDb;
Runnable storeResult = () -> {
member.setTropoSsbMarginDb(band, finalMarginDb);
chatController.fireUserListUpdate("Reachability calculated");
};
if (Platform.isFxApplicationThread()) {
storeResult.run(); /**
} else { * Requests a full path analysis for the station map.
Platform.runLater(storeResult); *
* <p>This is the only normal automatic entry point for online/full terrain
* analysis. The map needs the full PathAnalysisResult, and the station table
* needs the SSB margin. Both are produced here through the same calculation.</p>
*
* @param member best matching ChatMember, may be null when only a map snapshot exists
* @param selectedSnapshot selected map snapshot
* @param fxCallback callback executed on the JavaFX thread
*/
public void requestPathAnalysisForMap(ChatMember member,
MapCallsignRawSnapshot selectedSnapshot,
Consumer<PathAnalysisResult> fxCallback) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
if (selectedSnapshot == null) {
dispatchFxCallback(fxCallback, PathAnalysisResult.waitingForSelection(ownLocator6));
return;
} }
});
String targetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
if (ownLocator6.length() != 6) {
dispatchFxCallback(fxCallback,
PathAnalysisResult.waitingForValidHomeLocator(ownLocator6, targetLocator6));
return;
}
if (!selectedSnapshot.hasUsablePosition()) {
dispatchFxCallback(fxCallback,
PathAnalysisResult.waitingForValidTarget(ownLocator6, targetLocator6));
return;
}
double analysisFrequencyMHz = PathGeometryUtils.resolveAnalysisFrequencyMHz(
selectedSnapshot.lastKnownFrequenciesByBand()
);
if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand);
}
Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
if (analysisBand == null) {
analysisBand = member == null ? Band.B_144 : resolveAutoBand(member);
}
PathAnalysisRequest request = buildRequest(
ownLocator6,
selectedSnapshot.callSignRaw(),
targetLocator6,
selectedSnapshot.latitudeDeg(),
selectedSnapshot.longitudeDeg(),
analysisFrequencyMHz
);
requestPathAnalysisAndStore(member, analysisBand, request, fxCallback);
}
/**
* Starts a full on-demand reachability calculation for one station row.
*
* <p>Use this for explicit operator actions only. It may call the online terrain
* API and therefore must not be used from TableView cell factories or automatic
* chat-join processing.</p>
*
* @param member station to calculate
* @param band selected reachability band
*/
public void calculateSelectedStationOnDemand(ChatMember member, Band band) {
if (member == null || band == null) {
return;
}
PathAnalysisRequest request = buildRequestForChatMember(member, band);
if (request == null) {
member.setTropoSsbMarginDb(band, Double.NaN);
chatController.fireUserListUpdate("Reachability unavailable");
return;
}
requestPathAnalysisAndStore(member, band, request, null);
} }
/** /**
@@ -161,42 +278,222 @@ public final class ReachabilityService {
executor.shutdownNow(); executor.shutdownNow();
} }
private double calculateSsbMarginDb(ChatMember member, Band band) { /**
* Starts or reuses one shared path-analysis calculation.
*
* @param member matching ChatMember, may be null for map-only snapshots
* @param band band under which the value is stored in ChatMember
* @param request full path-analysis request
* @param fxCallback optional map callback
*/
private void requestPathAnalysisAndStore(ChatMember member,
Band band,
PathAnalysisRequest request,
Consumer<PathAnalysisResult> fxCallback) {
if (request == null || band == null) {
return;
}
String calculationKey = buildCalculationKey(request);
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
if (cachedResult != null) {
storePathAnalysisResultInChatMembers(member, band, cachedResult);
dispatchFxCallback(fxCallback, cachedResult);
return;
}
Long retryAfterEpochMs = failedCalculationRetryAfterEpochMs.get(calculationKey);
if (retryAfterEpochMs != null && System.currentTimeMillis() < retryAfterEpochMs) {
dispatchFxCallback(fxCallback, createNoProfileResult(
request,
"Previous path analysis failed or the terrain API limit was reached. Retry is delayed briefly."
));
return;
}
addPendingCallback(calculationKey, fxCallback);
if (!queuedCalculationKeys.add(calculationKey)) {
return;
}
executor.submit(() -> {
PathAnalysisResult result;
try {
result = pathAnalysisService.analyze(request);
if (result == null) {
result = createNoProfileResult(request, "Path analysis returned no result.");
}
} catch (Exception exception) {
result = createNoProfileResult(
request,
"Path analysis failed: " + exception.getMessage()
);
}
boolean usableBudget = hasUsableLinkBudget(result);
if (usableBudget) {
pathAnalysisResultCache.put(calculationKey, result);
failedCalculationRetryAfterEpochMs.remove(calculationKey);
} else {
failedCalculationRetryAfterEpochMs.put(
calculationKey,
System.currentTimeMillis() + FAILED_ANALYSIS_RETRY_DELAY_MS
);
}
queuedCalculationKeys.remove(calculationKey);
PathAnalysisResult finalResult = result;
Runnable updateTask = () -> {
storePathAnalysisResultInChatMembers(member, band, finalResult);
dispatchAndClearPendingCallbacks(calculationKey, finalResult);
chatController.fireUserListUpdate("Reachability calculated");
};
if (Platform.isFxApplicationThread()) {
updateTask.run();
} else {
Platform.runLater(updateTask);
}
});
}
/**
* Builds a path-analysis request from a ChatMember row.
*
* @param member station row
* @param band selected reachability band
* @return request or null if locators are not usable
*/
private PathAnalysisRequest buildRequestForChatMember(ChatMember member, Band band) {
String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat()); String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
String targetLocator6 = normalizeLocator6(member.getQra()); String targetLocator6 = normalizeLocator6(member.getQra());
if (ownLocator6.length() != 6 || targetLocator6.length() != 6) { if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
return Double.NaN; return null;
} }
Location homeLocation = new Location(ownLocator6);
Location targetLocation = new Location(targetLocator6); Location targetLocation = new Location(targetLocator6);
double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band); double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
PathAnalysisRequest request = new PathAnalysisRequest( return buildRequest(
ownLocator6, ownLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
member.getCallSignRaw(), member.getCallSignRaw(),
targetLocator6, targetLocator6,
targetLocation.getLatitude().toDegrees(), targetLocation.getLatitude().toDegrees(),
targetLocation.getLongitude().toDegrees(), targetLocation.getLongitude().toDegrees(),
analysisFrequencyMHz
);
}
/**
* Builds the shared PathAnalysisRequest used by map and table/manual requests.
*/
private PathAnalysisRequest buildRequest(String ownLocator6,
String targetCallsignRaw,
String targetLocator6,
double targetLatitudeDeg,
double targetLongitudeDeg,
double analysisFrequencyMHz) {
Location homeLocation = new Location(ownLocator6);
return new PathAnalysisRequest(
ownLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
targetCallsignRaw,
targetLocator6,
targetLatitudeDeg,
targetLongitudeDeg,
analysisFrequencyMHz, analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(), chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(), chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR, PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings() chatController.getChatPreferences().buildPathLinkBudgetSettings()
); );
PathAnalysisResult result = pathAnalysisService.analyze(request);
if (result == null || result.linkBudgetSummary() == null || !result.linkBudgetSummary().hasUsableBudget()) {
return Double.NaN;
} }
return result.linkBudgetSummary().bidirectionalSsbMarginDb(); /**
* Stores the SSB margin from a completed analysis in all matching ChatMember
* objects. Matching uses raw callsign and locator.
*/
private void storePathAnalysisResultInChatMembers(ChatMember primaryMember,
Band band,
PathAnalysisResult result) {
if (band == null || result == null) {
return;
} }
double marginDb = hasUsableLinkBudget(result)
? result.linkBudgetSummary().bidirectionalSsbMarginDb()
: Double.NaN;
if (primaryMember != null) {
primaryMember.setTropoSsbMarginDb(band, marginDb);
}
String resultCallSignRaw = normalizeCallsignRaw(result.toCallsignRaw());
String resultLocator6 = normalizeLocator6(result.toLocator6());
for (ChatMember member : chatController.snapshotChatMembers()) {
if (member == null) {
continue;
}
String memberCallSignRaw = normalizeCallsignRaw(member.getCallSignRaw());
if (!resultCallSignRaw.isBlank() && !resultCallSignRaw.equals(memberCallSignRaw)) {
continue;
}
String memberLocator6 = normalizeLocator6(member.getQra());
if (!resultLocator6.isBlank() && !resultLocator6.equals(memberLocator6)) {
continue;
}
member.setTropoSsbMarginDb(band, marginDb);
}
}
/**
* Resolves the analysis frequency from a map snapshot first, because the map
* aggregates all visible ChatMember variants and often knows the best current
* frequency per band.
*
* @param member fallback member
* @param selectedSnapshot selected map snapshot
* @return analysis frequency in MHz
*/
private double resolveAnalysisFrequencyForSnapshot(ChatMember member, MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot != null) {
double snapshotFrequencyMHz =
PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand());
if (Double.isFinite(snapshotFrequencyMHz) && snapshotFrequencyMHz > 0.0) {
return snapshotFrequencyMHz;
}
}
Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
return resolveAnalysisFrequencyForBand(member, fallbackBand);
}
/**
* Resolves the analysis frequency for one member/band pair.
*
* <p>Preference order:
* <ol>
* <li>knownActiveBands frequency for the band</li>
* <li>current displayed QRG if it belongs to the same band</li>
* <li>band default frequency</li>
* </ol>
*/
private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) { private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
if (member != null && member.getKnownActiveBands() != null) { if (member != null && member.getKnownActiveBands() != null) {
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band); ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band);
@@ -207,19 +504,148 @@ public final class ReachabilityService {
} }
} }
if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) {
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue());
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
if (parsedBand == null || parsedBand == band) {
return parsedFrequencyMHz;
}
}
}
return band.getDefaultAnalysisFrequencyMHz(); return band.getDefaultAnalysisFrequencyMHz();
} }
private String buildCalculationKey(ChatMember member, Band band) { /**
String callSignRaw = member.getCallSignRaw() == null ? "" : member.getCallSignRaw(); * Adds a map callback to the pending callback list for one calculation key.
String locator = member.getQra() == null ? "" : member.getQra(); *
return callSignRaw.trim().toUpperCase() + "|" + locator.trim().toUpperCase() + "|" + band.name(); * @param calculationKey path key
* @param fxCallback callback to add
*/
private void addPendingCallback(String calculationKey, Consumer<PathAnalysisResult> fxCallback) {
if (fxCallback == null) {
return;
}
pendingMapCallbacksByKey
.computeIfAbsent(
calculationKey,
ignored -> Collections.synchronizedList(new ArrayList<>())
)
.add(fxCallback);
}
/**
* Dispatches and removes all callbacks waiting for one completed calculation.
*
* @param calculationKey path key
* @param result completed result
*/
private void dispatchAndClearPendingCallbacks(String calculationKey, PathAnalysisResult result) {
List<Consumer<PathAnalysisResult>> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
if (callbacks == null || callbacks.isEmpty()) {
return;
}
List<Consumer<PathAnalysisResult>> callbackSnapshot;
synchronized (callbacks) {
callbackSnapshot = new ArrayList<>(callbacks);
}
for (Consumer<PathAnalysisResult> callback : callbackSnapshot) {
dispatchFxCallback(callback, result);
}
}
/**
* Executes a map callback on the JavaFX application thread.
*
* @param fxCallback callback to execute
* @param result result to pass
*/
private void dispatchFxCallback(Consumer<PathAnalysisResult> fxCallback, PathAnalysisResult result) {
if (fxCallback == null) {
return;
}
if (Platform.isFxApplicationThread()) {
fxCallback.accept(result);
} else {
Platform.runLater(() -> fxCallback.accept(result));
}
}
/**
* Checks whether the result contains a usable link-budget summary.
*
* @param result path-analysis result
* @return true if SSB margin can be read
*/
private boolean hasUsableLinkBudget(PathAnalysisResult result) {
return result != null
&& result.linkBudgetSummary() != null
&& result.linkBudgetSummary().hasUsableBudget();
}
/**
* Creates a no-profile/no-budget result for failed service calculations.
*
* @param request original request
* @param statusText status shown in the map detail panel
* @return placeholder path result
*/
private PathAnalysisResult createNoProfileResult(PathAnalysisRequest request, String statusText) {
return PathAnalysisResult.noProfile(
"Reachability",
request.fromLocator6(),
request.toLocator6(),
request.toCallsignRaw(),
Double.NaN,
Double.NaN,
request.homeAntennaHeightMeters(),
request.targetAntennaHeightMeters(),
request.frequencyMHz(),
statusText
);
}
/**
* Builds a stable calculation key.
*
* <p>The key includes station, locators, frequency, antenna heights and link
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
*/
private String buildCalculationKey(PathAnalysisRequest request) {
return normalizeLocator6(request.fromLocator6())
+ "|"
+ normalizeCallsignRaw(request.toCallsignRaw())
+ "|"
+ normalizeLocator6(request.toLocator6())
+ "|"
+ String.format(Locale.US, "%.5f", request.toLatitudeDeg())
+ "|"
+ String.format(Locale.US, "%.5f", request.toLongitudeDeg())
+ "|"
+ String.format(Locale.US, "%.3f", request.frequencyMHz())
+ "|"
+ String.format(Locale.US, "%.1f", request.homeAntennaHeightMeters())
+ "|"
+ String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
+ "|"
+ request.linkBudgetSettings();
}
private String normalizeCallsignRaw(String callSignRaw) {
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
} }
private String normalizeLocator6(String locator) { private String normalizeLocator6(String locator) {
return locator == null ? "" : locator.trim().toUpperCase(); return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
} }
private static final class ReachabilityThreadFactory implements ThreadFactory { private static final class ReachabilityThreadFactory implements ThreadFactory {
@Override @Override
public Thread newThread(Runnable runnable) { public Thread newThread(Runnable runnable) {
@@ -2,6 +2,7 @@ package kst4contest.controller;
import javafx.application.Platform; import javafx.application.Platform;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.model.Band;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage; import kst4contest.model.ThreadStateMessage;
import kst4contest.view.GuiUtils; import kst4contest.view.GuiUtils;
@@ -286,6 +287,72 @@ public class ReadUDPByWintestThread extends Thread {
// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871)); // socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871));
// } // }
/**
* Resolves the project Band enum from Win-Test band IDs.
*
* <p>Only bands that exist in the current Band enum are returned. 50/70 MHz are
* still represented as worked flags in ChatMember, but they are not part of the
* current Reachability/New-Locator band enum.</p>
*
* @param bandId Win-Test band id from ADDQSO
* @return matching Band or null
*/
private Band helper_resolveBandFromWinTestBandId(String bandId) {
if (bandId == null) {
return null;
}
switch (bandId.trim()) {
case "12": return Band.B_144;
case "14": return Band.B_432;
case "16": return Band.B_1296;
case "17": return Band.B_2320;
case "18": return Band.B_3400;
case "19": return Band.B_5760;
case "20": return Band.B_10G;
case "21": return Band.B_24G;
default: return null;
}
}
/**
* Extracts the locator from a Win-Test ADDQSO packet.
*
* <p>Current parser model based on the existing split-by-quotes code:
* <ul>
* <li>{@code split("\"")[7]} = callsign</li>
* <li>{@code split("\"")[11]} = received exchange, e.g. 599001</li>
* <li>{@code split("\"")[13]} = locator, e.g. JO51UM</li>
* </ul>
*
* <p>If the dedicated locator field is empty, the exchange is used as fallback.</p>
*
* @param msg raw ADDQSO message
* @return normalized six-character locator or null
*/
private String helper_resolveLocatorFromWinTestAddQso(String msg) {
if (msg == null) {
return null;
}
String[] quotedParts = msg.split("\"");
if (quotedParts.length > 13) {
String locator = WorkedGrossFieldCache.extractLocator6(quotedParts[13]);
if (locator != null) {
return locator;
}
}
if (quotedParts.length > 11) {
return WorkedGrossFieldCache.extractLocator6(quotedParts[11]);
}
return null;
}
/** /**
* Catches add-qso messages of wintest if a new qso gets into the log<br/> * Catches add-qso messages of wintest if a new qso gets into the log<br/>
* *
@@ -310,16 +377,22 @@ public class ReadUDPByWintestThread extends Thread {
// receivedQsos.put(qsoNumber, msg); // receivedQsos.put(qsoNumber, msg);
// lastKnownQso = Math.max(lastKnownQso, qsoNumber); // lastKnownQso = Math.max(lastKnownQso, qsoNumber);
String callSignCatched = msg.split("\"") [7]; String callSignCatched = msg.split("\"") [7];
String locatorFromLogger = helper_resolveLocatorFromWinTestAddQso(msg);
ChatMember workedCall = new ChatMember(); ChatMember workedCall = new ChatMember();
workedCall.setCallSign(callSignCatched); workedCall.setCallSign(callSignCatched);
workedCall.setWorked(true); //its worked at this place, for sure! workedCall.setWorked(true); //its worked at this place, for sure!
if (locatorFromLogger != null) {
workedCall.setQra(locatorFromLogger);
}
ArrayList<Integer> markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall); ArrayList<Integer> markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall);
String bandId; String bandId;
bandId = msg.split("\"")[6].split(" ")[4].trim(); bandId = msg.split("\"")[6].split(" ")[4].trim();
Band workedBand = helper_resolveBandFromWinTestBandId(bandId);
switch (bandId) { switch (bandId) {
case "10" -> workedCall.setWorked50(true); case "10" -> workedCall.setWorked50(true);
case "11" -> workedCall.setWorked70(true); case "11" -> workedCall.setWorked70(true);
@@ -336,6 +409,10 @@ public class ReadUDPByWintestThread extends Thread {
default -> System.out.println("[WinTestUDPRcvr: warning] Unbekannte Band-ID: " + bandId); default -> System.out.println("[WinTestUDPRcvr: warning] Unbekannte Band-ID: " + bandId);
} }
if (workedBand != null && locatorFromLogger != null) {
this.client.registerWorkedGrossField(workedBand, locatorFromLogger, workedCall, "WINTEST");
}
if (!markTheseChattersAsWorked.isEmpty()) { if (!markTheseChattersAsWorked.isEmpty()) {
//Worked call is part of the current chatmember list //Worked call is part of the current chatmember list
@@ -345,6 +422,13 @@ public class ReadUDPByWintestThread extends Thread {
modifyThat.setWorked(true); //worked its for sure modifyThat.setWorked(true); //worked its for sure
if (locatorFromLogger != null
&& (modifyThat.getQra() == null
|| modifyThat.getQra().isBlank()
|| "unknown".equalsIgnoreCase(modifyThat.getQra()))) {
modifyThat.setQra(locatorFromLogger);
}
if (workedCall.isWorked50()) { if (workedCall.isWorked50()) {
modifyThat.setWorked50(true); modifyThat.setWorked50(true);
} else if (workedCall.isWorked70()) { } else if (workedCall.isWorked70()) {
@@ -398,7 +482,11 @@ public class ReadUDPByWintestThread extends Thread {
if (!isInChat) { if (!isInChat) {
workedCall.setName("unknown"); workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown"); workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall); this.client.getDbHandler().storeChatMember(workedCall);
} }
@@ -404,10 +404,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
break; break;
} }
// if (!client.getMap_ucxLogInfoWorkedCalls().containsKey("call")) {
// client.getMap_ucxLogInfoWorkedCalls().put(call, workedCall);
// } else
{ {
/** /**
* That means, the station is worked already but maybe at another band. So we * That means, the station is worked already but maybe at another band. So we
@@ -119,6 +119,31 @@ public final class WorkedGrossFieldCache {
.contains(grossField); .contains(grossField);
} }
/**
* Checks whether a locator gross field has already been worked on any band.
*
* <p>The UI grid status and the "Only new grids" filter use this any-band logic.
* This avoids user errors caused by wrong active-band settings and fits single
* band contests such as NAC better.</p>
*
* @param locatorOrGrossField six-character locator or four-character gross field
* @return true if the gross field exists in the cache on any band
*/
public synchronized boolean isGrossFieldWorkedAny(String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (grossField == null) {
return false;
}
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null && workedGrossFields.contains(grossField)) {
return true;
}
}
return false;
}
/** /**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts * Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK". * one from exchange strings such as "001JO41HK".
@@ -659,6 +659,27 @@ public class ChatMember {
return band != null && this.tropoSsbMarginDbByBand.containsKey(band); return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
} }
/**
* Returns true only when a finite, usable tropo SSB margin is stored for the band.
*
* <p>This is intentionally different from {@link #hasTropoSsbMarginDb(Band)}:
* a stored NaN means that an analysis was attempted but did not produce a usable
* link budget. Such failed values should not permanently block later retries.</p>
*
* @param band band to check
* @return true if a finite SSB margin exists
*/
public boolean hasFiniteTropoSsbMarginDb(Band band) {
if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return false;
}
Double storedValue = this.tropoSsbMarginDbByBand.get(band);
return storedValue != null && Double.isFinite(storedValue);
}
/** /**
* Reads the calculated tropo SSB margin for one band. * Reads the calculated tropo SSB margin for one band.
* *
@@ -678,21 +699,34 @@ public class ChatMember {
return OptionalDouble.of(storedValue); return OptionalDouble.of(storedValue);
} }
/** /**
* Formats the calculated tropo margin for direct table display. * Formats the calculated tropo margin for direct table display.
* *
* <p>The method distinguishes three states:
* <ul>
* <li>{@code ... @144}: analysis has not run yet</li>
* <li>{@code ? @144}: analysis ran, but no usable link budget was produced</li>
* <li>{@code +8.4 dB @144}: finite SSB margin exists</li>
* </ul>
*
* @param band selected/auto-resolved reachability band * @param band selected/auto-resolved reachability band
* @return display text such as "+8.4 dB @144" or "- @144" * @return display text such as "+8.4 dB @144", "? @144" or "... @144"
*/ */
public String formatTropoSsbMarginForBand(Band band) { public String formatTropoSsbMarginForBand(Band band) {
String bandLabel = band == null ? "?" : band.getDisplayLabel(); String bandLabel = band == null ? "?" : band.getDisplayLabel();
OptionalDouble marginDb = getTropoSsbMarginDb(band);
if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) { if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
return "- @" + bandLabel; return "... @" + bandLabel;
} }
return String.format(Locale.US, "%+.1f dB @%s", marginDb.getAsDouble(), bandLabel); Double storedValue = this.tropoSsbMarginDbByBand.get(band);
if (storedValue == null || !Double.isFinite(storedValue)) {
return "? @" + bandLabel;
}
return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
} }
@@ -1,13 +1,18 @@
package kst4contest.utils; package kst4contest.utils;
import javafx.scene.media.Media; import javazoom.jl.decoder.JavaLayerException;
import javafx.scene.media.MediaPlayer; import javazoom.jl.player.Player;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import java.io.File; import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedList;
import java.util.Queue; import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* This part of the client drives the sounds. Its a singleton instance. All audio outs are directed to this instance.<br> * This part of the client drives the sounds. Its a singleton instance. All audio outs are directed to this instance.<br>
@@ -113,8 +118,16 @@ public class PlayAudioUtils {
} }
private Queue<Media> musicList = new LinkedList<Media>(); private final Queue<String> musicList = new ConcurrentLinkedQueue<>();
private MediaPlayer mediaPlayer ; /** True once audio fails; prevents repeated error logs and further play attempts. */
private final AtomicBoolean audioUnavailable = new AtomicBoolean(false);
/** True while the drain loop is running; prevents duplicate concurrent drains. */
private final AtomicBoolean playing = new AtomicBoolean(false);
private final ExecutorService audioThread = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "audio-player");
t.setDaemon(true);
return t;
});
/** /**
* Plays notification sounds out of the windws 95 box by given action character<br/> * Plays notification sounds out of the windws 95 box by given action character<br/>
@@ -131,40 +144,31 @@ public class PlayAudioUtils {
*/ */
public void playNoiseLauncher(char actionChar) { public void playNoiseLauncher(char actionChar) {
// ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3");
switch (actionChar){ switch (actionChar){
case '-': case '-':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3"));
break; break;
case '!': case '!':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3"));
break; break;
case 'C': case 'C':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3"));
break; break;
case 'P': case 'P':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3"));
break; break;
case 'E': case 'E':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3"));
break; break;
case 'N': case 'N':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3"));
break; break;
// case 'M':
// musicList.add(new Media(new File ("VOICE.mp3").toURI().toString()));
// break;
default: default:
System.out.println("[KST4ContestApp, warning, letter not defined!]"); System.out.println("[KST4ContestApp, warning, letter not defined!]");
} }
playMusic(); playMusic();
// mediaPlayer.dispose();
} }
@@ -183,125 +187,124 @@ public class PlayAudioUtils {
for (char letterToPlay: playThisInCW){ for (char letterToPlay: playThisInCW){
switch (letterToPlay){ switch (letterToPlay){
case 'A': case 'A':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3"));
break; break;
case 'B': case 'B':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3"));
break; break;
case 'C': case 'C':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3"));
break; break;
case 'D': case 'D':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3"));
break; break;
case 'E': case 'E':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3"));
break; break;
case 'F': case 'F':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3"));
break; break;
case 'G': case 'G':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3"));
break; break;
case 'H': case 'H':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3"));
break; break;
case 'I': case 'I':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3"));
break; break;
case 'J': case 'J':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3"));
break; break;
case 'K': case 'K':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3"));
break; break;
case 'L': case 'L':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3"));
break; break;
case 'M': case 'M':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3"));
break; break;
case 'N': case 'N':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3"));
break; break;
case 'O': case 'O':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3"));
break; break;
case 'P': case 'P':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3"));
break; break;
case 'Q': case 'Q':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3"));
break; break;
case 'R': case 'R':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3"));
break; break;
case 'S': case 'S':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3"));
break; break;
case 'T': case 'T':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3"));
break; break;
case 'U': case 'U':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3"));
break; break;
case 'V': case 'V':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3"));
break; break;
case 'W': case 'W':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3"));
break; break;
case 'X': case 'X':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3"));
break; break;
case 'Y': case 'Y':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3"));
break; break;
case 'Z': case 'Z':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3"));
break; break;
case '1': case '1':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3"));
break; break;
case '2': case '2':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3"));
break; break;
case '3': case '3':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3"));
break; break;
case '4': case '4':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3"));
break; break;
case '5': case '5':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3"));
break; break;
case '6': case '6':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3"));
break; break;
case '7': case '7':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3"));
break; break;
case '8': case '8':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3"));
break; break;
case '9': case '9':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3"));
break; break;
case '0': case '0':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3"));
break; break;
case '/': case '/':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3"));
break; break;
case ' ': case ' ':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3"));
break; break;
default: default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW)); System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW));
} }
} }
playMusic(); playMusic();
// mediaPlayer.dispose();
} }
@@ -323,162 +326,166 @@ public class PlayAudioUtils {
for (char letterToPlay: spellThisWithVoice){ for (char letterToPlay: spellThisWithVoice){
switch (letterToPlay){ switch (letterToPlay){
case '!': case '!':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3"));
break; break;
case '?': case '?':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3"));
break; break;
case '#': case '#':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3"));
break; break;
case '*': case '*':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3"));
break; break;
case '$': case '$':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3"));
break; break;
case 'A': case 'A':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3"));
break; break;
case 'B': case 'B':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3"));
break; break;
case 'C': case 'C':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3"));
break; break;
case 'D': case 'D':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3"));
break; break;
case 'E': case 'E':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3"));
break; break;
case 'F': case 'F':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3"));
break; break;
case 'G': case 'G':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3"));
break; break;
case 'H': case 'H':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3"));
break; break;
case 'I': case 'I':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3"));
break; break;
case 'J': case 'J':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3"));
break; break;
case 'K': case 'K':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3"));
break; break;
case 'L': case 'L':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3"));
break; break;
case 'M': case 'M':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3"));
break; break;
case 'N': case 'N':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3"));
break; break;
case 'O': case 'O':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3"));
break; break;
case 'P': case 'P':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3"));
break; break;
case 'Q': case 'Q':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3"));
break; break;
case 'R': case 'R':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3"));
break; break;
case 'S': case 'S':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3"));
break; break;
case 'T': case 'T':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3"));
break; break;
case 'U': case 'U':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3"));
break; break;
case 'V': case 'V':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3"));
break; break;
case 'W': case 'W':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3"));
break; break;
case 'X': case 'X':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3"));
break; break;
case 'Y': case 'Y':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3"));
break; break;
case 'Z': case 'Z':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3"));
break; break;
case '1': case '1':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3"));
break; break;
case '2': case '2':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3"));
break; break;
case '3': case '3':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3"));
break; break;
case '4': case '4':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3"));
break; break;
case '5': case '5':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3"));
break; break;
case '6': case '6':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3"));
break; break;
case '7': case '7':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3"));
break; break;
case '8': case '8':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3"));
break; break;
case '9': case '9':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3"));
break; break;
case '0': case '0':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3"));
break; break;
case '/': case '/':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3"));
break; break;
// case ' ':
// musicList.add(new Media(new File ("VOICESPACE.mp3").toURI().toString()));
// break;
default: default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(spellThisWithVoice)); System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(spellThisWithVoice));
} }
} }
playMusic(); playMusic();
// mediaPlayer.dispose();
} }
private void playMusic() {
// System.out.println("Kst4ContestApplication.playMusic"); private void playMusic() {
if(musicList.peek() == null) if (audioUnavailable.get()) {
{ musicList.clear();
return; return;
} }
mediaPlayer = new MediaPlayer(musicList.poll()); // Only start a drain loop if none is running; the running loop will pick up new items itself.
mediaPlayer.setRate(1.0); if (!playing.compareAndSet(false, true)) return;
audioThread.submit(() -> {
mediaPlayer.setOnReady(() -> { String path;
mediaPlayer.play(); while ((path = musicList.poll()) != null) {
mediaPlayer.setOnEndOfMedia(() -> { if (audioUnavailable.get()) break;
// mediaPlayer.dispose(); try (FileInputStream fis = new FileInputStream(path);
BufferedInputStream bis = new BufferedInputStream(fis)) {
new Player(bis).play();
} catch (IOException | JavaLayerException e) {
audioUnavailable.set(true);
musicList.clear();
System.out.println("[KST4ContestApp, warning, audio playback disabled (could not create media player): " + e + "]");
break;
}
}
playing.set(false);
// Items may have been enqueued between the last poll() and playing.set(false);
// restart the drain loop if so.
if (!musicList.isEmpty() && !audioUnavailable.get()) {
playMusic(); playMusic();
if (musicList.isEmpty()) {
// mediaPlayer.dispose();
} }
}); });
});
} }
} }
+13 -18
View File
@@ -51,26 +51,21 @@ public class GuiUtils {
} }
} }
private static void triggerUpdate(ChatController chatController) {
{
//trick to trigger gui changes on property changes of obects
Predicate<ChatMember> dummyPredicate = new Predicate<ChatMember>() {
@Override
public boolean test(ChatMember chatMember) {
return true;
}
};
/** /**
* //TODO: following 2 lines are a quick fix to making disappear worked chatmembers of the list * Requests a safe UI refresh of the filtered ChatMember list.
* Thats uncomfortable due to this also causes selection changes, *
* Better way is to change all worked and qrv values to observables and then trigger the underlying * <p>Older versions used the trick of adding/removing a dummy predicate. That can
* list to fire an invalidationevent. Really Todo! * break JavaFX SortedList internals when the table is sorted and a FilteredList
* refilter happens at the same time. The controller-level refresh path is safer
* because Kst4ContestApplication now re-applies the existing predicates directly.</p>
*
* @param chatController central controller
*/ */
chatController.getLst_chatMemberListFilterPredicates().add(dummyPredicate); private static void triggerUpdate(ChatController chatController) {
chatController.getLst_chatMemberListFilterPredicates().remove(dummyPredicate); if (chatController == null) {
return;
}
} chatController.fireUserListUpdate("Forced filtered ChatMember refresh");
} }
} }
@@ -67,11 +67,20 @@ import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge; import kst4contest.view.map.StationMapBridge;
import kst4contest.view.map.StationMapView; import kst4contest.view.map.StationMapView;
import kst4contest.view.map.OfflineDemImportService; import kst4contest.view.map.OfflineDemImportService;
import kst4contest.controller.WorkedGrossFieldCache;
public class Kst4ContestApplication extends Application implements StatusUpdateListener { public class Kst4ContestApplication extends Application implements StatusUpdateListener {
// private static final Kst4ContestApplication dbcontroller = new DBController(); // private static final Kst4ContestApplication dbcontroller = new DBController();
// Null means Auto: use the lowest session band, or category fallback. // Null means Auto: use the lowest session band, or category fallback.
// Enables optional color highlighting of the QRA/grid cell.
// The status text itself remains visible independently of this flag.
private boolean gridSquareHighlightEnabled = false;
private PauseTransition userListRefreshCoalescer;
private String pendingUserListUpdateReason = "";
private Band selectedReachabilityBandOverride = null; private Band selectedReachabilityBandOverride = null;
private StationMapView stationMapView; //view class for the avl stn map private StationMapView stationMapView; //view class for the avl stn map
@@ -955,7 +964,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return true; return true;
} else return false; } else return false;
} catch (NullPointerException SenderNull) { } catch (NullPointerException SenderNull) {
System.out.println("KST4ContestApp, <<<catched error>>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage()); // System.out.println("KST4ContestApp, <<<catched error>>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage());
return false; return false;
} }
@@ -1040,7 +1049,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} else return false; } else return false;
} catch (Exception exception) { } catch (Exception exception) {
System.out.println("KST4ContestApplication <<<catched ERROR>>>>: cant get sender infos due to sender is not known yet" + exception.getMessage()); // System.out.println("KST4ContestApplication <<<catched ERROR>>>>: cant get sender infos due to sender is not known yet" + exception.getMessage());
return false; return false;
} }
} }
@@ -1056,6 +1065,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
/**
* Applies the currently selected station filters to the ChatMember FilteredList.
*
* <p>This replaces the old predicate-property binding. The old binding worked
* for normal filter changes, but it became unstable when we tried to force a
* refresh by adding/removing a dummy predicate. Calling this method simply
* rebuilds the combined predicate and sets it directly on the FilteredList.</p>
*/
private void applyChatMemberFilterPredicates() {
if (chatcontroller == null
|| chatcontroller.getLst_chatMemberListFiltered() == null
|| chatcontroller.getLst_chatMemberListFilterPredicates() == null) {
return;
}
Predicate<ChatMember> combinedPredicate =
chatcontroller.getLst_chatMemberListFilterPredicates()
.stream()
.reduce(chatMember -> true, Predicate::and);
chatcontroller.getLst_chatMemberListFiltered().setPredicate(combinedPredicate);
}
/** /**
* Helper method for furtherinfoPane * Helper method for furtherinfoPane
* @param s * @param s
@@ -1231,6 +1265,75 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
}); });
/**
* Optionally highlights already worked grid squares in the QRA column.
*
* <p>The default table design is preserved as much as possible:
* <ul>
* <li>If grid coloring is disabled, the cell uses the standard JavaFX style.</li>
* <li>If the grid is not worked yet, the cell also uses the standard style.</li>
* <li>Only already worked grids get a subtle background color.</li>
* <li>Selected rows keep the normal table selection style.</li>
* </ul>
*
* <p>No font weight is changed here. The compact worked/grid status such as
* {@code xo} is emphasized only in the worked-any column.</p>
*/
qraCol.setCellFactory(column -> new TableCell<ChatMember, String>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setTooltip(null);
setStyle("");
return;
}
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
boolean gridWorked = member != null
&& chatcontroller != null
&& chatcontroller.isGridSquareWorkedAny(member);
String grossField = WorkedGrossFieldCache.extractGrossField(item);
setText(item);
setTooltip(new Tooltip(
"Grid status: "
+ (grossField == null ? "unknown" : grossField)
+ "\nGrid worked any: "
+ (gridWorked ? "yes" : "no")
));
/*
* Important:
* Do not style the cell unless the Grid color button is active AND the
* grid has already been worked. This keeps normal/new grids visually
* identical to the standard table design.
*/
if (!gridSquareHighlightEnabled || !gridWorked) {
setStyle("");
return;
}
/*
* Preserve normal selection appearance. A selected row should look like a
* selected row, not like a custom-colored QRA cell.
*/
if (isSelected() || (getTableRow() != null && getTableRow().isSelected())) {
setStyle("");
return;
}
/*
* Already worked grid:
* Use a subtle background derived from the current theme. No bold text.
*/
setStyle("-fx-background-color: derive(-fx-control-inner-background, -18%);");
}
});
TableColumn<ChatMember, String> qrBCol = new TableColumn<ChatMember, String>("QRB"); TableColumn<ChatMember, String> qrBCol = new TableColumn<ChatMember, String>("QRB");
qrBCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() { qrBCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@@ -1455,19 +1558,60 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override @Override
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> cellDataFeatures) { public ObservableValue<String> call(CellDataFeatures<ChatMember, String> cellDataFeatures) {
SimpleStringProperty wkd = new SimpleStringProperty(); return new SimpleStringProperty(formatWorkedAnyGridStatus(cellDataFeatures.getValue()));
if (cellDataFeatures.getValue().isWorked()) {
wkd.setValue("X");
} else {
wkd.setValue("");
}
return wkd;
} }
}); });
wkdAny_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(14)); wkdAny_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(14));
/**
* Shows the compact worked/grid status and explains it by tooltip.
*/
wkdAny_subcol.setCellFactory(column -> new TableCell<ChatMember, String>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setTooltip(null);
setStyle("");
return;
}
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
setText(item);
setTooltip(new Tooltip(buildWorkedAnyGridStatusTooltip(member)));
setAlignment(Pos.CENTER);
setStyle("-fx-font-weight: bold;");
}
});
/**
* Shows the compact worked/grid status and explains it by tooltip.
*/
wkdAny_subcol.setCellFactory(column -> new TableCell<ChatMember, String>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setTooltip(null);
setStyle("");
return;
}
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
setText(item);
setTooltip(new Tooltip(buildWorkedAnyGridStatusTooltip(member)));
setAlignment(Pos.CENTER);
setStyle("-fx-font-weight: bold;");
}
});
TableColumn<ChatMember, String> vhfCol_subcol = new TableColumn<ChatMember, String>("144"); TableColumn<ChatMember, String> vhfCol_subcol = new TableColumn<ChatMember, String>("144");
vhfCol_subcol vhfCol_subcol
.setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() { .setCellValueFactory(new Callback<CellDataFeatures<ChatMember, String>, ObservableValue<String>>() {
@@ -5458,29 +5602,79 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ChatMessage sendMe = new ChatMessage(); ChatMessage sendMe = new ChatMessage();
/** /*
* testing bugfix * Resolve the selected station safely.
*
* Normal case:
* - selectedCallSignInfoStageChatMember is set by clicking/selecting a station.
*
* Fallbacks:
* - current table selection
* - ScoreService selected member
*
* If no station is selected at all, the message is sent to the main category.
*/ */
ChatMember effectiveSelectedMember = selectedCallSignInfoStageChatMember;
ChatCategory sendMeInThisCat; if (effectiveSelectedMember == null
String categoryNumber = selectedCallSignInfoStageChatMember.getChatCategory().getCategoryNumber() +""; && tbl_chatMember != null
&& tbl_chatMember.getSelectionModel() != null) {
effectiveSelectedMember = tbl_chatMember.getSelectionModel().getSelectedItem();
}
if (effectiveSelectedMember == null
&& chatcontroller != null
&& chatcontroller.getScoreService() != null) {
effectiveSelectedMember = chatcontroller.getScoreService().getSelectedChatMember();
}
/*
* Default decision:
* If no station is selected, use the main category.
*/
ChatCategory sendMeInThisCat = chatcontroller.getChatCategoryMain();
/*
* If a station is selected and its category matches one of our active chat
* categories, send in that category. This keeps the old behaviour, but avoids
* NullPointerExceptions when no station has been selected yet.
*/
if (effectiveSelectedMember != null && effectiveSelectedMember.getChatCategory() != null) {
String categoryNumber = effectiveSelectedMember.getChatCategory().getCategoryNumber() + "";
if (chatcontroller.getChatCategoryMain() != null
&& categoryNumber.equals(chatcontroller.getChatCategoryMain().getCategoryNumber() + "")) {
if (categoryNumber.equals(chatcontroller.getChatCategoryMain().getCategoryNumber() + "")) {
sendMeInThisCat = chatcontroller.getChatCategoryMain(); sendMeInThisCat = chatcontroller.getChatCategoryMain();
} else if (categoryNumber.equals(chatcontroller.getChatCategorySecondChat().getCategoryNumber() + "")) { } else if (chatcontroller.getChatCategorySecondChat() != null
&& categoryNumber.equals(chatcontroller.getChatCategorySecondChat().getCategoryNumber() + "")) {
sendMeInThisCat = chatcontroller.getChatCategorySecondChat(); sendMeInThisCat = chatcontroller.getChatCategorySecondChat();
} else { } else {
sendMeInThisCat = chatcontroller.getChatCategoryMain(); // Chatcategory default decision sendMeInThisCat = chatcontroller.getChatCategoryMain(); // Chatcategory default decision
} }
}
System.out.println("<<<<<<<<<<<<<<<<<<<<< detected Category for sending message is " + sendMeInThisCat + " // selected member: " + selectedCallSignInfoStageChatMember.getChatCategory() + " evt " + event.isConsumed() ); String selectedMemberDebugText;
/** if (effectiveSelectedMember == null) {
* end testing bugfix selectedMemberDebugText = "none, using main category";
*/ } else {
selectedMemberDebugText = effectiveSelectedMember.getCallSignRaw()
+ " / "
+ effectiveSelectedMember.getChatCategory();
}
sendMe.setChatCategory(sendMeInThisCat); //new in 1.26, answer in channel of the selected member System.out.println("<<<<<<<<<<<<<<<<<<<<< detected Category for sending message is "
+ sendMeInThisCat
+ " // selected member: "
+ selectedMemberDebugText
+ " evt "
+ event.isConsumed());
sendMe.setChatCategory(sendMeInThisCat); // new in 1.26, answer in channel of the selected member if available
sendMe.setMessageText(txt_chatMessageUserInput.getText()); sendMe.setMessageText(txt_chatMessageUserInput.getText());
// If operator sends "/cq CALL ..." => arm pending ping metrics for reply-time / no-reply tracking // If operator sends "/cq CALL ..." => arm pending ping metrics for reply-time / no-reply tracking
@@ -5492,8 +5686,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getMessageTXBus().add(sendMe); // move the message to the tx queue chatcontroller.getMessageTXBus().add(sendMe); // move the message to the tx queue
txt_chatMessageUserInput.clear(); txt_chatMessageUserInput.clear();
} }
}); });
@@ -6091,13 +6283,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false); chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
ToggleButton btnTglNewLocator = new ToggleButton("New locator"); ToggleButton btnTglNewLocator = new ToggleButton("Only new grids");
Predicate<ChatMember> newLocatorPredicate = new Predicate<ChatMember>() { Predicate<ChatMember> newLocatorPredicate = new Predicate<ChatMember>() {
@Override @Override
public boolean test(ChatMember chatMember) { public boolean test(ChatMember chatMember) {
return chatcontroller.isNewLocatorOnAnyEnabledBand(chatMember); return chatcontroller.isNewGridSquare(chatMember);
} }
}; };
btnTglNewLocator.setOnAction(new EventHandler<ActionEvent>() { btnTglNewLocator.setOnAction(new EventHandler<ActionEvent>() {
@Override @Override
public void handle(ActionEvent actionEvent) { public void handle(ActionEvent actionEvent) {
@@ -6108,7 +6301,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
} }
}); });
btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose gross locator is still new on at least one active own band")); btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose 4-character grid square has not been worked on any band yet"));
ToggleButton btnTglGridColor = new ToggleButton("Grid color");
/**
* Enables optional QRA-cell coloring for grid status.
*
* <p>This does not filter the list. It only colors the locator field:
* worked grid = darker, new grid = brighter.</p>
*/
btnTglGridColor.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent actionEvent) {
gridSquareHighlightEnabled = btnTglGridColor.isSelected();
tbl_chatMember.refresh();
}
});
btnTglGridColor.setTooltip(new Tooltip("Color the QRA field by grid status without filtering the station list"));
ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB"); ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB");
Predicate<ChatMember> reachableTropoPredicate = new Predicate<ChatMember>() { Predicate<ChatMember> reachableTropoPredicate = new Predicate<ChatMember>() {
@@ -6391,8 +6603,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
"-fx-border-color: lightgrey;"); "-fx-border-color: lightgrey;");
chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates())); // chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates()));
applyChatMemberFilterPredicates();
chatcontroller.getLst_chatMemberListFilterPredicates().addListener(
(ListChangeListener<Predicate<ChatMember>>) change -> applyChatMemberFilterPredicates()
);
TextField chatMemberTableFilterTextField = new TextField("Find..."); TextField chatMemberTableFilterTextField = new TextField("Find...");
chatMemberTableFilterTextField.setFocusTraversable(false); chatMemberTableFilterTextField.setFocusTraversable(false);
@@ -6434,8 +6651,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getLst_chatMemberListFilterPredicates().remove(searchTextPredicate); chatcontroller.getLst_chatMemberListFilterPredicates().remove(searchTextPredicate);
} }
else { else {
if (!chatcontroller.getLst_chatMemberListFilterPredicates().contains(searchTextPredicate)) {
chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate); chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate);
} }
}
System.out.println("KST4CApp " + chatMemberTableFilterTextField.textProperty().getValue().equals("") + " / " + !chatMemberTableFilterTextField.focusedProperty().getValue()); System.out.println("KST4CApp " + chatMemberTableFilterTextField.textProperty().getValue().equals("") + " / " + !chatMemberTableFilterTextField.focusedProperty().getValue());
} }
@@ -6444,6 +6663,33 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox(); HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox();
Button btnCalculateSelectedTropo = new Button("Calc selected");
/**
* Calculates full terrain/path reachability only for the currently selected
* station. This is intentionally operator-triggered to avoid API-limit problems.
*/
btnCalculateSelectedTropo.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
ChatMember selectedMember = tbl_chatMember.getSelectionModel().getSelectedItem();
if (selectedMember == null && chatcontroller.getScoreService() != null) {
selectedMember = chatcontroller.getScoreService().getSelectedChatMember();
}
if (selectedMember == null) {
return;
}
Band selectedBand = resolveReachabilityBandForUi(selectedMember);
chatcontroller.getReachabilityService().calculateSelectedStationOnDemand(selectedMember, selectedBand);
}
});
btnCalculateSelectedTropo.setTooltip(new Tooltip("Calculate full Tropo/path analysis for the selected station only"));
ComboBox<String> cmbReachabilityBand = new ComboBox<>(); ComboBox<String> cmbReachabilityBand = new ComboBox<>();
cmbReachabilityBand.getItems().add("Auto"); cmbReachabilityBand.getItems().add("Auto");
for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) { for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) {
@@ -6455,16 +6701,26 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override @Override
public void handle(ActionEvent event) { public void handle(ActionEvent event) {
selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue()); selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue());
for (ChatMember member : chatcontroller.getLst_chatMemberList()) {
Band bandToCalculate = resolveReachabilityBandForUi(member); // Changing the display band must not start batch terrain analysis.
chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, bandToCalculate); // Existing cached values are shown; new values are calculated on map click
} // or via the explicit "Calc selected" button.
chatcontroller.fireUserListUpdate("Reachability band changed"); chatcontroller.fireUserListUpdate("Reachability band changed");
} }
}); });
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Reachability:")); chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Reachability:"));
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(cmbReachabilityBand); chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(cmbReachabilityBand);
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnCalculateSelectedTropo);
/**
* In order to work the filters needs the proper band settings, which should be worked
*/
if (chatcontroller.getReachabilityService().getEnabledStationBands().isEmpty()) {
Label bandSetupWarning = new Label("Please enable at least one active station band for New Locator/New Band filters.");
bandSetupWarning.setStyle("-fx-text-fill: orange; -fx-font-weight: bold;");
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(bandSetupWarning);
}
ToggleButton btnTglwkd = new ToggleButton("wkd"); ToggleButton btnTglwkd = new ToggleButton("wkd");
@@ -6734,6 +6990,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll( chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll(
btnTglNewLocator, btnTglNewLocator,
btnTglGridColor,
btnTglReachableTropo, btnTglReachableTropo,
btnTglNewBands, btnTglNewBands,
btnTglAsNext5Min btnTglAsNext5Min
@@ -9353,6 +9610,75 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return sb.toString(); return sb.toString();
} }
/**
* Formats the global worked/grid status for the worked-any column.
*
* <p>Status characters follow the user feedback:
* <ul>
* <li>empty = call not worked, grid not worked</li>
* <li>x = call worked</li>
* <li>o = grid worked, call not worked</li>
* <li>xo = call and grid worked</li>
* </ul>
*
* <p>The call status is based on {@link ChatMember#isWorked()}, i.e. worked-any.
* The grid status is based on the four-character grid square worked on any band.</p>
*
* @param member station row
* @return compact status text
*/
private String formatWorkedAnyGridStatus(ChatMember member) {
if (member == null) {
return "";
}
boolean callWorked = member.isWorked();
boolean gridWorked = chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member);
if (callWorked && gridWorked) {
return "xo";
}
if (callWorked) {
return "x";
}
if (gridWorked) {
return "o";
}
return "";
}
/**
* Builds a tooltip for the worked-any/grid-status cell.
*
* @param member station row
* @return tooltip text
*/
private String buildWorkedAnyGridStatusTooltip(ChatMember member) {
if (member == null) {
return "empty = call not worked, grid not worked\n"
+ "x = call worked\n"
+ "o = grid worked\n"
+ "xo = call and grid worked";
}
String grossField = WorkedGrossFieldCache.extractGrossField(member.getQra());
String gridText = grossField == null ? "unknown grid" : "grid " + grossField;
return "Worked status:\n"
+ "empty = call not worked, grid not worked\n"
+ "x = call worked\n"
+ "o = grid worked\n"
+ "xo = call and grid worked\n\n"
+ "This station:\n"
+ "Call worked: " + (member.isWorked() ? "yes" : "no") + "\n"
+ "Grid worked: " + (chatcontroller != null && chatcontroller.isGridSquareWorkedAny(member) ? "yes" : "no")
+ " (" + gridText + ")";
}
// /** // /**
// * // *
// * resets the style of the not selected direction buttons // * resets the style of the not selected direction buttons
@@ -9497,16 +9823,43 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
//if we receive a threadstatemessage for sked warning, enable the sked warning //if we receive a threadstatemessage for sked warning, enable the sked warning
}
/**
* Forces the station FilteredList to evaluate all active predicates again.
*
* <p>Several filters depend on mutable ChatMember fields, for example worked
* flags, AirScout windows or calculated Tropo values. Updating such fields does
* not necessarily create a JavaFX list-change event. Therefore we explicitly
* re-apply the combined predicate instead of adding/removing a dummy predicate.
* The dummy-predicate trick can corrupt SortedList mapping in JavaFX.</p>
*/
private void forceChatMemberFilterRefresh() {
applyChatMemberFilterPredicates();
} }
@Override @Override
public void onUserListUpdated(String reason) { public void onUserListUpdated(String reason) {
Platform.runLater(() -> { Platform.runLater(() -> {
pendingUserListUpdateReason = reason;
// tbl_chatMember.sort(); if (userListRefreshCoalescer == null) {
userListRefreshCoalescer = new PauseTransition(Duration.millis(300));
userListRefreshCoalescer.setOnFinished(event -> {
forceChatMemberFilterRefresh();
if (tbl_chatMember != null) {
tbl_chatMember.refresh(); tbl_chatMember.refresh();
}
System.out.println("KST4Capp, UI Update Trigger: " + reason); refreshStationMapIfVisible();
System.out.println("KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason);
});
}
userListRefreshCoalescer.playFromStart();
}); });
} }
@@ -9606,6 +9959,72 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// } // }
/**
* Returns the best currently selected ChatMember for actions that depend on a
* selected station.
*
* <p>The normal source is selectedCallSignInfoStageChatMember. As a fallback,
* the current table selection and the ScoreService selection are checked. If
* nothing is selected yet, null is returned. This is valid and must be handled
* by the caller.</p>
*
* @return selected ChatMember or null if no station is selected
*/
private ChatMember getEffectiveSelectedChatMember() {
if (selectedCallSignInfoStageChatMember != null) {
return selectedCallSignInfoStageChatMember;
}
if (tbl_chatMember != null
&& tbl_chatMember.getSelectionModel() != null
&& tbl_chatMember.getSelectionModel().getSelectedItem() != null) {
return tbl_chatMember.getSelectionModel().getSelectedItem();
}
if (chatcontroller != null
&& chatcontroller.getScoreService() != null
&& chatcontroller.getScoreService().getSelectedChatMember() != null) {
return chatcontroller.getScoreService().getSelectedChatMember();
}
return null;
}
/**
* Resolves the chat category for an outgoing operator message.
*
* <p>If a station is selected, the message is sent in the category of that
* station. If no station is selected yet, the message is sent in the main chat
* category. This prevents NullPointerExceptions when the operator writes to the
* chat immediately after joining.</p>
*
* @param selectedMember selected station, may be null
* @return category for the outgoing message, normally main or second category
*/
private ChatCategory resolveOutgoingChatCategory(ChatMember selectedMember) {
ChatCategory mainCategory = chatcontroller.getChatCategoryMain();
ChatCategory secondCategory = chatcontroller.getChatCategorySecondChat();
if (selectedMember == null || selectedMember.getChatCategory() == null) {
return mainCategory;
}
String categoryNumber = selectedMember.getChatCategory().getCategoryNumber() + "";
if (mainCategory != null
&& categoryNumber.equals(mainCategory.getCategoryNumber() + "")) {
return mainCategory;
}
if (secondCategory != null
&& categoryNumber.equals(secondCategory.getCategoryNumber() + "")) {
return secondCategory;
}
return mainCategory;
}
/** /**
* helper method, in cases of QRG ending qith 0 as double values, it will fill the ending 0 to the qrg string * helper method, in cases of QRG ending qith 0 as double values, it will fill the ending 0 to the qrg string
* @param raw * @param raw
@@ -214,6 +214,11 @@ public final class MapHtmlResources {
<body class="kst-theme-light"> <body class="kst-theme-light">
<div id="map"></div> <div id="map"></div>
<script>
// JavaFX 21 WebView can render Leaflet's CSS translate3d tile positioning incorrectly.
// Disable Leaflet 3D transforms before leaflet.js is loaded to keep tile rendering stable.
window.L_DISABLE_3D = true;
</script>
<script> <script>
""" + leafletJs + """ """ + leafletJs + """
</script> </script>
@@ -366,18 +371,31 @@ public final class MapHtmlResources {
applyThemeClass(); applyThemeClass();
map = L.map('map', { map = L.map('map', {
zoomControl: true zoomControl: true,
zoomAnimation: false,
fadeAnimation: false,
markerZoomAnimation: false,
inertia: false
}).setView([51.0, 10.0], 6); }).setView([51.0, 10.0], 6);
jsLog('Leaflet map initialized'); jsLog('Leaflet map initialized');
L.tileLayer( const tileLayer = L.tileLayer(
'http://127.0.0.1:' + window._kstTileProxyPort + '/tiles/{s}/{z}/{x}/{y}.png', 'http://127.0.0.1:' + window._kstTileProxyPort + '/tiles/{s}/{z}/{x}/{y}.png',
{ {
maxZoom: 18, maxZoom: 18,
updateWhenZooming: false,
keepBuffer: 4,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
} }
).addTo(map); );
tileLayer.on('tileerror', function (event) {
const source = event && event.tile ? event.tile.src : 'unknown';
jsError('OSM tile load failed: ' + source);
});
tileLayer.addTo(map);
map.createPane('beamPane'); map.createPane('beamPane');
map.getPane('beamPane').style.zIndex = 410; map.getPane('beamPane').style.zIndex = 410;
@@ -420,9 +438,28 @@ public final class MapHtmlResources {
return; return;
} }
jsLog('invalidateSize'); const mapElement = document.getElementById('map');
const domWidth = mapElement ? mapElement.clientWidth : -1;
const domHeight = mapElement ? mapElement.clientHeight : -1;
map.invalidateSize(false); jsLog('invalidateSize dom=' + domWidth + 'x' + domHeight
+ ' leafletBefore=' + map.getSize().x + 'x' + map.getSize().y);
window.requestAnimationFrame(function () {
map.invalidateSize({
animate: false,
pan: false,
debounceMoveend: true
});
window.setTimeout(function () {
map.invalidateSize({
animate: false,
pan: false,
debounceMoveend: true
});
jsLog('invalidateSize leafletAfter=' + map.getSize().x + 'x' + map.getSize().y);
if (invalidateNotifyTimer) { if (invalidateNotifyTimer) {
window.clearTimeout(invalidateNotifyTimer); window.clearTimeout(invalidateNotifyTimer);
@@ -431,6 +468,12 @@ public final class MapHtmlResources {
invalidateNotifyTimer = window.setTimeout(function () { invalidateNotifyTimer = window.setTimeout(function () {
notifyViewport(); notifyViewport();
}, 120); }, 120);
}, 80);
});
}
function resize() {
invalidateSize();
} }
function zoomIn() { function zoomIn() {
@@ -693,6 +736,7 @@ public final class MapHtmlResources {
return { return {
init: init, init: init,
invalidateSize: invalidateSize, invalidateSize: invalidateSize,
resize: resize,
zoomIn: zoomIn, zoomIn: zoomIn,
zoomOut: zoomOut, zoomOut: zoomOut,
inspectPoint: inspectPoint, inspectPoint: inspectPoint,
@@ -6,7 +6,6 @@ import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
import javafx.util.Duration; import javafx.util.Duration;
import kst4contest.controller.ChatController; import kst4contest.controller.ChatController;
import kst4contest.locatorUtils.Location;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences; import kst4contest.model.ChatPreferences;
@@ -15,12 +14,13 @@ import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer; import java.util.function.Consumer;
import kst4contest.model.Band;
import java.util.function.Predicate;
/** /**
* Synchronizes the application state with the station map window. * Synchronizes the application state with the station map window.
* *
@@ -32,7 +32,7 @@ import java.util.function.Consumer;
*/ */
public final class StationMapBridge { public final class StationMapBridge {
private final ExecutorService pathAnalysisExecutor = Executors.newSingleThreadExecutor(new PathAnalysisThreadFactory());
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0); private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
private final ChatController chatController; private final ChatController chatController;
@@ -45,8 +45,7 @@ public final class StationMapBridge {
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder(); private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
// private final OfflineDemManager offlineDemManager = new OfflineDemManager();
private final PathAnalysisService pathAnalysisService;
private String lastPathAnalysisRequestSignature = ""; private String lastPathAnalysisRequestSignature = "";
@@ -64,9 +63,7 @@ public final class StationMapBridge {
this.refreshCoalescer.setOnFinished(event -> refreshNow()); this.refreshCoalescer.setOnFinished(event -> refreshNow());
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(
new OpenMeteoTerrainProfileProvider()
);
} }
public void install() { public void install() {
@@ -85,6 +82,10 @@ public final class StationMapBridge {
(obs, oldValue, newValue) -> scheduleRefresh() (obs, oldValue, newValue) -> scheduleRefresh()
); );
chatController.getLst_chatMemberListFilterPredicates().addListener(
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh()
);
requestImmediateRefresh(); requestImmediateRefresh();
} }
@@ -170,6 +171,17 @@ public final class StationMapBridge {
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot); requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
} }
/**
* Requests the selected station path analysis through the central reachability
* service.
*
* <p>The map no longer owns a separate PathAnalysisService. This ensures that
* the map detail panel, the Tropo table column and the station filters all use
* exactly the same path/link-budget calculation.</p>
*
* @param ownLocator6 own locator from preferences
* @param selectedSnapshot selected map marker snapshot
*/
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6); String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
@@ -202,20 +214,28 @@ public final class StationMapBridge {
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw) PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
); );
pathAnalysisExecutor.submit(() -> { ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
PathAnalysisResult result = buildPathAnalysisResult(normalizedOwnLocator6, selectedSnapshot);
Platform.runLater(() -> { chatController.getReachabilityService().requestPathAnalysisForMap(
selectedMember,
selectedSnapshot,
result -> {
if (generation != pathAnalysisGeneration.get()) { if (generation != pathAnalysisGeneration.get()) {
return; return;
} }
stationMapView.setPathAnalysisResult(result); stationMapView.setPathAnalysisResult(result);
}); }
}); );
} }
/**
* Invalidates pending map callbacks.
*
* <p>The actual calculation executor is owned by ReachabilityService now, so
* this bridge no longer shuts down any path-analysis thread directly.</p>
*/
public void dispose() { public void dispose() {
pathAnalysisExecutor.shutdownNow(); pathAnalysisGeneration.incrementAndGet();
} }
private void handleMapCallsignSelection(String callSignRaw) { private void handleMapCallsignSelection(String callSignRaw) {
@@ -286,43 +306,6 @@ public final class StationMapBridge {
return callSignRaw.trim().toUpperCase(Locale.ROOT); return callSignRaw.trim().toUpperCase(Locale.ROOT);
} }
private PathAnalysisResult buildPathAnalysisResult(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
if (selectedSnapshot == null) {
return PathAnalysisResult.waitingForSelection(normalizedOwnLocator6);
}
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
if (normalizedOwnLocator6.length() != 6) {
return PathAnalysisResult.waitingForValidHomeLocator(normalizedOwnLocator6, normalizedTargetLocator6);
}
if (!selectedSnapshot.hasUsablePosition()) {
return PathAnalysisResult.waitingForValidTarget(normalizedOwnLocator6, normalizedTargetLocator6);
}
Location homeLocation = new Location(normalizedOwnLocator6);
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
PathAnalysisRequest request = new PathAnalysisRequest(
normalizedOwnLocator6,
homeLocation.getLatitude().toDegrees(),
homeLocation.getLongitude().toDegrees(),
selectedSnapshot.callSignRaw(),
normalizedTargetLocator6,
selectedSnapshot.latitudeDeg(),
selectedSnapshot.longitudeDeg(),
analysisFrequencyMHz,
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
chatController.getChatPreferences().buildPathLinkBudgetSettings()
);
return pathAnalysisService.analyze(request);
}
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) { private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) { if (selectedSnapshot == null) {
@@ -376,12 +359,5 @@ public final class StationMapBridge {
return locator.trim().toUpperCase(Locale.ROOT); return locator.trim().toUpperCase(Locale.ROOT);
} }
private static final class PathAnalysisThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, "station-map-path-analysis");
thread.setDaemon(true);
return thread;
}
}
} }
@@ -49,6 +49,17 @@ public final class StationMapView {
private final Stage stage = new Stage(); private final Stage stage = new Stage();
private final WebView webView = new WebView(); private final WebView webView = new WebView();
private final WebEngine webEngine = webView.getEngine(); private final WebEngine webEngine = webView.getEngine();
/**
* Keep a strong Java reference to the bridge object.
*
* JavaFX WebView/JSObject does not guarantee that a Java object passed through
* window.setMember(...) remains strongly reachable from the Java side. Newer
* JavaFX/WebKit/GC combinations can otherwise lose callbacks after a while.
*/
private final JavaMapBridge javaMapBridge = new JavaMapBridge();
private TileProxyServer tileProxyServer; private TileProxyServer tileProxyServer;
private Scene scene; private Scene scene;
@@ -85,6 +96,22 @@ public final class StationMapView {
private List<MapCallsignRawSnapshot> lastSnapshots = List.of(); private List<MapCallsignRawSnapshot> lastSnapshots = List.of();
private MapCallsignRawSnapshot lastSelectedSnapshot; private MapCallsignRawSnapshot lastSelectedSnapshot;
private boolean filteredViewActive; private boolean filteredViewActive;
/**
* Last JSON payloads sent into Leaflet. Used to avoid clearing/recreating
* layers every score/table refresh. Rebuilding DOM markers every few seconds
* is visible as flicker in JavaFX 21 WebView.
*/
private String lastRenderedStationsJson = "";
private String lastRenderedBeamJson = "";
private String lastRenderedConnectionJson = "";
private String lastRenderedGridJson = "";
/**
* Prevent periodic refreshes from panning the map back to the selected station.
* Explicit calls to focusCallsignRaw(...) still pan immediately.
*/
private String lastAutoFocusedCallsignRaw = "";
private double homeLatitudeDeg = Double.NaN; private double homeLatitudeDeg = Double.NaN;
private double homeLongitudeDeg = Double.NaN; private double homeLongitudeDeg = Double.NaN;
@@ -192,8 +219,11 @@ public final class StationMapView {
return; return;
} }
String normalizedCallsignRaw = callSignRaw.trim().toUpperCase(Locale.ROOT);
lastAutoFocusedCallsignRaw = normalizedCallsignRaw;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.focusCallsignRaw(" + toJsStringLiteral(callSignRaw.trim().toUpperCase(Locale.ROOT)) + ");" "window.kstMapApi.focusCallsignRaw(" + toJsStringLiteral(normalizedCallsignRaw) + ");"
); );
} }
@@ -292,6 +322,8 @@ public final class StationMapView {
webView.setMinWidth(0); webView.setMinWidth(0);
webView.setMinHeight(220); webView.setMinHeight(220);
webView.setPrefHeight(420); webView.setPrefHeight(420);
webView.setMaxWidth(Double.MAX_VALUE);
webView.setMaxHeight(Double.MAX_VALUE);
VBox.setVgrow(webView, Priority.ALWAYS); VBox.setVgrow(webView, Priority.ALWAYS);
profileSection.setMinWidth(0); profileSection.setMinWidth(0);
@@ -623,7 +655,7 @@ public final class StationMapView {
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
if (newState == javafx.concurrent.Worker.State.SUCCEEDED) { if (newState == javafx.concurrent.Worker.State.SUCCEEDED) {
JSObject window = (JSObject) webEngine.executeScript("window"); JSObject window = (JSObject) webEngine.executeScript("window");
window.setMember("javaMapBridge", new JavaMapBridge()); window.setMember("javaMapBridge", javaMapBridge);
executeMapScriptSafely("window.kstMapApi.init();"); executeMapScriptSafely("window.kstMapApi.init();");
@@ -643,11 +675,13 @@ public final class StationMapView {
return; return;
} }
Platform.runLater(() -> Platform.runLater(() -> Platform.runLater(() ->
Platform.runLater(() -> executeMapScriptSafely("window.kstMapApi.resize();")
executeMapScriptSafely("window.kstMapApi.invalidateSize();"))); ));
} }
private void handleWebViewClick(MouseEvent event) { private void handleWebViewClick(MouseEvent event) {
logWebViewMouseEvent("MOUSE_CLICKED", event); logWebViewMouseEvent("MOUSE_CLICKED", event);
@@ -910,9 +944,6 @@ public final class StationMapView {
detailPathProfileChart.setObstructionSummary(result.obstructionSummary()); detailPathProfileChart.setObstructionSummary(result.obstructionSummary());
} }
private void renderAll() { private void renderAll() {
if (!mapReady) { if (!mapReady) {
return; return;
@@ -930,49 +961,78 @@ public final class StationMapView {
renderBeam(); renderBeam();
renderConnectionLine(); renderConnectionLine();
renderGridIfViewportKnown(); renderGridIfViewportKnown();
focusSelectedStationOnlyWhenSelectionChanged();
if (lastSelectedSnapshot != null) {
focusCallsignRaw(lastSelectedSnapshot.callSignRaw());
} }
private void focusSelectedStationOnlyWhenSelectionChanged() {
if (lastSelectedSnapshot == null || lastSelectedSnapshot.callSignRaw() == null) {
lastAutoFocusedCallsignRaw = "";
return;
}
String selectedCallsignRaw = lastSelectedSnapshot.callSignRaw().trim().toUpperCase(Locale.ROOT);
if (selectedCallsignRaw.isBlank() || selectedCallsignRaw.equals(lastAutoFocusedCallsignRaw)) {
return;
}
focusCallsignRaw(selectedCallsignRaw);
} }
private void renderStations() { private void renderStations() {
String stationsJson = toStationsJson(lastSnapshots);
if (stationsJson.equals(lastRenderedStationsJson)) {
return;
}
lastRenderedStationsJson = stationsJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setStations(" + toJsStringLiteral(toStationsJson(lastSnapshots)) + ");" "window.kstMapApi.setStations(" + toJsStringLiteral(stationsJson) + ");"
); );
} }
private void renderBeam() { private void renderBeam() {
if (!Double.isFinite(homeLatitudeDeg) String beamJson = "null";
|| !Double.isFinite(homeLongitudeDeg)
|| beamWidthDeg <= 0.0
|| maxQrbKm <= 0.0) {
executeMapScriptSafely("window.kstMapApi.setBeam('null');"); if (Double.isFinite(homeLatitudeDeg)
return; && Double.isFinite(homeLongitudeDeg)
} && beamWidthDeg > 0.0
&& maxQrbKm > 0.0) {
List<double[]> sectorPoints = buildBeamPolygon(homeLatitudeDeg, homeLongitudeDeg, antennaAzimuthDeg, beamWidthDeg, maxQrbKm); List<double[]> sectorPoints = buildBeamPolygon(homeLatitudeDeg, homeLongitudeDeg, antennaAzimuthDeg, beamWidthDeg, maxQrbKm);
beamJson = toPointArrayJson(sectorPoints);
}
if (beamJson.equals(lastRenderedBeamJson)) {
return;
}
lastRenderedBeamJson = beamJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setBeam(" + toJsStringLiteral(toPointArrayJson(sectorPoints)) + ");" "window.kstMapApi.setBeam(" + toJsStringLiteral(beamJson) + ");"
); );
} }
private void renderConnectionLine() { private void renderConnectionLine() {
if (lastSelectedSnapshot == null || !lastSelectedSnapshot.hasUsablePosition() String connectionJson = "null";
|| !Double.isFinite(homeLatitudeDeg) || !Double.isFinite(homeLongitudeDeg)) {
executeMapScriptSafely("window.kstMapApi.setConnection('null');"); if (lastSelectedSnapshot != null && lastSelectedSnapshot.hasUsablePosition()
return; && Double.isFinite(homeLatitudeDeg) && Double.isFinite(homeLongitudeDeg)) {
}
List<double[]> points = List.of( List<double[]> points = List.of(
new double[]{homeLatitudeDeg, homeLongitudeDeg}, new double[]{homeLatitudeDeg, homeLongitudeDeg},
new double[]{lastSelectedSnapshot.latitudeDeg(), lastSelectedSnapshot.longitudeDeg()} new double[]{lastSelectedSnapshot.latitudeDeg(), lastSelectedSnapshot.longitudeDeg()}
); );
connectionJson = toPointArrayJson(points);
}
if (connectionJson.equals(lastRenderedConnectionJson)) {
return;
}
lastRenderedConnectionJson = connectionJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setConnection(" + toJsStringLiteral(toPointArrayJson(points)) + ");" "window.kstMapApi.setConnection(" + toJsStringLiteral(connectionJson) + ");"
); );
} }
@@ -1011,11 +1071,19 @@ public final class StationMapView {
+ " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx()) + " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx())
+ " cells=" + visibleGridCells.size()); + " cells=" + visibleGridCells.size());
String gridJson = toGridJson(visibleGridCells, renderPlan);
if (gridJson.equals(lastRenderedGridJson)) {
return;
}
lastRenderedGridJson = gridJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setGrid(" + toJsStringLiteral(toGridJson(visibleGridCells, renderPlan)) + ");" "window.kstMapApi.setGrid(" + toJsStringLiteral(gridJson) + ");"
); );
} }
private List<double[]> buildBeamPolygon(double startLatDeg, private List<double[]> buildBeamPolygon(double startLatDeg,
double startLonDeg, double startLonDeg,
double centerAzimuthDeg, double centerAzimuthDeg,
@@ -19,8 +19,10 @@ import java.util.concurrent.Executors;
*/ */
final class TileProxyServer { final class TileProxyServer {
private static final int CACHE_MAX = 512; private static final int CACHE_MAX = 2048;
private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool"; private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool";
private static final int TILE_PROXY_THREADS = 12;
private static final int TILE_PROXY_BACKLOG = 128;
private final ServerSocket serverSocket; private final ServerSocket serverSocket;
private final ExecutorService executor; private final ExecutorService executor;
@@ -39,9 +41,9 @@ final class TileProxyServer {
} }
}); });
this.serverSocket = new ServerSocket(0, 16, InetAddress.getByName("127.0.0.1")); this.serverSocket = new ServerSocket(0, TILE_PROXY_BACKLOG, InetAddress.getByName("127.0.0.1"));
this.executor = Executors.newFixedThreadPool(4, r -> { this.executor = Executors.newFixedThreadPool(TILE_PROXY_THREADS, r -> {
Thread t = new Thread(r, "tile-proxy"); Thread t = new Thread(r, "tile-proxy");
t.setDaemon(true); t.setDaemon(true);
return t; return t;
@@ -132,6 +134,7 @@ final class TileProxyServer {
+ "Content-Type: image/png\r\n" + "Content-Type: image/png\r\n"
+ "Content-Length: " + tileData.length + "\r\n" + "Content-Length: " + tileData.length + "\r\n"
+ "Cache-Control: max-age=86400\r\n" + "Cache-Control: max-age=86400\r\n"
+ "Access-Control-Allow-Origin: *\r\n"
+ "Connection: close\r\n" + "Connection: close\r\n"
+ "\r\n"; + "\r\n";
out.write(header.getBytes()); out.write(header.getBytes());
+1
View File
@@ -6,6 +6,7 @@ module praktiKST {
requires java.sql; requires java.sql;
requires javafx.media; requires javafx.media;
requires jdk.jsobject; requires jdk.jsobject;
requires jlayer;
requires java.net.http; requires java.net.http;
requires java.desktop; requires java.desktop;
requires jdk.crypto.ec; requires jdk.crypto.ec;
+1
View File
@@ -2370,3 +2370,4 @@ DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd43
9A2HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null 9A2HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null 9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300true; wkd3400false; wkd5600false; wkd10Gfalse ; null 9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300true; wkd3400false; wkd5600false; wkd10Gfalse ; null
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz