Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91812a3105 | |||
| 372d2f450a | |||
| f160b07bf4 | |||
| 307062eb35 | |||
| 72d5df2c70 | |||
| 19f9f1f3a6 |
@@ -16,6 +16,7 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-maven-plugin</artifactId>
|
||||
@@ -24,6 +25,34 @@
|
||||
<mainClass>com.rdesk.Main</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.3</version>
|
||||
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
|
||||
<configuration>
|
||||
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.rdesk.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
@@ -91,6 +120,30 @@
|
||||
<artifactId>languagesupport</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
<!-- multimedia -->
|
||||
<dependency>
|
||||
<groupId>com.googlecode.soundlibs</groupId>
|
||||
<artifactId>mp3spi</artifactId>
|
||||
<version>1.9.5.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jflac</groupId>
|
||||
<artifactId>jflac-codec</artifactId>
|
||||
<version>1.5.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.bytedeco</groupId>
|
||||
<artifactId>ffmpeg</artifactId>
|
||||
<version>6.1.1-1.5.10</version>
|
||||
<classifier>windows-x86_64</classifier>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.bytedeco</groupId>
|
||||
<artifactId>javacv</artifactId>
|
||||
<version>1.5.10</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -1,95 +1,16 @@
|
||||
package com.rdesk;
|
||||
|
||||
import com.rdesk.applications.productivity.Calculator;
|
||||
import com.rdesk.applications.productivity.browsing.TabbedBrowser;
|
||||
import com.rdesk.applications.productivity.code.CodeDesk;
|
||||
import com.rdesk.applications.system.Exit;
|
||||
import com.rdesk.applications.productivity.Notes;
|
||||
import com.rdesk.applications.system.TaskManager;
|
||||
import com.rdesk.applications.system.filehaj.FileExplorerApp;
|
||||
import com.rdesk.kernel.*;
|
||||
import com.rdesk.kernel.launcher.AppLauncher;
|
||||
|
||||
import javax.swing.*;
|
||||
import com.rdesk.applications.system.login.Login;
|
||||
import com.rdesk.kernel.BootProc;
|
||||
|
||||
public class Main {
|
||||
|
||||
private static final boolean RUN_IN_FS = true;
|
||||
private static final boolean USE_SYSDEFAULT_LAF = false;
|
||||
private static final boolean USE_FLATLAF = false;
|
||||
|
||||
public static void main(String[] args) {
|
||||
SwingUtilities.invokeLater(Main::boot);
|
||||
}
|
||||
boolean dbg = false;
|
||||
|
||||
private static void boot() {
|
||||
Kernel kernel = Kernel.get();
|
||||
kernel.boot();
|
||||
JFrame frame = new JFrame("rDesk");
|
||||
|
||||
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
|
||||
frame.addWindowListener(new java.awt.event.WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(java.awt.event.WindowEvent e) {
|
||||
kernel.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
if (USE_FLATLAF) {
|
||||
try {
|
||||
UIManager.setLookAndFeel(
|
||||
"com.formdev.flatlaf.themes.FlatMacLightLaf"
|
||||
);
|
||||
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
|
||||
JOptionPane.showMessageDialog(
|
||||
null,
|
||||
ex.getMessage() + "@USE_FLATLAF"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (USE_SYSDEFAULT_LAF) {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
|
||||
JOptionPane.showMessageDialog(
|
||||
null,
|
||||
ex.getMessage() + "@USE_SYSDEFAULT_LAF"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (RUN_IN_FS) {
|
||||
frame.setUndecorated(true);
|
||||
frame.setExtendedState(JFrame.MAXIMIZED_BOTH);
|
||||
if (!dbg) {
|
||||
Login.main(args);
|
||||
} else {
|
||||
frame.setSize(1200, 800);
|
||||
frame.setLocationRelativeTo(null);
|
||||
BootProc.boot();
|
||||
}
|
||||
|
||||
JDesktopPane desktop = new JDesktopPane();
|
||||
kernel.windows().attachDesktop(desktop);
|
||||
|
||||
AppLauncher launcher = new AppLauncher(kernel);
|
||||
|
||||
registerSystemApps(launcher);
|
||||
|
||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, launcher, desktop);
|
||||
split.setDividerSize(0);
|
||||
split.setEnabled(false);
|
||||
|
||||
frame.setContentPane(split);
|
||||
frame.setVisible(true);
|
||||
}
|
||||
|
||||
private static void registerSystemApps(AppLauncher launcher) {
|
||||
|
||||
launcher.addApp(new Notes(), "Productivity");
|
||||
launcher.addApp(new TabbedBrowser(), "Productivity");
|
||||
launcher.addApp(new CodeDesk(), "Productivity");
|
||||
launcher.addApp(new Calculator(), "Productivity");
|
||||
launcher.addApp(new Exit(), "System");
|
||||
launcher.addApp(new TaskManager(), "System");
|
||||
launcher.addApp(new FileExplorerApp(), "System");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.rdesk.applications.entertainment.comic;
|
||||
|
||||
public class ComicData {
|
||||
private int num;
|
||||
private String title;
|
||||
private String img;
|
||||
private String alt;
|
||||
|
||||
// Gson requires default constructor
|
||||
public ComicData() {}
|
||||
|
||||
public int getNum() {
|
||||
return num;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getImg() {
|
||||
return img;
|
||||
}
|
||||
|
||||
public String getAlt() {
|
||||
return alt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.rdesk.applications.entertainment.comic;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
public class ComicFetcher {
|
||||
private static final String BASE_URL = "https://xkcd.com/";
|
||||
private static final String INFO_URL = BASE_URL + "info.0.json";
|
||||
private static final String COMIC_URL_TEMPLATE = BASE_URL + "%d/info.0.json";
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final Gson gson;
|
||||
|
||||
public ComicFetcher() {
|
||||
this.httpClient = HttpClient.newHttpClient();
|
||||
this.gson = new Gson();
|
||||
}
|
||||
|
||||
public int fetchLatestComicNumber() throws IOException, InterruptedException {
|
||||
String json = fetchJson(INFO_URL);
|
||||
LatestInfo info = gson.fromJson(json, LatestInfo.class);
|
||||
if (info == null || info.num == 0) {
|
||||
throw new IOException("api response error");
|
||||
}
|
||||
return info.num;
|
||||
}
|
||||
|
||||
public ComicData fetchComicData(int num) throws IOException, InterruptedException {
|
||||
String url = String.format(COMIC_URL_TEMPLATE, num);
|
||||
String json = fetchJson(url);
|
||||
ComicData comic = gson.fromJson(json, ComicData.class);
|
||||
if (comic == null || comic.getImg() == null) {
|
||||
throw new IOException("error , idk bro #" + num);
|
||||
}
|
||||
return comic;
|
||||
}
|
||||
|
||||
private String fetchJson(String urlString) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(urlString))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode() + " for " + urlString);
|
||||
}
|
||||
return response.body();
|
||||
}
|
||||
|
||||
private static class LatestInfo {
|
||||
int num;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.rdesk.applications.entertainment.comic;
|
||||
|
||||
import com.rdesk.kernel.DesktopApp;
|
||||
import com.rdesk.kernel.Kernel;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
|
||||
public class Xkcd implements DesktopApp {
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "xkcd";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "XKCD";
|
||||
}
|
||||
|
||||
@Override
|
||||
public JInternalFrame createWindow(Kernel kernel) {
|
||||
JInternalFrame frame = new JInternalFrame(
|
||||
"XKCD",
|
||||
true, true, true, true
|
||||
);
|
||||
frame.setSize(800, 700);
|
||||
frame.setLayout(new BorderLayout(5, 5));
|
||||
|
||||
JPanel topPanel = new JPanel(new BorderLayout(5, 0));
|
||||
JButton refreshButton = new JButton("Random Comic");
|
||||
JLabel titleLabel = new JLabel(" ", SwingConstants.CENTER);
|
||||
titleLabel.setFont(new Font("SansSerif", Font.BOLD, 14));
|
||||
topPanel.add(refreshButton, BorderLayout.WEST);
|
||||
topPanel.add(titleLabel, BorderLayout.CENTER);
|
||||
frame.add(topPanel, BorderLayout.NORTH);
|
||||
|
||||
ZoomableImagePanel imagePanel = new ZoomableImagePanel();
|
||||
frame.add(imagePanel, BorderLayout.CENTER);
|
||||
|
||||
JTextArea altTextArea = new JTextArea(4, 40);
|
||||
altTextArea.setEditable(false);
|
||||
altTextArea.setLineWrap(true);
|
||||
altTextArea.setWrapStyleWord(true);
|
||||
altTextArea.setFont(new Font("SansSerif", Font.PLAIN, 12));
|
||||
JScrollPane altScrollPane = new JScrollPane(altTextArea);
|
||||
frame.add(altScrollPane, BorderLayout.SOUTH);
|
||||
|
||||
new XkcdController(refreshButton, titleLabel, altTextArea, imagePanel).loadRandomComic();
|
||||
|
||||
frame.setVisible(true);
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.rdesk.applications.entertainment.comic;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public class XkcdController {
|
||||
private final JButton refreshButton;
|
||||
private final JLabel titleLabel;
|
||||
private final JTextArea altTextArea;
|
||||
private final ZoomableImagePanel imagePanel;
|
||||
private final ComicFetcher fetcher;
|
||||
|
||||
public XkcdController(JButton refreshButton, JLabel titleLabel,
|
||||
JTextArea altTextArea, ZoomableImagePanel imagePanel) {
|
||||
this.refreshButton = refreshButton;
|
||||
this.titleLabel = titleLabel;
|
||||
this.altTextArea = altTextArea;
|
||||
this.imagePanel = imagePanel;
|
||||
this.fetcher = new ComicFetcher();
|
||||
|
||||
refreshButton.addActionListener(e -> loadRandomComic());
|
||||
}
|
||||
|
||||
public void loadRandomComic() {
|
||||
setLoadingState(true);
|
||||
SwingWorker<ComicData, Void> worker = new SwingWorker<>() {
|
||||
@Override
|
||||
protected ComicData doInBackground() throws Exception {
|
||||
int latest = fetcher.fetchLatestComicNumber();
|
||||
int randomNum = (int) (Math.random() * latest) + 1;
|
||||
return fetcher.fetchComicData(randomNum);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done() {
|
||||
setLoadingState(false);
|
||||
try {
|
||||
ComicData comic = get();
|
||||
if (comic != null) {
|
||||
displayComic(comic);
|
||||
} else {
|
||||
showError("cant load comic");
|
||||
}
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
showError("Error: " + e.getCause().getMessage());
|
||||
}
|
||||
}
|
||||
};
|
||||
worker.execute();
|
||||
}
|
||||
|
||||
private void displayComic(ComicData comic) {
|
||||
titleLabel.setText(String.format("#%d: %s", comic.getNum(), comic.getTitle()));
|
||||
altTextArea.setText(comic.getAlt());
|
||||
|
||||
SwingWorker<BufferedImage, Void> imageWorker = new SwingWorker<>() {
|
||||
@Override
|
||||
protected BufferedImage doInBackground() throws Exception {
|
||||
URL url = new URL(comic.getImg());
|
||||
return javax.imageio.ImageIO.read(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void done() {
|
||||
try {
|
||||
BufferedImage img = get();
|
||||
imagePanel.setImage(img);
|
||||
} catch (Exception e) {
|
||||
showError("failed to load image: " + e.getMessage());
|
||||
imagePanel.clearImage();
|
||||
}
|
||||
}
|
||||
};
|
||||
imageWorker.execute();
|
||||
}
|
||||
|
||||
private void setLoadingState(boolean loading) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
refreshButton.setEnabled(!loading);
|
||||
if (loading) {
|
||||
titleLabel.setText("fetching image");
|
||||
altTextArea.setText("");
|
||||
imagePanel.clearImage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showError(String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
|
||||
titleLabel.setText(" ");
|
||||
altTextArea.setText("");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.rdesk.applications.entertainment.comic;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseWheelEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
public class ZoomableImagePanel extends JPanel {
|
||||
private BufferedImage originalImage;
|
||||
private double zoom = 1.0;
|
||||
private Point dragStart;
|
||||
private Point viewportStart;
|
||||
private final JViewport viewport;
|
||||
|
||||
private static final double ZOOM_STEP = 0.1;
|
||||
private static final double MIN_ZOOM = 0.2;
|
||||
private static final double MAX_ZOOM = 3.0;
|
||||
|
||||
public ZoomableImagePanel() {
|
||||
setLayout(new BorderLayout());
|
||||
JLabel imageLabel = new JLabel("", SwingConstants.CENTER);
|
||||
JScrollPane scrollPane = new JScrollPane(imageLabel);
|
||||
add(scrollPane, BorderLayout.CENTER);
|
||||
this.viewport = scrollPane.getViewport();
|
||||
|
||||
imageLabel.addMouseWheelListener(this::onMouseWheel);
|
||||
|
||||
DragHandler dragHandler = new DragHandler(imageLabel);
|
||||
imageLabel.addMouseListener(dragHandler);
|
||||
imageLabel.addMouseMotionListener(dragHandler);
|
||||
}
|
||||
|
||||
public void setImage(BufferedImage image) {
|
||||
this.originalImage = image;
|
||||
this.zoom = 1.0;
|
||||
updateDisplay();
|
||||
SwingUtilities.invokeLater(() -> viewport.setViewPosition(new Point(0, 0)));
|
||||
}
|
||||
|
||||
public void clearImage() {
|
||||
this.originalImage = null;
|
||||
removeImageFromLabel();
|
||||
}
|
||||
|
||||
private void updateDisplay() {
|
||||
if (originalImage == null) {
|
||||
removeImageFromLabel();
|
||||
return;
|
||||
}
|
||||
JLabel label = getImageLabel();
|
||||
int newWidth = (int) (originalImage.getWidth() * zoom);
|
||||
int newHeight = (int) (originalImage.getHeight() * zoom);
|
||||
Image scaled = originalImage.getScaledInstance(newWidth, newHeight, Image.SCALE_SMOOTH);
|
||||
label.setIcon(new ImageIcon(scaled));
|
||||
label.setText("");
|
||||
label.setPreferredSize(new Dimension(newWidth, newHeight));
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
private void onMouseWheel(MouseWheelEvent e) {
|
||||
if (originalImage == null) return;
|
||||
double oldZoom = zoom;
|
||||
double newZoom = zoom + (e.getWheelRotation() < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
||||
newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
|
||||
if (newZoom == zoom) return;
|
||||
|
||||
Point mouseOnScreen = e.getPoint();
|
||||
Point viewPos = viewport.getViewPosition();
|
||||
double xRatio = (viewPos.x + mouseOnScreen.x) / (double) viewport.getExtentSize().width;
|
||||
double yRatio = (viewPos.y + mouseOnScreen.y) / (double) viewport.getExtentSize().height;
|
||||
|
||||
zoom = newZoom;
|
||||
updateDisplay();
|
||||
|
||||
Dimension contentSize = getImageLabel().getPreferredSize();
|
||||
Dimension viewSize = viewport.getExtentSize();
|
||||
int newX = (int) (contentSize.width * xRatio - viewSize.width / 2.0);
|
||||
int newY = (int) (contentSize.height * yRatio - viewSize.height / 2.0);
|
||||
newX = Math.max(0, Math.min(newX, contentSize.width - viewSize.width));
|
||||
newY = Math.max(0, Math.min(newY, contentSize.height - viewSize.height));
|
||||
viewport.setViewPosition(new Point(newX, newY));
|
||||
}
|
||||
|
||||
private JLabel getImageLabel() {
|
||||
return (JLabel) ((JScrollPane) getComponent(0)).getViewport().getView();
|
||||
}
|
||||
|
||||
private void removeImageFromLabel() {
|
||||
JLabel label = getImageLabel();
|
||||
label.setIcon(null);
|
||||
label.setText("Loading");
|
||||
label.setPreferredSize(null);
|
||||
revalidate();
|
||||
}
|
||||
|
||||
private class DragHandler extends MouseAdapter {
|
||||
private final JLabel label;
|
||||
|
||||
DragHandler(JLabel label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
if (!SwingUtilities.isLeftMouseButton(e)) return;
|
||||
dragStart = e.getPoint();
|
||||
viewportStart = viewport.getViewPosition();
|
||||
label.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
if (!SwingUtilities.isLeftMouseButton(e)) return;
|
||||
dragStart = null;
|
||||
viewportStart = null;
|
||||
label.setCursor(Cursor.getDefaultCursor());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
if (dragStart == null || viewportStart == null) return;
|
||||
int deltaX = dragStart.x - e.getX();
|
||||
int deltaY = dragStart.y - e.getY();
|
||||
Point newPos = new Point(viewportStart.x + deltaX, viewportStart.y + deltaY);
|
||||
|
||||
Dimension contentSize = label.getPreferredSize();
|
||||
Dimension viewSize = viewport.getExtentSize();
|
||||
newPos.x = Math.max(0, Math.min(newPos.x, contentSize.width - viewSize.width));
|
||||
newPos.y = Math.max(0, Math.min(newPos.y, contentSize.height - viewSize.height));
|
||||
viewport.setViewPosition(newPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import org.bytedeco.javacv.FFmpegFrameGrabber;
|
||||
import org.bytedeco.javacv.Frame;
|
||||
import org.bytedeco.javacv.FrameGrabber;
|
||||
|
||||
import javax.sound.sampled.*;
|
||||
import java.nio.ShortBuffer;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.*;
|
||||
|
||||
public class AudioEngine {
|
||||
|
||||
public enum State { STOPPED, PLAYING, PAUSED }
|
||||
|
||||
private volatile State state = State.STOPPED;
|
||||
private volatile long positionFrames = 0;
|
||||
private volatile long totalFrames = 0;
|
||||
private AudioFormat currentFormat;
|
||||
private volatile float volume = 0.8f; // 0..1
|
||||
|
||||
private static final int EQ_BANDS = 10;
|
||||
private static final float[] EQ_FREQS = { 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 };
|
||||
private final float[] eqGainDb = new float[EQ_BANDS];
|
||||
private final float[][] bqCoeff = new float[EQ_BANDS][5];
|
||||
private final float[][][] bqState = new float[EQ_BANDS][2][2];
|
||||
|
||||
private static final int FFT_SIZE = 4096;
|
||||
private Consumer<float[]> fftListener;
|
||||
private final float[] fftBuffer = new float[FFT_SIZE];
|
||||
private int fftPos = 0;
|
||||
|
||||
private ExecutorService playerThread;
|
||||
private volatile boolean stopRequested = false;
|
||||
private volatile boolean pauseRequested = false;
|
||||
|
||||
private Runnable onTrackEnd;
|
||||
private Consumer<Long> onPositionUpdate;
|
||||
private SourceDataLine dataLine;
|
||||
|
||||
private Track currentTrack = null;
|
||||
private double requestedSeekFraction = 0.0;
|
||||
private boolean seekPending = false;
|
||||
|
||||
public AudioEngine() { initEq(); }
|
||||
|
||||
public void setOnTrackEnd(Runnable r) { this.onTrackEnd = r; }
|
||||
public void setOnPositionUpdate(Consumer<Long> c) { this.onPositionUpdate = c; }
|
||||
public void setFftListener(Consumer<float[]> c) { this.fftListener = c; }
|
||||
|
||||
public State getState() { return state; }
|
||||
public long getPositionFrames() { return positionFrames; }
|
||||
public long getTotalFrames() { return totalFrames; }
|
||||
public AudioFormat getFormat() { return currentFormat; }
|
||||
|
||||
public void setVolume(float v) {
|
||||
volume = Math.max(0f, Math.min(1f, v));
|
||||
applyVolumeToLine();
|
||||
}
|
||||
|
||||
public void play(Track track) {
|
||||
stop();
|
||||
playerThread = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "wavy-audio");
|
||||
t.setDaemon(true);
|
||||
t.setPriority(Thread.MAX_PRIORITY - 1);
|
||||
return t;
|
||||
});
|
||||
stopRequested = false;
|
||||
pauseRequested = false;
|
||||
playerThread.submit(() -> playbackLoop(track));
|
||||
}
|
||||
|
||||
public void togglePause() {
|
||||
if (state == State.PLAYING) {
|
||||
pauseRequested = true;
|
||||
state = State.PAUSED;
|
||||
} else if (state == State.PAUSED) {
|
||||
pauseRequested = false;
|
||||
state = State.PLAYING;
|
||||
synchronized (this) { notifyAll(); }
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
stopRequested = true;
|
||||
pauseRequested = false;
|
||||
synchronized (this) { notifyAll(); }
|
||||
if (playerThread != null) {
|
||||
playerThread.shutdownNow();
|
||||
try { playerThread.awaitTermination(2, TimeUnit.SECONDS); }
|
||||
catch (InterruptedException ignored) {}
|
||||
playerThread = null;
|
||||
}
|
||||
closeLine();
|
||||
state = State.STOPPED;
|
||||
positionFrames = 0;
|
||||
}
|
||||
|
||||
public void seekFraction(double fraction) {
|
||||
if (currentTrack == null) return;
|
||||
requestedSeekFraction = Math.max(0.0, Math.min(1.0, fraction));
|
||||
seekPending = true;
|
||||
}
|
||||
|
||||
public void setEqBand(int band, float db) {
|
||||
if (band < 0 || band >= EQ_BANDS) return;
|
||||
eqGainDb[band] = db;
|
||||
recomputeBiquad(band);
|
||||
}
|
||||
|
||||
public float getEqBand(int band) { return eqGainDb[band]; }
|
||||
public float[] getEqFrequencies() { return EQ_FREQS.clone(); }
|
||||
public static float[] getStaticEqFrequencies() { return EQ_FREQS.clone(); }
|
||||
|
||||
public void shutdown() { stop(); }
|
||||
|
||||
private void playbackLoop(Track track) {
|
||||
currentTrack = track;
|
||||
state = State.PLAYING;
|
||||
|
||||
FFmpegFrameGrabber grabber = null;
|
||||
try {
|
||||
grabber = new FFmpegFrameGrabber(track.getFilePath().toFile());
|
||||
grabber.setSampleMode(FrameGrabber.SampleMode.SHORT); // always 16-bit signed interleaved
|
||||
grabber.setOption("probesize", "5000000");
|
||||
grabber.setOption("analyzeduration", "5000000");
|
||||
grabber.start();
|
||||
|
||||
final int sampleRate = grabber.getSampleRate();
|
||||
final int channels = grabber.getAudioChannels();
|
||||
final int frameSize = channels * 2; // 16-bit to 2 bytes per sample per channel
|
||||
|
||||
currentFormat = new AudioFormat(
|
||||
AudioFormat.Encoding.PCM_SIGNED,
|
||||
sampleRate, 16, channels,
|
||||
frameSize, sampleRate, false);
|
||||
|
||||
long durationMicros = grabber.getLengthInTime();
|
||||
if (durationMicros > 0) {
|
||||
long ffmpegSec = durationMicros / 1_000_000L;
|
||||
totalFrames = (long)(durationMicros / 1_000_000.0 * sampleRate);
|
||||
if (track.getDurationSeconds() == 0)
|
||||
track.setDurationSeconds(ffmpegSec);
|
||||
} else {
|
||||
if (track.getDurationSeconds() > 0)
|
||||
totalFrames = track.getDurationSeconds() * sampleRate;
|
||||
else
|
||||
totalFrames = 0;
|
||||
}
|
||||
|
||||
DataLine.Info lineInfo = new DataLine.Info(SourceDataLine.class, currentFormat);
|
||||
dataLine = (SourceDataLine) AudioSystem.getLine(lineInfo);
|
||||
dataLine.open(currentFormat, (int)(sampleRate * frameSize * 0.1f));
|
||||
dataLine.start();
|
||||
applyVolumeToLine();
|
||||
|
||||
resetBiquadState();
|
||||
positionFrames = 0;
|
||||
|
||||
float[] samples = new float[8192];
|
||||
|
||||
while (!stopRequested) {
|
||||
|
||||
synchronized (this) {
|
||||
while (pauseRequested && !stopRequested) {
|
||||
try { wait(50); } catch (InterruptedException e) { break; }
|
||||
}
|
||||
}
|
||||
if (stopRequested) break;
|
||||
|
||||
if (seekPending) {
|
||||
seekPending = false;
|
||||
if (totalFrames > 0) {
|
||||
long targetMicros = (long)(requestedSeekFraction
|
||||
* (durationMicros > 0 ? durationMicros
|
||||
: track.getDurationSeconds() * 1_000_000L));
|
||||
grabber.setTimestamp(targetMicros, /* keyFrame */ true);
|
||||
positionFrames = (long)((targetMicros / 1_000_000.0) * sampleRate);
|
||||
}
|
||||
resetBiquadState();
|
||||
continue;
|
||||
}
|
||||
|
||||
Frame frame = grabber.grabSamples();
|
||||
if (frame == null || frame.samples == null || frame.samples.length == 0) {
|
||||
// End of stream
|
||||
break;
|
||||
}
|
||||
|
||||
ShortBuffer sb = (ShortBuffer) frame.samples[0];
|
||||
sb.rewind();
|
||||
int sampleCount = sb.remaining();
|
||||
|
||||
// Grow float buffer if needed
|
||||
if (samples.length < sampleCount) samples = new float[sampleCount];
|
||||
|
||||
// short to float (-1..1)
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
samples[i] = sb.get() / 32768.0f;
|
||||
|
||||
applyEq(samples, sampleCount, channels);
|
||||
feedFft(samples, sampleCount, channels);
|
||||
|
||||
// float to short to bytes
|
||||
byte[] pcmBytes = new byte[sampleCount * 2];
|
||||
for (int i = 0; i < sampleCount; i++) {
|
||||
float s = Math.max(-1f, Math.min(1f, samples[i]));
|
||||
short sh = (short)(s * 32767f);
|
||||
pcmBytes[i*2] = (byte)( sh & 0xFF);
|
||||
pcmBytes[i*2 + 1] = (byte)((sh >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
dataLine.write(pcmBytes, 0, pcmBytes.length);
|
||||
|
||||
positionFrames += sampleCount / channels;
|
||||
if (onPositionUpdate != null) onPositionUpdate.accept(positionFrames);
|
||||
}
|
||||
|
||||
if (!stopRequested) dataLine.drain();
|
||||
closeLine();
|
||||
grabber.stop();
|
||||
grabber.release();
|
||||
grabber = null;
|
||||
|
||||
state = State.STOPPED;
|
||||
if (!stopRequested && onTrackEnd != null) onTrackEnd.run();
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("[Wavy] Playback error for: " + track.getFilePath() + " — " + e.getMessage());
|
||||
state = State.STOPPED;
|
||||
if (grabber != null) {
|
||||
try { grabber.stop(); grabber.release(); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closeLine() {
|
||||
if (dataLine != null) {
|
||||
if (dataLine.isOpen()) { dataLine.stop(); dataLine.close(); }
|
||||
dataLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void applyVolumeToLine() {
|
||||
if (dataLine == null || !dataLine.isOpen()) return;
|
||||
if (dataLine.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
|
||||
FloatControl gc = (FloatControl) dataLine.getControl(FloatControl.Type.MASTER_GAIN);
|
||||
float minDb = gc.getMinimum();
|
||||
float maxDb = 0f;
|
||||
float db = (volume == 0f) ? minDb : minDb + (maxDb - minDb) * volume;
|
||||
gc.setValue(Math.max(minDb, Math.min(maxDb, db)));
|
||||
}
|
||||
}
|
||||
|
||||
private void initEq() {
|
||||
for (int b = 0; b < EQ_BANDS; b++) { eqGainDb[b] = 0f; recomputeBiquad(b); }
|
||||
}
|
||||
|
||||
// math heavy shit, this code may burn your eyes out.
|
||||
// it works, but i dont like how it works
|
||||
private void recomputeBiquad(int band) {
|
||||
float sampleRate = (currentFormat != null) ? currentFormat.getSampleRate() : 44100f;
|
||||
float f0 = EQ_FREQS[band];
|
||||
float dBgain= eqGainDb[band];
|
||||
float Q = 1.41421356f;
|
||||
float A = (float) Math.pow(10.0, dBgain / 40.0);
|
||||
double w0 = 2.0 * Math.PI * f0 / sampleRate;
|
||||
double cosW = Math.cos(w0);
|
||||
double sinW = Math.sin(w0);
|
||||
double alpha= sinW / (2.0 * Q);
|
||||
double b0 = 1 + alpha*A, b1 = -2*cosW, b2 = 1 - alpha*A;
|
||||
double a0 = 1 + alpha/A, a1 = -2*cosW, a2 = 1 - alpha/A;
|
||||
bqCoeff[band][0] = (float)(b0/a0); bqCoeff[band][1] = (float)(b1/a0);
|
||||
bqCoeff[band][2] = (float)(b2/a0); bqCoeff[band][3] = (float)(a1/a0);
|
||||
bqCoeff[band][4] = (float)(a2/a0);
|
||||
}
|
||||
|
||||
private void applyEq(float[] samples, int count, int channels) {
|
||||
for (int band = 0; band < EQ_BANDS; band++) {
|
||||
if (Math.abs(eqGainDb[band]) < 0.01f) continue;
|
||||
float b0=bqCoeff[band][0], b1=bqCoeff[band][1], b2=bqCoeff[band][2];
|
||||
float a1=bqCoeff[band][3], a2=bqCoeff[band][4];
|
||||
for (int i = 0; i < count; i++) {
|
||||
int ch = i % channels;
|
||||
float x = samples[i];
|
||||
float z1 = bqState[band][ch][0], z2 = bqState[band][ch][1];
|
||||
float y = b0*x + z1;
|
||||
bqState[band][ch][0] = b1*x - a1*y + z2;
|
||||
bqState[band][ch][1] = b2*x - a2*y;
|
||||
samples[i] = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void resetBiquadState() {
|
||||
for (float[][] b : bqState) for (float[] c : b) { c[0]=0; c[1]=0; }
|
||||
}
|
||||
|
||||
// FFT (Cooley-Tukey radix-2 DIT — unchanged)
|
||||
// Links / references which may be helpful while reading through the code:
|
||||
// https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm
|
||||
// https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm#The_radix-2_DIT_case
|
||||
|
||||
private void feedFft(float[] samples, int count, int channels) {
|
||||
if (fftListener == null) return;
|
||||
for (int i = 0; i < count; i += channels) {
|
||||
float mono = 0;
|
||||
for (int c = 0; c < channels; c++) mono += samples[i+c];
|
||||
mono /= channels;
|
||||
fftBuffer[fftPos++] = mono;
|
||||
if (fftPos == FFT_SIZE) {
|
||||
fftListener.accept(computeFFT(fftBuffer));
|
||||
System.arraycopy(fftBuffer, FFT_SIZE/2, fftBuffer, 0, FFT_SIZE/2);
|
||||
fftPos = FFT_SIZE/2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static float[] computeFFT(float[] x) {
|
||||
int n = x.length;
|
||||
float[] re = x.clone(), im = new float[n];
|
||||
// hann window
|
||||
for (int i = 0; i < n; i++)
|
||||
re[i] *= 0.5f * (1 - (float)Math.cos(2*Math.PI*i/(n-1)));
|
||||
// bit reversal
|
||||
for (int j=1, i=0; j<n; j++) {
|
||||
int bit = n>>1;
|
||||
for (; (i&bit)!=0; bit>>=1) i^=bit;
|
||||
i^=bit;
|
||||
if (j<i) {
|
||||
float t=re[j]; re[j]=re[i]; re[i]=t;
|
||||
t=im[j]; im[j]=im[i]; im[i]=t;
|
||||
}
|
||||
}
|
||||
// cooley turkey algorithm
|
||||
for (int len=2; len<=n; len<<=1) {
|
||||
double angle=-2*Math.PI/len;
|
||||
float wRe=(float)Math.cos(angle), wIm=(float)Math.sin(angle);
|
||||
for (int i=0; i<n; i+=len) {
|
||||
float cRe=1, cIm=0;
|
||||
for (int j=0; j<len/2; j++) {
|
||||
float uRe=re[i+j], uIm=im[i+j];
|
||||
float vRe=re[i+j+len/2]*cRe - im[i+j+len/2]*cIm;
|
||||
float vIm=re[i+j+len/2]*cIm + im[i+j+len/2]*cRe;
|
||||
re[i+j]=uRe+vRe; im[i+j]=uIm+vIm;
|
||||
re[i+j+len/2]=uRe-vRe; im[i+j+len/2]=uIm-vIm;
|
||||
float nRe=cRe*wRe-cIm*wIm; cIm=cRe*wIm+cIm*wRe; cRe=nRe;
|
||||
}
|
||||
}
|
||||
}
|
||||
float[] mag = new float[n/2];
|
||||
for (int i=0; i<n/2; i++)
|
||||
mag[i] = (float)Math.sqrt(re[i]*re[i]+im[i]*im[i])/n;
|
||||
return mag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
public class CoverArtPanel extends JPanel {
|
||||
|
||||
private BufferedImage coverImage = null;
|
||||
private String trackTitle = "No track";
|
||||
private String artistName = "";
|
||||
|
||||
public CoverArtPanel() {
|
||||
setBorder(BorderFactory.createLoweredBevelBorder());
|
||||
setPreferredSize(new Dimension(130, 130));
|
||||
}
|
||||
|
||||
public void setTrack(Track track) {
|
||||
coverImage = track != null ? track.getCoverArt() : null;
|
||||
trackTitle = track != null ? track.getDisplayTitle() : "No track";
|
||||
artistName = track != null ? track.getArtist() : "";
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
super.paintComponent(g);
|
||||
int w = getWidth(), h = getHeight();
|
||||
if (coverImage != null) {
|
||||
Graphics2D g2 = (Graphics2D) g;
|
||||
g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
|
||||
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
double sx = (double) w / coverImage.getWidth();
|
||||
double sy = (double) h / coverImage.getHeight();
|
||||
double scale = Math.max(sx, sy);
|
||||
int dw = (int)(coverImage.getWidth() * scale);
|
||||
int dh = (int)(coverImage.getHeight() * scale);
|
||||
int dx = (w - dw) / 2, dy = (h - dh) / 2;
|
||||
g2.drawImage(coverImage, dx, dy, dw, dh, null);
|
||||
} else {
|
||||
g.setColor(UIManager.getColor("Panel.background"));
|
||||
g.fillRect(0, 0, w, h);
|
||||
g.setColor(UIManager.getColor("Label.foreground"));
|
||||
g.setFont(g.getFont().deriveFont(Font.BOLD, 10f));
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String line1 = trackTitle.length() > 20 ? trackTitle.substring(0,18)+"…" : trackTitle;
|
||||
String line2 = artistName.length() > 20 ? artistName.substring(0,18)+"…" : artistName;
|
||||
g.drawString(line1, (w - fm.stringWidth(line1))/2, h/2 - fm.getHeight()/2 + fm.getAscent());
|
||||
g.setFont(g.getFont().deriveFont(Font.PLAIN, 9f));
|
||||
fm = g.getFontMetrics();
|
||||
g.drawString(line2, (w - fm.stringWidth(line2))/2, h/2 + fm.getAscent() + 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
|
||||
public class EqualizerPanel extends JPanel {
|
||||
|
||||
private static final int BANDS = 10;
|
||||
private static final String[] FREQ_LABELS = {
|
||||
"32Hz","64Hz","125Hz","250Hz","500Hz","1kHz","2kHz","4kHz","8kHz","16kHz"
|
||||
};
|
||||
|
||||
// TODO: load from JSON in rsys directory (~/rsys/wavy_dat/eq/presets.json)
|
||||
// TODO-TODO: This is actually a really shitty idea? We dont distribute the User rsys folder! (Keep this)
|
||||
private static final String[][] PRESETS = {
|
||||
{"Flat", "0,0,0,0,0,0,0,0,0,0"},
|
||||
{"Bass Boost", "8,6,4,2,0,0,0,0,0,0"},
|
||||
{"Treble Boost", "0,0,0,0,0,0,2,4,6,8"},
|
||||
{"V-Shape", "6,4,2,-1,-3,-3,-1,2,4,6"},
|
||||
{"Rock", "4,3,1,0,-1,0,2,3,3,2"},
|
||||
{"Jazz", "3,2,1,2,0,-1,-1,0,1,2"},
|
||||
{"Classical", "0,0,0,0,0,0,-2,-3,-3,-2"},
|
||||
{"Electronic", "5,3,0,-1,-3,0,1,2,4,5"},
|
||||
};
|
||||
|
||||
private final AudioEngine engine;
|
||||
private final JSlider[] sliders = new JSlider[BANDS];
|
||||
private final JLabel[] dbLabels = new JLabel[BANDS];
|
||||
private final FreqCurvePanel curve;
|
||||
|
||||
public EqualizerPanel(AudioEngine engine) {
|
||||
this.engine = engine;
|
||||
setLayout(new BorderLayout(4, 4));
|
||||
setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
|
||||
|
||||
curve = new FreqCurvePanel();
|
||||
curve.setPreferredSize(new Dimension(0, 80));
|
||||
|
||||
add(buildTopBar(), BorderLayout.NORTH);
|
||||
add(curve, BorderLayout.CENTER);
|
||||
add(buildSliders(), BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
private JPanel buildTopBar() {
|
||||
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
|
||||
p.add(new JLabel("Preset:"));
|
||||
JComboBox<String> presetBox = new JComboBox<>();
|
||||
for (String[] pr : PRESETS) presetBox.addItem(pr[0]);
|
||||
presetBox.addActionListener(e -> applyPreset(presetBox.getSelectedIndex()));
|
||||
p.add(presetBox);
|
||||
JButton reset = new JButton("Reset");
|
||||
reset.addActionListener(e -> applyPreset(0));
|
||||
p.add(reset);
|
||||
return p;
|
||||
}
|
||||
|
||||
private JPanel buildSliders() {
|
||||
JPanel p = new JPanel(new GridLayout(1, BANDS, 2, 0));
|
||||
for (int i = 0; i < BANDS; i++) {
|
||||
final int band = i;
|
||||
JPanel col = new JPanel();
|
||||
col.setLayout(new BoxLayout(col, BoxLayout.Y_AXIS));
|
||||
|
||||
JLabel dbLbl = new JLabel("0 dB", SwingConstants.CENTER);
|
||||
dbLbl.setFont(dbLbl.getFont().deriveFont(9f));
|
||||
dbLbl.setAlignmentX(0.5f);
|
||||
dbLabels[i] = dbLbl;
|
||||
|
||||
JSlider sl = new JSlider(JSlider.VERTICAL, -120, 120, 0);
|
||||
sl.setPaintTicks(true);
|
||||
sl.setMajorTickSpacing(60);
|
||||
sl.setMinorTickSpacing(20);
|
||||
sl.setSnapToTicks(false);
|
||||
sl.setAlignmentX(0.5f);
|
||||
sliders[i] = sl;
|
||||
sl.addChangeListener(e -> {
|
||||
float db = sl.getValue() / 10.0f;
|
||||
engine.setEqBand(band, db);
|
||||
dbLabels[band].setText(String.format("%+.0f dB", db));
|
||||
curve.repaint();
|
||||
});
|
||||
|
||||
JLabel freqLbl = new JLabel(FREQ_LABELS[i], SwingConstants.CENTER);
|
||||
freqLbl.setFont(freqLbl.getFont().deriveFont(8f));
|
||||
freqLbl.setAlignmentX(0.5f);
|
||||
|
||||
col.add(dbLbl);
|
||||
col.add(sl);
|
||||
col.add(freqLbl);
|
||||
p.add(col);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private void applyPreset(int idx) {
|
||||
if (idx < 0 || idx >= PRESETS.length) return;
|
||||
String[] vals = PRESETS[idx][1].split(",");
|
||||
for (int i = 0; i < BANDS && i < vals.length; i++) {
|
||||
try {
|
||||
float db = Float.parseFloat(vals[i].trim());
|
||||
sliders[i].setValue(Math.round(db * 10));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private class FreqCurvePanel extends JPanel {
|
||||
FreqCurvePanel() {
|
||||
setBorder(BorderFactory.createTitledBorder("Frequency Response"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
super.paintComponent(g);
|
||||
Graphics2D g2 = (Graphics2D) g.create();
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
int w = getWidth(), h = getHeight();
|
||||
Insets ins = getInsets();
|
||||
int x0 = ins.left + 2, y0 = ins.top + 2;
|
||||
int cw = w - ins.left - ins.right - 4;
|
||||
int ch = h - ins.top - ins.bottom - 4;
|
||||
if (cw <= 0 || ch <= 0) { g2.dispose(); return; }
|
||||
|
||||
float midY = y0 + ch / 2.0f;
|
||||
|
||||
g2.setColor(Color.LIGHT_GRAY);
|
||||
g2.drawLine(x0, (int)midY, x0 + cw, (int)midY);
|
||||
|
||||
float logMin = (float)Math.log10(20.0);
|
||||
float logMax = (float)Math.log10(20000.0);
|
||||
int pts = cw;
|
||||
int[] xp = new int[pts], yp = new int[pts];
|
||||
for (int i = 0; i < pts; i++) {
|
||||
float f = (float)Math.pow(10, logMin + (logMax - logMin) * i / pts);
|
||||
double totalDb = 0;
|
||||
for (int b = 0; b < BANDS; b++) {
|
||||
float db = sliders[b].getValue() / 10.0f;
|
||||
if (Math.abs(db) < 0.01f) continue;
|
||||
float f0 = AudioEngine.getStaticEqFrequencies()[b];
|
||||
float Q = 1.41421356f;
|
||||
double ratio = f / f0;
|
||||
double lrSq = (ratio - 1.0/ratio) * (ratio - 1.0/ratio);
|
||||
double denom = 1 + lrSq / (Q * Q * (ratio + 1.0/ratio) * (ratio + 1.0/ratio));
|
||||
totalDb += db * denom;
|
||||
}
|
||||
float py = midY - (float)(totalDb / 12.0 * ch / 2 * 0.9);
|
||||
xp[i] = x0 + i;
|
||||
yp[i] = Math.max(y0, Math.min(y0 + ch, (int)py));
|
||||
}
|
||||
|
||||
g2.setColor(getForeground());
|
||||
g2.setStroke(new BasicStroke(1.5f));
|
||||
for (int i = 1; i < pts; i++) {
|
||||
g2.drawLine(xp[i-1], yp[i-1], xp[i], yp[i]);
|
||||
}
|
||||
|
||||
// Band markers ; i like blue (like Boris Johnson)!
|
||||
g2.setColor(Color.BLUE);
|
||||
for (int b = 0; b < BANDS; b++) {
|
||||
float f0 = AudioEngine.getStaticEqFrequencies()[b];
|
||||
float db = sliders[b].getValue() / 10.0f;
|
||||
float nx = x0 + (float)((Math.log10(f0) - logMin) / (logMax - logMin) * cw);
|
||||
float ny = midY - (db / 12.0f * ch / 2 * 0.9f);
|
||||
ny = Math.max(y0, Math.min(y0 + ch, ny));
|
||||
g2.fillOval((int)nx - 3, (int)ny - 3, 6, 6);
|
||||
}
|
||||
|
||||
g2.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class MetadataReader {
|
||||
|
||||
public static void populate(Track track) {
|
||||
String fmt = track.getFormat();
|
||||
try {
|
||||
switch (fmt) {
|
||||
case "MP3" -> readID3v2(track);
|
||||
case "FLAC" -> readFLAC(track);
|
||||
case "WAV" -> readWAV(track);
|
||||
}
|
||||
} catch (Exception ignored) {/* do jackshit */}
|
||||
}
|
||||
|
||||
// MP3 / ID3v2
|
||||
private static void readID3v2(Track track) throws IOException {
|
||||
try (RandomAccessFile raf = new RandomAccessFile(track.getFilePath().toFile(), "r")) {
|
||||
byte[] header = new byte[10];
|
||||
if (raf.read(header) < 10) return;
|
||||
if (header[0] != 'I' || header[1] != 'D' || header[2] != '3') return;
|
||||
|
||||
int majorVer = header[3] & 0xFF;
|
||||
if (majorVer < 3) return;
|
||||
|
||||
// syncsafe size
|
||||
long tagSize = ((header[6] & 0x7F) << 21)
|
||||
| ((header[7] & 0x7F) << 14)
|
||||
| ((header[8] & 0x7F) << 7)
|
||||
| (header[9] & 0x7F);
|
||||
|
||||
byte[] tagData = new byte[(int) tagSize];
|
||||
raf.readFully(tagData);
|
||||
|
||||
int pos = 0;
|
||||
while (pos + 10 <= tagData.length) {
|
||||
String frameId = new String(tagData, pos, 4, StandardCharsets.ISO_8859_1);
|
||||
if (frameId.charAt(0) == '\0') break; // padding
|
||||
|
||||
int frameSize;
|
||||
if (majorVer == 4) {
|
||||
frameSize = ((tagData[pos+4] & 0x7F) << 21)
|
||||
| ((tagData[pos+5] & 0x7F) << 14)
|
||||
| ((tagData[pos+6] & 0x7F) << 7)
|
||||
| (tagData[pos+7] & 0x7F);
|
||||
} else {
|
||||
frameSize = ((tagData[pos+4] & 0xFF) << 24)
|
||||
| ((tagData[pos+5] & 0xFF) << 16)
|
||||
| ((tagData[pos+6] & 0xFF) << 8)
|
||||
| (tagData[pos+7] & 0xFF);
|
||||
}
|
||||
pos += 10; // skip frame header
|
||||
|
||||
if (frameSize <= 0 || pos + frameSize > tagData.length) break;
|
||||
|
||||
byte[] frameData = new byte[frameSize];
|
||||
System.arraycopy(tagData, pos, frameData, 0, frameSize);
|
||||
pos += frameSize;
|
||||
|
||||
switch (frameId) {
|
||||
case "TIT2" -> track.setTitle(decodeTextFrame(frameData));
|
||||
case "TPE1" -> track.setArtist(decodeTextFrame(frameData));
|
||||
case "TALB" -> track.setAlbum(decodeTextFrame(frameData));
|
||||
case "TDRC", "TYER" -> {
|
||||
String yr = decodeTextFrame(frameData);
|
||||
if (yr.length() >= 4) {
|
||||
try { track.setYear(Integer.parseInt(yr.substring(0, 4))); } catch (NumberFormatException ignored) {}
|
||||
}
|
||||
}
|
||||
case "TRCK" -> {
|
||||
String trck = decodeTextFrame(frameData);
|
||||
int slash = trck.indexOf('/');
|
||||
try { track.setTrackNumber(Integer.parseInt(slash >= 0 ? trck.substring(0, slash) : trck)); }
|
||||
catch (NumberFormatException ignored) {}
|
||||
}
|
||||
case "APIC" -> track.setCoverArt(decodeAPIC(frameData));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String decodeTextFrame(byte[] data) {
|
||||
if (data.length == 0) return "";
|
||||
int enc = data[0] & 0xFF;
|
||||
int start = 1;
|
||||
// Strip BOM for UTF-16
|
||||
// vv terrible code vv
|
||||
return switch (enc) {
|
||||
case 0 -> new String(data, start, data.length - start, StandardCharsets.ISO_8859_1).trim();
|
||||
case 1 -> {
|
||||
// UTF-16 with BOM
|
||||
if (data.length - start >= 2 && data[start] == (byte)0xFF && data[start+1] == (byte)0xFE)
|
||||
yield new String(data, start+2, data.length - start - 2, StandardCharsets.UTF_16LE).trim();
|
||||
if (data.length - start >= 2 && data[start] == (byte)0xFE && data[start+1] == (byte)0xFF)
|
||||
yield new String(data, start+2, data.length - start - 2, StandardCharsets.UTF_16BE).trim();
|
||||
yield new String(data, start, data.length - start, StandardCharsets.UTF_16).trim();
|
||||
}
|
||||
case 2 -> new String(data, start, data.length - start, StandardCharsets.UTF_16BE).trim();
|
||||
case 3 -> new String(data, start, data.length - start, StandardCharsets.UTF_8).trim();
|
||||
default -> new String(data, start, data.length - start, StandardCharsets.ISO_8859_1).trim();
|
||||
};
|
||||
}
|
||||
|
||||
private static BufferedImage decodeAPIC(byte[] data) {
|
||||
if (data.length < 4) return null;
|
||||
int enc = data[0] & 0xFF;
|
||||
int pos = 1;
|
||||
while (pos < data.length && data[pos] != 0) pos++;
|
||||
pos++;
|
||||
if (pos >= data.length) return null;
|
||||
pos++;
|
||||
if (enc == 1 || enc == 2) {
|
||||
while (pos + 1 < data.length && !(data[pos] == 0 && data[pos+1] == 0)) pos++;
|
||||
pos += 2;
|
||||
} else {
|
||||
while (pos < data.length && data[pos] != 0) pos++;
|
||||
pos++;
|
||||
}
|
||||
if (pos >= data.length) return null;
|
||||
try {
|
||||
return ImageIO.read(new ByteArrayInputStream(data, pos, data.length - pos));
|
||||
} catch (IOException e) { return null; }
|
||||
}
|
||||
|
||||
// FLAC
|
||||
private static void readFLAC(Track track) throws IOException {
|
||||
try (DataInputStream dis = new DataInputStream(
|
||||
new BufferedInputStream(new FileInputStream(track.getFilePath().toFile())))) {
|
||||
|
||||
byte[] magic = new byte[4];
|
||||
dis.readFully(magic);
|
||||
if (magic[0] != 'f' || magic[1] != 'L' || magic[2] != 'a' || magic[3] != 'C') return;
|
||||
|
||||
boolean last = false;
|
||||
while (!last) {
|
||||
int blockHeader = dis.readUnsignedByte();
|
||||
last = (blockHeader & 0x80) != 0;
|
||||
int blockType = blockHeader & 0x7F;
|
||||
int blockLen = (dis.readUnsignedByte() << 16)
|
||||
| (dis.readUnsignedByte() << 8)
|
||||
| dis.readUnsignedByte();
|
||||
byte[] blockData = new byte[blockLen];
|
||||
dis.readFully(blockData);
|
||||
|
||||
if (blockType == 4) { // VORBIS_COMMENT
|
||||
parseVorbisComment(track, blockData);
|
||||
} else if (blockType == 6) { // PICTURE
|
||||
BufferedImage img = parseFLACPicture(blockData);
|
||||
if (img != null) track.setCoverArt(img);
|
||||
} else if (blockType == 0) { // STREAMINFO ; duration
|
||||
if (blockLen >= 18) {
|
||||
long totalSamples = ((long)(blockData[13] & 0x0F) << 32)
|
||||
| (((long)(blockData[14] & 0xFF)) << 24)
|
||||
| (((long)(blockData[15] & 0xFF)) << 16)
|
||||
| (((long)(blockData[16] & 0xFF)) << 8)
|
||||
| ((long)(blockData[17] & 0xFF));
|
||||
int sampleRate = ((blockData[10] & 0xFF) << 12)
|
||||
| ((blockData[11] & 0xFF) << 4)
|
||||
| ((blockData[12] & 0xFF) >> 4);
|
||||
if (sampleRate > 0)
|
||||
track.setDurationSeconds(totalSamples / sampleRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void parseVorbisComment(Track track, byte[] data) {
|
||||
try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data))) {
|
||||
int vendorLen = readLE32(dis);
|
||||
dis.skipBytes(vendorLen);
|
||||
int count = readLE32(dis);
|
||||
for (int i = 0; i < count; i++) {
|
||||
int len = readLE32(dis);
|
||||
byte[] cb = new byte[len];
|
||||
dis.readFully(cb);
|
||||
String comment = new String(cb, StandardCharsets.UTF_8);
|
||||
int eq = comment.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
String k = comment.substring(0, eq).toUpperCase();
|
||||
String v = comment.substring(eq + 1);
|
||||
switch (k) {
|
||||
case "TITLE" -> track.setTitle(v);
|
||||
case "ARTIST" -> track.setArtist(v);
|
||||
case "ALBUM" -> track.setAlbum(v);
|
||||
case "DATE" -> { try { track.setYear(Integer.parseInt(v.substring(0, Math.min(4, v.length())))); } catch (NumberFormatException ignored) {} }
|
||||
case "TRACKNUMBER" -> { try { track.setTrackNumber(Integer.parseInt(v)); } catch (NumberFormatException ignored) {} }
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
private static BufferedImage parseFLACPicture(byte[] data) {
|
||||
try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data))) {
|
||||
int pictureType = dis.readInt();
|
||||
int mimeLen = dis.readInt();
|
||||
dis.skipBytes(mimeLen);
|
||||
int descLen = dis.readInt();
|
||||
dis.skipBytes(descLen);
|
||||
dis.skipBytes(16); // width, height, depth, index
|
||||
int picLen = dis.readInt();
|
||||
byte[] picData = new byte[picLen];
|
||||
dis.readFully(picData);
|
||||
return ImageIO.read(new ByteArrayInputStream(picData));
|
||||
} catch (IOException e) { return null; }
|
||||
}
|
||||
|
||||
// WAV
|
||||
private static void readWAV(Track track) throws IOException {
|
||||
try (DataInputStream dis = new DataInputStream(
|
||||
new BufferedInputStream(new FileInputStream(track.getFilePath().toFile())))) {
|
||||
|
||||
byte[] riff = new byte[4];
|
||||
dis.readFully(riff);
|
||||
if (!new String(riff, StandardCharsets.US_ASCII).equals("RIFF")) return;
|
||||
readLE32(dis); // total size
|
||||
byte[] wave = new byte[4];
|
||||
dis.readFully(wave);
|
||||
if (!new String(wave, StandardCharsets.US_ASCII).equals("WAVE")) return;
|
||||
|
||||
int sampleRate = 0;
|
||||
long dataSize = 0;
|
||||
int blockAlign = 0;
|
||||
|
||||
while (dis.available() >= 8) {
|
||||
byte[] chunkId = new byte[4];
|
||||
dis.readFully(chunkId);
|
||||
long chunkSize = Integer.toUnsignedLong(readLE32(dis));
|
||||
String id = new String(chunkId, StandardCharsets.US_ASCII);
|
||||
|
||||
if (id.equals("fmt ")) {
|
||||
dis.skipBytes(2); // audio format
|
||||
dis.skipBytes(2); // channels
|
||||
sampleRate = readLE32(dis);
|
||||
dis.skipBytes(4); // byte rate
|
||||
blockAlign = dis.readShort();
|
||||
dis.skipBytes(2); // bits per sample
|
||||
long skip = chunkSize - 16;
|
||||
if (skip > 0) dis.skipBytes((int) skip);
|
||||
} else if (id.equals("data")) {
|
||||
dataSize = chunkSize;
|
||||
dis.skipBytes((int) Math.min(chunkSize, Integer.MAX_VALUE));
|
||||
} else if (id.equals("LIST")) {
|
||||
byte[] listType = new byte[4];
|
||||
dis.readFully(listType);
|
||||
String lt = new String(listType, StandardCharsets.US_ASCII);
|
||||
if (lt.equals("INFO")) {
|
||||
long remaining = chunkSize - 4;
|
||||
while (remaining >= 8) {
|
||||
byte[] subId = new byte[4];
|
||||
dis.readFully(subId);
|
||||
int subLen = readLE32(dis);
|
||||
remaining -= 8;
|
||||
byte[] subData = new byte[subLen];
|
||||
dis.readFully(subData);
|
||||
remaining -= subLen;
|
||||
if (subLen % 2 != 0 && remaining > 0) { dis.skipBytes(1); remaining--; }
|
||||
String subKey = new String(subId, StandardCharsets.US_ASCII);
|
||||
String val = new String(subData, StandardCharsets.ISO_8859_1).replace("\0", "").trim();
|
||||
switch (subKey) {
|
||||
case "INAM" -> track.setTitle(val);
|
||||
case "IART" -> track.setArtist(val);
|
||||
case "IPRD" -> track.setAlbum(val);
|
||||
case "ICRD" -> { try { track.setYear(Integer.parseInt(val.substring(0, Math.min(4, val.length())))); } catch (NumberFormatException ignored) {} }
|
||||
case "ITRK" -> { try { track.setTrackNumber(Integer.parseInt(val)); } catch (NumberFormatException ignored) {} }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
long skip = chunkSize - 4;
|
||||
if (skip > 0) dis.skipBytes((int) skip);
|
||||
}
|
||||
} else {
|
||||
if (chunkSize > 0) dis.skipBytes((int) Math.min(chunkSize, Integer.MAX_VALUE));
|
||||
}
|
||||
}
|
||||
|
||||
if (sampleRate > 0 && blockAlign > 0 && dataSize > 0) {
|
||||
track.setDurationSeconds(dataSize / (long)((long) sampleRate * blockAlign));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int readLE32(DataInputStream dis) throws IOException {
|
||||
int b0 = dis.readUnsignedByte();
|
||||
int b1 = dis.readUnsignedByte();
|
||||
int b2 = dis.readUnsignedByte();
|
||||
int b3 = dis.readUnsignedByte();
|
||||
return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class MusicLibrary {
|
||||
|
||||
private static final Set<String> SUPPORTED_EXTENSIONS =
|
||||
Set.of(".mp3", ".flac", ".wav");
|
||||
|
||||
private final List<Track> tracks = new ArrayList<>();
|
||||
private Path rootPath;
|
||||
|
||||
private ExecutorService scanExecutor;
|
||||
|
||||
public MusicLibrary(Path rootPath) {
|
||||
this.rootPath = rootPath;
|
||||
}
|
||||
|
||||
public Path getRootPath() { return rootPath; }
|
||||
|
||||
public void setRootPath(Path rootPath) {
|
||||
this.rootPath = rootPath;
|
||||
}
|
||||
|
||||
public List<Track> getTracks() {
|
||||
return Collections.unmodifiableList(tracks);
|
||||
}
|
||||
|
||||
public void scan(Consumer<Track> onProgress, Runnable onComplete) {
|
||||
if (scanExecutor != null && !scanExecutor.isShutdown()) {
|
||||
scanExecutor.shutdownNow();
|
||||
}
|
||||
scanExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "wavy-library-scan");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
tracks.clear();
|
||||
|
||||
scanExecutor.submit(() -> {
|
||||
try {
|
||||
if (!Files.isDirectory(rootPath)) {
|
||||
onComplete.run();
|
||||
return;
|
||||
}
|
||||
Files.walkFileTree(rootPath, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
String name = file.getFileName().toString().toLowerCase();
|
||||
boolean supported = SUPPORTED_EXTENSIONS.stream().anyMatch(name::endsWith);
|
||||
if (supported) {
|
||||
Track track = new Track(file);
|
||||
MetadataReader.populate(track);
|
||||
synchronized (tracks) {
|
||||
tracks.add(track);
|
||||
}
|
||||
onProgress.accept(track);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException exc) {
|
||||
return FileVisitResult.CONTINUE; // skip unreadable files
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
onComplete.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void sortByArtistTitle() {
|
||||
synchronized (tracks) {
|
||||
tracks.sort(Comparator
|
||||
.comparing(Track::getArtist, String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(Track::getTitle, String.CASE_INSENSITIVE_ORDER));
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
if (scanExecutor != null) scanExecutor.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
|
||||
public class SourceConfig {
|
||||
|
||||
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), "rsys");
|
||||
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("wavy_sources.json");
|
||||
|
||||
public static Path loadMusicRoot() {
|
||||
if (Files.exists(CONFIG_FILE)) {
|
||||
try {
|
||||
String json = Files.readString(CONFIG_FILE, StandardCharsets.UTF_8);
|
||||
String path = extractJsonString(json, "musicRoot");
|
||||
if (path != null && !path.isBlank()) {
|
||||
Path p = Path.of(path);
|
||||
if (Files.isDirectory(p)) return p;
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
return defaultMusicRoot();
|
||||
}
|
||||
|
||||
public static void saveMusicRoot(Path root) throws IOException {
|
||||
Files.createDirectories(CONFIG_DIR);
|
||||
String json = "{\n \"musicRoot\": \"" + escape(root.toString()) + "\"\n}\n";
|
||||
Files.writeString(CONFIG_FILE, json, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Path defaultMusicRoot() {
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
if (os.contains("win")) {
|
||||
String home = System.getProperty("user.home");
|
||||
return Path.of(home, "Music");
|
||||
}
|
||||
return Path.of(System.getProperty("user.home"), "Music");
|
||||
}
|
||||
|
||||
private static String extractJsonString(String json, String key) {
|
||||
String needle = "\"" + key + "\"";
|
||||
int ki = json.indexOf(needle);
|
||||
if (ki < 0) return null;
|
||||
int colon = json.indexOf(':', ki + needle.length());
|
||||
if (colon < 0) return null;
|
||||
int open = json.indexOf('"', colon + 1);
|
||||
if (open < 0) return null;
|
||||
int close = open + 1;
|
||||
while (close < json.length()) {
|
||||
char c = json.charAt(close);
|
||||
if (c == '\\') { close += 2; continue; }
|
||||
if (c == '"') break;
|
||||
close++;
|
||||
}
|
||||
if (close >= json.length()) return null;
|
||||
return unescape(json.substring(open + 1, close));
|
||||
}
|
||||
|
||||
private static String escape(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static String unescape(String s) {
|
||||
return s.replace("\\\\", "\\").replace("\\\"", "\"").replace("\\/", "/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
|
||||
public class SpectrogramPanel extends JPanel {
|
||||
|
||||
private static final int BAR_COUNT = 96;
|
||||
private static final Color[] HEAT = buildHeatMap();
|
||||
|
||||
private BufferedImage waterfallImg = null;
|
||||
private Graphics2D waterfallG = null;
|
||||
|
||||
private float[] currentMag = new float[8192];
|
||||
private float[] barSmoothed = new float[BAR_COUNT];
|
||||
private float[] barPeak = new float[BAR_COUNT];
|
||||
private int[] peakTimer = new int[BAR_COUNT];
|
||||
|
||||
private final ArrayBlockingQueue<float[]> fftQueue = new ArrayBlockingQueue<>(16);
|
||||
private final Timer renderTimer;
|
||||
|
||||
public SpectrogramPanel() {
|
||||
setBorder(BorderFactory.createTitledBorder("Spectrogram"));
|
||||
setPreferredSize(new Dimension(400, 220));
|
||||
setMinimumSize(new Dimension(100, 80));
|
||||
|
||||
renderTimer = new Timer(16, e -> {
|
||||
float[] mag;
|
||||
boolean updated = false;
|
||||
while ((mag = fftQueue.poll()) != null) {
|
||||
currentMag = mag;
|
||||
scrollWaterfall();
|
||||
updated = true;
|
||||
}
|
||||
if (updated) repaint();
|
||||
});
|
||||
renderTimer.start();
|
||||
}
|
||||
|
||||
public void receiveFft(float[] magnitudes) {
|
||||
fftQueue.offer(magnitudes.clone());
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
currentMag = new float[2048];
|
||||
java.util.Arrays.fill(barSmoothed, 0f);
|
||||
java.util.Arrays.fill(barPeak, 0f);
|
||||
// Clear waterfall image
|
||||
if (waterfallG != null) {
|
||||
waterfallG.setBackground(getBackground());
|
||||
Insets ins = getInsets();
|
||||
int wh = (getHeight() - ins.top - ins.bottom) / 2;
|
||||
waterfallG.clearRect(0, 0, waterfallImg.getWidth(), Math.max(1, wh));
|
||||
}
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
super.paintComponent(g);
|
||||
|
||||
Insets ins = getInsets();
|
||||
int x0 = ins.left, y0 = ins.top;
|
||||
int w = getWidth() - ins.left - ins.right;
|
||||
int h = getHeight() - ins.top - ins.bottom;
|
||||
if (w <= 4 || h <= 4) return;
|
||||
|
||||
int waterfallH = h / 2;
|
||||
int barH = h - waterfallH;
|
||||
|
||||
ensureWaterfallImage(w, waterfallH);
|
||||
drawWaterfall(g, x0, y0, w, waterfallH);
|
||||
drawSpectrum (g, x0, y0 + waterfallH, w, barH);
|
||||
}
|
||||
|
||||
private void ensureWaterfallImage(int w, int h) {
|
||||
if (waterfallImg == null || waterfallImg.getWidth() != w || waterfallImg.getHeight() != h) {
|
||||
BufferedImage newImg = new BufferedImage(w, Math.max(1,h), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D ng = newImg.createGraphics();
|
||||
Color bg = getBackground();
|
||||
ng.setColor(bg != null ? bg : Color.WHITE);
|
||||
ng.fillRect(0, 0, w, Math.max(1,h));
|
||||
if (waterfallImg != null && waterfallG != null) {
|
||||
ng.drawImage(waterfallImg, 0, 0, null);
|
||||
waterfallG.dispose();
|
||||
}
|
||||
waterfallImg = newImg;
|
||||
waterfallG = ng;
|
||||
}
|
||||
}
|
||||
|
||||
private void scrollWaterfall() {
|
||||
if (waterfallImg == null) return;
|
||||
int w = waterfallImg.getWidth(), h = waterfallImg.getHeight();
|
||||
if (h < 2) return;
|
||||
|
||||
waterfallG.copyArea(0, 1, w, h - 1, 0, -1);
|
||||
|
||||
computeBars();
|
||||
|
||||
int fftLen = currentMag.length;
|
||||
float dbMin = -80f, dbMax = 0f;
|
||||
double logMin = Math.log10(20.0), logMax = Math.log10(22050.0);
|
||||
|
||||
int[] raster = new int[w];
|
||||
for (int px = 0; px < w; px++) {
|
||||
double freq = Math.pow(10, logMin + (logMax - logMin) * px / w);
|
||||
int bin = Math.min(fftLen - 1, (int)(freq / 22050.0 * fftLen));
|
||||
|
||||
int binL = Math.max(0, bin - 1);
|
||||
int binR = Math.min(fftLen-1, bin + 1);
|
||||
float mag = 0;
|
||||
for (int k = binL; k <= binR; k++) mag += currentMag[k];
|
||||
mag /= (binR - binL + 1);
|
||||
|
||||
float db = mag > 0 ? 20f * (float)Math.log10(mag) : dbMin;
|
||||
float val = Math.max(0f, Math.min(1f, (db - dbMin) / (dbMax - dbMin)));
|
||||
|
||||
raster[px] = HEAT[(int)(val * (HEAT.length - 1))].getRGB();
|
||||
}
|
||||
waterfallImg.setRGB(0, h - 1, w, 1, raster, 0, w);
|
||||
}
|
||||
|
||||
private void drawWaterfall(Graphics g, int x, int y, int w, int h) {
|
||||
if (waterfallImg != null)
|
||||
g.drawImage(waterfallImg, x, y, w, h, null);
|
||||
g.setColor(Color.GRAY);
|
||||
g.drawLine(x, y + h - 1, x + w, y + h - 1);
|
||||
}
|
||||
|
||||
private void drawSpectrum(Graphics g, int x, int y, int w, int h) {
|
||||
float[] bars = barSmoothed; // already updated by scrollWaterfall
|
||||
int barW = Math.max(1, w / BAR_COUNT);
|
||||
int gap = Math.max(1, barW / 5);
|
||||
|
||||
for (int i = 0; i < BAR_COUNT; i++) {
|
||||
float val = bars[i];
|
||||
int bx = x + i * barW;
|
||||
int bh = (int)(val * (h - 12));
|
||||
|
||||
int ci = (int)(val * (HEAT.length - 1));
|
||||
g.setColor(HEAT[ci]);
|
||||
g.fillRect(bx + gap/2, y + h - 12 - bh, barW - gap, bh);
|
||||
|
||||
if (barPeak[i] > 0.01f) {
|
||||
int ph = (int)(barPeak[i] * (h - 12));
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(bx + gap/2, y + h - 12 - ph - 1, barW - gap, 2);
|
||||
}
|
||||
}
|
||||
|
||||
g.setColor(getForeground());
|
||||
g.setFont(g.getFont().deriveFont(8f));
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String[] lbl = {"32","125","500","2k","8k","16k"};
|
||||
double logMin = Math.log10(20.0), logMax = Math.log10(22050.0);
|
||||
float[] freqs = {32,125,500,2000,8000,16000};
|
||||
for (int i = 0; i < lbl.length; i++) {
|
||||
double nx = (Math.log10(freqs[i]) - logMin) / (logMax - logMin) * w;
|
||||
g.drawString(lbl[i], x + (int)nx, y + h - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private float[] computeBars() {
|
||||
int fftLen = currentMag.length;
|
||||
float[] bars = new float[BAR_COUNT];
|
||||
double logMin = Math.log10(20.0), logMax = Math.log10(22050.0);
|
||||
|
||||
for (int b = 0; b < BAR_COUNT; b++) {
|
||||
double f1 = Math.pow(10, logMin + (logMax - logMin) * b / BAR_COUNT);
|
||||
double f2 = Math.pow(10, logMin + (logMax - logMin) * (b + 1) / BAR_COUNT);
|
||||
int bin1 = Math.max(0, Math.min(fftLen-1, (int)(f1/22050.0*fftLen)));
|
||||
int bin2 = Math.max(bin1, Math.min(fftLen-1, (int)(f2/22050.0*fftLen)));
|
||||
float sum = 0; int cnt = 0;
|
||||
for (int k = bin1; k <= bin2; k++) { sum += currentMag[k]; cnt++; }
|
||||
bars[b] = cnt > 0 ? sum / cnt : 0;
|
||||
}
|
||||
|
||||
float dbMin = -80, dbMax = 0;
|
||||
for (int b = 0; b < BAR_COUNT; b++) {
|
||||
float db = bars[b] > 0 ? 20*(float)Math.log10(bars[b]) : dbMin;
|
||||
bars[b] = Math.max(0, Math.min(1, (db - dbMin) / (dbMax - dbMin)));
|
||||
}
|
||||
|
||||
for (int b = 0; b < BAR_COUNT; b++) {
|
||||
barSmoothed[b] = barSmoothed[b]*0.65f + bars[b]*0.35f;
|
||||
if (barSmoothed[b] > barPeak[b]) { barPeak[b] = barSmoothed[b]; peakTimer[b] = 25; }
|
||||
else if (peakTimer[b] > 0) peakTimer[b]--;
|
||||
else barPeak[b] = Math.max(0, barPeak[b] - 0.006f);
|
||||
}
|
||||
return barSmoothed;
|
||||
}
|
||||
|
||||
private static Color[] buildHeatMap() {
|
||||
int N = 256;
|
||||
Color[] map = new Color[N];
|
||||
Color[] stops = { Color.BLACK, new Color(0,0,180), Color.CYAN, Color.YELLOW, Color.RED };
|
||||
int segs = stops.length - 1;
|
||||
for (int i = 0; i < N; i++) {
|
||||
float t = (float)i/(N-1) * segs;
|
||||
int seg = Math.min((int)t, segs-1);
|
||||
float f = t - seg;
|
||||
Color a = stops[seg], b = stops[seg+1];
|
||||
map[i] = new Color(
|
||||
// hehe larp
|
||||
lerp(a.getRed(), b.getRed(), f),
|
||||
lerp(a.getGreen(), b.getGreen(), f),
|
||||
lerp(a.getBlue(), b.getBlue(), f));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
private static int lerp(int a, int b, float t) {
|
||||
return Math.max(0, Math.min(255, (int)(a + (b-a)*t)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class Track {
|
||||
|
||||
private final Path filePath;
|
||||
private String title;
|
||||
private String artist;
|
||||
private String album;
|
||||
private int year;
|
||||
private int trackNumber;
|
||||
private long durationSeconds;
|
||||
private BufferedImage coverArt; // null if not embedded
|
||||
private final String format; // "MP3", "FLAC", "WAV"
|
||||
|
||||
public Track(Path filePath) {
|
||||
this.filePath = filePath;
|
||||
String name = filePath.getFileName().toString();
|
||||
int dot = name.lastIndexOf('.');
|
||||
this.format = dot >= 0 ? name.substring(dot + 1).toUpperCase() : "UNKNOWN";
|
||||
this.title = dot >= 0 ? name.substring(0, dot) : name;
|
||||
this.artist = "Unknown Artist";
|
||||
this.album = "Unknown Album";
|
||||
this.year = 0;
|
||||
this.trackNumber = 0;
|
||||
this.durationSeconds = 0;
|
||||
this.coverArt = null;
|
||||
}
|
||||
|
||||
public Path getFilePath() { return filePath; }
|
||||
public String getTitle() { return title; }
|
||||
public String getArtist() { return artist; }
|
||||
public String getAlbum() { return album; }
|
||||
public int getYear() { return year; }
|
||||
public int getTrackNumber() { return trackNumber; }
|
||||
public long getDurationSeconds() { return durationSeconds; }
|
||||
public BufferedImage getCoverArt() { return coverArt; }
|
||||
public String getFormat() { return format; }
|
||||
|
||||
public String getDisplayTitle() {
|
||||
return title != null && !title.isBlank() ? title : filePath.getFileName().toString();
|
||||
}
|
||||
|
||||
public String getDurationString() {
|
||||
long m = durationSeconds / 60;
|
||||
long s = durationSeconds % 60;
|
||||
return String.format("%d:%02d", m, s);
|
||||
}
|
||||
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public void setArtist(String artist) { this.artist = artist; }
|
||||
public void setAlbum(String album) { this.album = album; }
|
||||
public void setYear(int year) { this.year = year; }
|
||||
public void setTrackNumber(int trackNumber) { this.trackNumber = trackNumber; }
|
||||
public void setDurationSeconds(long dur) { this.durationSeconds = dur; }
|
||||
public void setCoverArt(BufferedImage coverArt) { this.coverArt = coverArt; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("%s — %s", artist, title);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import com.rdesk.kernel.DesktopApp;
|
||||
import com.rdesk.kernel.Kernel;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class Wavy implements DesktopApp {
|
||||
|
||||
@Override public String getId() { return "wavy-player"; }
|
||||
@Override public String getName() { return "Wavy"; }
|
||||
|
||||
@Override
|
||||
public JInternalFrame createWindow(Kernel kernel) {
|
||||
JInternalFrame frame = new JInternalFrame(
|
||||
"Wavy", true, true, true, true);
|
||||
frame.setDefaultCloseOperation(JInternalFrame.DISPOSE_ON_CLOSE);
|
||||
|
||||
Path musicRoot = SourceConfig.loadMusicRoot();
|
||||
MusicLibrary library = new MusicLibrary(musicRoot);
|
||||
|
||||
WavyPlayerPanel player = new WavyPlayerPanel(library);
|
||||
|
||||
player.hookFrameClose(frame);
|
||||
|
||||
frame.setJMenuBar(buildMenuBar(frame, library, player));
|
||||
frame.setContentPane(player);
|
||||
frame.pack();
|
||||
frame.setSize(700, 780);
|
||||
frame.setVisible(true);
|
||||
|
||||
player.reloadLibrary();
|
||||
return frame;
|
||||
}
|
||||
|
||||
private JMenuBar buildMenuBar(JInternalFrame frame, MusicLibrary library,
|
||||
WavyPlayerPanel player) {
|
||||
JMenuBar bar = new JMenuBar();
|
||||
|
||||
JMenu file = new JMenu("File");
|
||||
|
||||
JMenuItem chooseLocation = new JMenuItem("Choose Music Location…");
|
||||
chooseLocation.setAccelerator(KeyStroke.getKeyStroke("control O"));
|
||||
chooseLocation.addActionListener(e -> {
|
||||
JFileChooser fc = new JFileChooser(library.getRootPath().toFile());
|
||||
fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
|
||||
fc.setDialogTitle("Select Music Folder");
|
||||
fc.setApproveButtonText("Use This Folder");
|
||||
if (fc.showOpenDialog(frame) == JFileChooser.APPROVE_OPTION) {
|
||||
Path chosen = fc.getSelectedFile().toPath();
|
||||
library.setRootPath(chosen);
|
||||
try { SourceConfig.saveMusicRoot(chosen); }
|
||||
catch (Exception ex) {
|
||||
JOptionPane.showMessageDialog(frame,
|
||||
"Could not save music location:\n" + ex.getMessage(),
|
||||
"Save Error", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
frame.setTitle("Wavy - " + chosen.getFileName());
|
||||
player.reloadLibrary();
|
||||
}
|
||||
});
|
||||
file.add(chooseLocation);
|
||||
|
||||
JMenuItem refresh = new JMenuItem("Refresh Library");
|
||||
refresh.setAccelerator(KeyStroke.getKeyStroke("F5"));
|
||||
refresh.addActionListener(e -> player.reloadLibrary());
|
||||
file.add(refresh);
|
||||
|
||||
file.addSeparator();
|
||||
|
||||
JMenuItem close = new JMenuItem("Close");
|
||||
close.addActionListener(e -> frame.dispose());
|
||||
file.add(close);
|
||||
|
||||
JMenu help = new JMenu("Help");
|
||||
JMenuItem about = new JMenuItem("About Wavy");
|
||||
about.addActionListener(e -> JOptionPane.showMessageDialog(frame, """
|
||||
Wavy is the music player for rDesk
|
||||
it supports MP3 FLAC and WAVE Files (soon to be extended)
|
||||
Wavy features a 10 band equalizer with some presets, shuffle,
|
||||
repeat, and a library path which persists between start / stops!
|
||||
""", "About Wavy", JOptionPane.INFORMATION_MESSAGE));
|
||||
help.add(about);
|
||||
|
||||
bar.add(file);
|
||||
bar.add(help);
|
||||
return bar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.rdesk.applications.entertainment.media;
|
||||
|
||||
import javax.swing.*;
|
||||
import javax.swing.event.ListSelectionEvent;
|
||||
import java.awt.*;
|
||||
import java.awt.event.*;
|
||||
import java.util.List;
|
||||
|
||||
public class WavyPlayerPanel extends JPanel {
|
||||
|
||||
private final AudioEngine engine;
|
||||
private final MusicLibrary library;
|
||||
private final CoverArtPanel coverPanel;
|
||||
private final SpectrogramPanel spectrogram;
|
||||
private final EqualizerPanel equalizer;
|
||||
|
||||
// Transport controls
|
||||
private final JLabel lblTitle = new JLabel("No track selected");
|
||||
private final JLabel lblArtist = new JLabel(" ");
|
||||
private final JLabel lblAlbum = new JLabel(" ");
|
||||
private final JLabel lblTime = new JLabel("0:00 / 0:00");
|
||||
private final JSlider seekBar = new JSlider(0, 1000, 0);
|
||||
private final JButton btnPrev = new JButton("<");
|
||||
private final JButton btnPlay = new JButton("Play");
|
||||
private final JButton btnStop = new JButton("Stop");
|
||||
private final JButton btnNext = new JButton(">");
|
||||
private final JCheckBox chkShuffle = new JCheckBox("Shuffle");
|
||||
private final JCheckBox chkRepeat = new JCheckBox("Repeat");
|
||||
private final JSlider volSlider;
|
||||
|
||||
// playlist
|
||||
private final DefaultListModel<Track> playlistModel = new DefaultListModel<>();
|
||||
private final JList<Track> playlistView = new JList<>(playlistModel);
|
||||
private final JLabel lblStatus = new JLabel("No library loaded.");
|
||||
|
||||
// state
|
||||
private int currentIndex = -1;
|
||||
private boolean seekDragging = false;
|
||||
private long currentDurationSec = 0;
|
||||
|
||||
private final Timer uiTimer;
|
||||
|
||||
public WavyPlayerPanel(MusicLibrary library) {
|
||||
this.library = library;
|
||||
this.engine = new AudioEngine();
|
||||
this.coverPanel = new CoverArtPanel();
|
||||
this.spectrogram = new SpectrogramPanel();
|
||||
this.equalizer = new EqualizerPanel(engine);
|
||||
|
||||
volSlider = new JSlider(0, 100, 80);
|
||||
|
||||
setLayout(new BorderLayout(0, 0));
|
||||
setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
|
||||
|
||||
engine.setOnTrackEnd(() -> SwingUtilities.invokeLater(this::playNext));
|
||||
engine.setFftListener(mag -> {
|
||||
spectrogram.receiveFft(mag);
|
||||
});
|
||||
engine.setOnPositionUpdate(pos -> {
|
||||
if (!seekDragging) SwingUtilities.invokeLater(() -> updateSeekBar(pos));
|
||||
});
|
||||
|
||||
buildUI();
|
||||
|
||||
uiTimer = new Timer(500, e -> updateTimeLabel());
|
||||
uiTimer.start();
|
||||
}
|
||||
|
||||
public void hookFrameClose(JInternalFrame frame) {
|
||||
frame.addInternalFrameListener(new javax.swing.event.InternalFrameAdapter() {
|
||||
@Override public void internalFrameClosing(javax.swing.event.InternalFrameEvent e) {
|
||||
engine.shutdown();
|
||||
library.shutdown();
|
||||
}
|
||||
@Override public void internalFrameClosed(javax.swing.event.InternalFrameEvent e) {
|
||||
engine.shutdown();
|
||||
library.shutdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildUI() {
|
||||
add(buildNowPlayingPanel(), BorderLayout.NORTH);
|
||||
|
||||
add(spectrogram, BorderLayout.CENTER);
|
||||
|
||||
add(buildBottomTabs(), BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
|
||||
private JPanel buildNowPlayingPanel() {
|
||||
JPanel panel = new JPanel(new BorderLayout(8, 0));
|
||||
panel.setBorder(BorderFactory.createTitledBorder("Now Playing"));
|
||||
|
||||
coverPanel.setPreferredSize(new Dimension(130, 130));
|
||||
coverPanel.setMinimumSize(new Dimension(60, 60));
|
||||
panel.add(coverPanel, BorderLayout.WEST);
|
||||
|
||||
JPanel right = new JPanel(new BorderLayout(0, 4));
|
||||
right.setBorder(BorderFactory.createEmptyBorder(2, 0, 2, 0));
|
||||
|
||||
JPanel infoPanel = new JPanel(new GridLayout(3, 1, 0, 1));
|
||||
lblTitle.setFont(lblTitle.getFont().deriveFont(Font.BOLD, 13f));
|
||||
infoPanel.add(lblTitle);
|
||||
infoPanel.add(lblArtist);
|
||||
infoPanel.add(lblAlbum);
|
||||
right.add(infoPanel, BorderLayout.NORTH);
|
||||
|
||||
JPanel seekPanel = new JPanel(new BorderLayout(4, 0));
|
||||
seekBar.setPaintTicks(false);
|
||||
seekBar.setPaintLabels(false);
|
||||
seekBar.addMouseListener(new MouseAdapter() {
|
||||
public void mousePressed(MouseEvent e) { seekDragging = true; }
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
seekDragging = false;
|
||||
engine.seekFraction(seekBar.getValue() / (double) seekBar.getMaximum());
|
||||
}
|
||||
});
|
||||
seekPanel.add(seekBar, BorderLayout.CENTER);
|
||||
seekPanel.add(lblTime, BorderLayout.EAST);
|
||||
right.add(seekPanel, BorderLayout.CENTER);
|
||||
|
||||
right.add(buildTransportRow(), BorderLayout.SOUTH);
|
||||
|
||||
panel.add(right, BorderLayout.CENTER);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private JPanel buildTransportRow() {
|
||||
JPanel row = new JPanel(new BorderLayout(0, 0));
|
||||
|
||||
JPanel btns = new JPanel(new FlowLayout(FlowLayout.LEFT, 3, 0));
|
||||
btns.add(btnPrev);
|
||||
btns.add(btnPlay);
|
||||
btns.add(btnStop);
|
||||
btns.add(btnNext);
|
||||
btns.add(new JSeparator(JSeparator.VERTICAL) {{ setPreferredSize(new Dimension(4, 24)); }});
|
||||
btns.add(chkShuffle);
|
||||
btns.add(chkRepeat);
|
||||
row.add(btns, BorderLayout.WEST);
|
||||
|
||||
JPanel vol = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
|
||||
vol.add(new JLabel("Volume:"));
|
||||
volSlider.setPreferredSize(new Dimension(90, volSlider.getPreferredSize().height));
|
||||
volSlider.setToolTipText("Volume");
|
||||
volSlider.addChangeListener(e -> engine.setVolume(volSlider.getValue() / 100f));
|
||||
vol.add(volSlider);
|
||||
row.add(vol, BorderLayout.EAST);
|
||||
|
||||
btnPlay.addActionListener(e -> togglePlayPause());
|
||||
btnStop.addActionListener(e -> {
|
||||
engine.stop();
|
||||
btnPlay.setText("Play");
|
||||
});
|
||||
btnPrev.addActionListener(e -> playPrev());
|
||||
btnNext.addActionListener(e -> playNext());
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private JTabbedPane buildBottomTabs() {
|
||||
JTabbedPane tabs = new JTabbedPane();
|
||||
|
||||
// Playlist
|
||||
JPanel playlistPanel = new JPanel(new BorderLayout(0, 2));
|
||||
playlistPanel.setBorder(BorderFactory.createEmptyBorder(2, 0, 0, 0));
|
||||
|
||||
playlistView.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
playlistView.setCellRenderer(new TrackCellRenderer());
|
||||
playlistView.setFixedCellHeight(38);
|
||||
playlistView.addMouseListener(new MouseAdapter() {
|
||||
public void mouseClicked(MouseEvent e) {
|
||||
if (e.getClickCount() == 2) {
|
||||
int idx = playlistView.locationToIndex(e.getPoint());
|
||||
if (idx >= 0) playTrack(idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
JScrollPane scroll = new JScrollPane(playlistView);
|
||||
scroll.setPreferredSize(new Dimension(0, 220));
|
||||
playlistPanel.add(scroll, BorderLayout.CENTER);
|
||||
playlistPanel.add(lblStatus, BorderLayout.SOUTH);
|
||||
tabs.addTab("Playlist", playlistPanel);
|
||||
|
||||
tabs.addTab("Equalizer", equalizer);
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
public void reloadLibrary() {
|
||||
playlistModel.clear();
|
||||
lblStatus.setText("Scanning");
|
||||
library.scan(
|
||||
track -> SwingUtilities.invokeLater(() -> {
|
||||
playlistModel.addElement(track);
|
||||
lblStatus.setText(playlistModel.size() + " tracks found");
|
||||
}),
|
||||
() -> SwingUtilities.invokeLater(() -> {
|
||||
library.sortByArtistTitle();
|
||||
List<Track> sorted = library.getTracks();
|
||||
playlistModel.clear();
|
||||
for (Track t : sorted) playlistModel.addElement(t);
|
||||
lblStatus.setText(playlistModel.size() + " tracks.");
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public AudioEngine getEngine() { return engine; }
|
||||
|
||||
private void togglePlayPause() {
|
||||
if (engine.getState() == AudioEngine.State.STOPPED) {
|
||||
if (currentIndex < 0 && playlistModel.size() > 0) playTrack(0);
|
||||
else if (currentIndex >= 0) playTrack(currentIndex);
|
||||
} else {
|
||||
engine.togglePause();
|
||||
boolean paused = engine.getState() == AudioEngine.State.PAUSED;
|
||||
btnPlay.setText(paused ? "Play" : "Pause");
|
||||
}
|
||||
}
|
||||
|
||||
private void playTrack(int index) {
|
||||
if (index < 0 || index >= playlistModel.size()) return;
|
||||
currentIndex = index;
|
||||
Track track = playlistModel.get(index);
|
||||
engine.play(track);
|
||||
updateTrackInfo(track);
|
||||
currentDurationSec = track.getDurationSeconds();
|
||||
playlistView.setSelectedIndex(index);
|
||||
playlistView.ensureIndexIsVisible(index);
|
||||
btnPlay.setText("Pause");
|
||||
spectrogram.reset();
|
||||
playlistView.repaint();
|
||||
}
|
||||
|
||||
private void playNext() {
|
||||
if (chkRepeat.isSelected() && currentIndex >= 0) { playTrack(currentIndex); return; }
|
||||
int next = chkShuffle.isSelected()
|
||||
? (int)(Math.random() * playlistModel.size())
|
||||
: (currentIndex + 1) % Math.max(1, playlistModel.size());
|
||||
playTrack(next);
|
||||
}
|
||||
|
||||
private void playPrev() {
|
||||
if (currentIndex <= 0) return;
|
||||
playTrack(currentIndex - 1);
|
||||
}
|
||||
|
||||
private void updateTrackInfo(Track track) {
|
||||
lblTitle.setText(track.getDisplayTitle());
|
||||
lblArtist.setText(track.getArtist());
|
||||
lblAlbum.setText(track.getAlbum() + (track.getYear() > 0 ? " (" + track.getYear() + ")" : ""));
|
||||
coverPanel.setTrack(track);
|
||||
seekBar.setValue(0);
|
||||
}
|
||||
|
||||
private void updateSeekBar(long posFrames) {
|
||||
long total = engine.getTotalFrames();
|
||||
if (total > 0) seekBar.setValue((int)(posFrames * 1000L / total));
|
||||
}
|
||||
|
||||
private void updateTimeLabel() {
|
||||
float sr = engine.getFormat() != null ? engine.getFormat().getSampleRate() : 44100f;
|
||||
long posFr = engine.getPositionFrames();
|
||||
long totalFr = engine.getTotalFrames();
|
||||
|
||||
long posSec = (long)(posFr / sr);
|
||||
long totalSec = totalFr > 0
|
||||
? (long)(totalFr / sr)
|
||||
: (currentIndex >= 0 ? playlistModel.get(currentIndex).getDurationSeconds() : 0);
|
||||
|
||||
if (totalFr > 0 && currentIndex >= 0) {
|
||||
Track t = playlistModel.get(currentIndex);
|
||||
if (t.getDurationSeconds() == 0) {
|
||||
t.setDurationSeconds(totalSec);
|
||||
playlistView.repaint();
|
||||
}
|
||||
}
|
||||
|
||||
lblTime.setText(fmt(posSec) + " / " + fmt(totalSec));
|
||||
|
||||
if (engine.getState() == AudioEngine.State.STOPPED) btnPlay.setText("Play");
|
||||
}
|
||||
|
||||
private static String fmt(long sec) {
|
||||
return String.format("%d:%02d", sec / 60, sec % 60);
|
||||
}
|
||||
|
||||
private class TrackCellRenderer implements ListCellRenderer<Track> {
|
||||
|
||||
private final JPanel panel = new JPanel(new BorderLayout(0, 1));
|
||||
private final JLabel lblTop = new JLabel();
|
||||
private final JLabel lblBot = new JLabel();
|
||||
|
||||
TrackCellRenderer() {
|
||||
panel.setBorder(BorderFactory.createEmptyBorder(3, 6, 3, 6));
|
||||
lblTop.setFont(lblTop.getFont().deriveFont(Font.BOLD, 12f));
|
||||
lblBot.setFont(lblBot.getFont().deriveFont(Font.PLAIN, 10f));
|
||||
panel.add(lblTop, BorderLayout.NORTH);
|
||||
panel.add(lblBot, BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<? extends Track> list,
|
||||
Track t, int index, boolean isSelected, boolean hasFocus) {
|
||||
|
||||
Color bg = isSelected
|
||||
? UIManager.getColor("List.selectionBackground")
|
||||
: (index % 2 == 0
|
||||
? list.getBackground()
|
||||
: blend(list.getBackground(), UIManager.getColor("Panel.background"), 0.5f));
|
||||
Color fg = isSelected
|
||||
? UIManager.getColor("List.selectionForeground")
|
||||
: list.getForeground();
|
||||
Color fgDim = isSelected ? fg : blend(fg, bg, 0.45f);
|
||||
|
||||
panel.setBackground(bg);
|
||||
lblTop.setForeground(fg);
|
||||
lblBot.setForeground(fgDim);
|
||||
|
||||
if (index == currentIndex) {
|
||||
panel.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(0, 3, 0, 0,
|
||||
UIManager.getColor("List.selectionBackground")),
|
||||
BorderFactory.createEmptyBorder(3, 4, 3, 6)));
|
||||
} else {
|
||||
panel.setBorder(BorderFactory.createEmptyBorder(3, 7, 3, 6));
|
||||
}
|
||||
|
||||
lblTop.setText(t.getDisplayTitle());
|
||||
|
||||
String dur = t.getDurationSeconds() > 0 ? t.getDurationString() : "--:--";
|
||||
String artist = t.getArtist(), album = t.getAlbum();
|
||||
String sub = artist + " \u2013 " + album
|
||||
+ " [" + t.getFormat() + "]"
|
||||
+ " " + dur;
|
||||
lblBot.setText(sub);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Color blend(Color a, Color b, float t) {
|
||||
if (a == null) a = Color.WHITE;
|
||||
if (b == null) b = Color.LIGHT_GRAY;
|
||||
int r = (int)(a.getRed() + (b.getRed() - a.getRed()) * t);
|
||||
int g = (int)(a.getGreen() + (b.getGreen() - a.getGreen()) * t);
|
||||
int bl= (int)(a.getBlue() + (b.getBlue() - a.getBlue()) * t);
|
||||
return new Color(
|
||||
Math.max(0, Math.min(255, r)),
|
||||
Math.max(0, Math.min(255, g)),
|
||||
Math.max(0, Math.min(255, bl)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.rdesk.applications.system;
|
||||
|
||||
import com.rdesk.applications.system.login.Login;
|
||||
import com.rdesk.kernel.DesktopApp;
|
||||
import com.rdesk.kernel.Kernel;
|
||||
|
||||
@@ -22,7 +23,7 @@ public class Exit implements DesktopApp {
|
||||
@Override
|
||||
public JInternalFrame createWindow(Kernel kernel) {
|
||||
JInternalFrame frame = new JInternalFrame("System Controls", true, true);
|
||||
frame.setSize(350, 180);
|
||||
frame.setSize(420, 240);
|
||||
frame.setResizable(false);
|
||||
|
||||
JPanel mainPanel = new JPanel(new GridBagLayout());
|
||||
@@ -30,7 +31,7 @@ public class Exit implements DesktopApp {
|
||||
GridBagConstraints gbc = new GridBagConstraints();
|
||||
gbc.gridwidth = GridBagConstraints.REMAINDER;
|
||||
gbc.fill = GridBagConstraints.HORIZONTAL;
|
||||
gbc.insets = new Insets(5, 5, 5, 5);
|
||||
gbc.insets = new Insets(6, 6, 6, 6);
|
||||
|
||||
JLabel label = new JLabel("Choose an action:");
|
||||
label.setFont(label.getFont().deriveFont(Font.BOLD));
|
||||
@@ -38,8 +39,11 @@ public class Exit implements DesktopApp {
|
||||
|
||||
JButton shutdownButton = new JButton("Shutdown rDesk");
|
||||
JButton shutdownOS = new JButton("Shutdown Host OS");
|
||||
JButton logoffButton = new JButton("Log off");
|
||||
|
||||
shutdownButton.setFocusPainted(false);
|
||||
shutdownButton.addActionListener(e -> confirmAndShutdown(kernel, frame));
|
||||
|
||||
shutdownOS.setFocusPainted(false);
|
||||
shutdownOS.addActionListener(e -> {
|
||||
try {
|
||||
@@ -48,8 +52,37 @@ public class Exit implements DesktopApp {
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
logoffButton.setFocusPainted(false);
|
||||
|
||||
logoffButton.addActionListener(e -> {
|
||||
int choice = JOptionPane.showConfirmDialog(
|
||||
frame,
|
||||
"Log off and return to the login screen?",
|
||||
"Confirm Log off",
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE
|
||||
);
|
||||
if (choice != JOptionPane.YES_OPTION) return;
|
||||
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
Login.main(null);
|
||||
} catch (Exception ex) {
|
||||
JOptionPane.showMessageDialog(frame,
|
||||
"Failed to launch login screen: " + ex.getMessage(),
|
||||
"Error",
|
||||
JOptionPane.ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
kernel.logoff();
|
||||
});
|
||||
});
|
||||
|
||||
mainPanel.add(shutdownButton, gbc);
|
||||
mainPanel.add(shutdownOS, gbc);
|
||||
mainPanel.add(logoffButton, gbc);
|
||||
|
||||
JButton cancelButton = new JButton("Cancel");
|
||||
cancelButton.setFocusPainted(false);
|
||||
@@ -89,11 +122,11 @@ public class Exit implements DesktopApp {
|
||||
"Are you sure? This will shutdown the Host OS",
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE
|
||||
);
|
||||
);
|
||||
|
||||
if (choice == JOptionPane.YES_OPTION) {
|
||||
frame.dispose();
|
||||
performShutdownCmd(); // cause else the call wouldnt be called
|
||||
performShutdownCmd();
|
||||
kernel.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -107,8 +140,8 @@ public class Exit implements DesktopApp {
|
||||
JOptionPane.WARNING_MESSAGE
|
||||
);
|
||||
if (choice == JOptionPane.YES_OPTION) {
|
||||
frame.dispose(); // close the confirmation window first
|
||||
frame.dispose();
|
||||
kernel.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
|
||||
package com.rdesk.applications.system.login;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.awt.Window;
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.FileWriter;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
public class Handler {
|
||||
private static final String FILENAME = System.getProperty("user.home") + File.separator + "rsys" + File.separator + "rsys_passwdstore.json";
|
||||
private static final SecureRandom RNG = new SecureRandom();
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
private static final int SALT_LEN = 16;
|
||||
private static final int KEY_LEN = 256; // bits
|
||||
private static final int ITERATIONS = 200_000;
|
||||
private static final String ALGO = "PBKDF2WithHmacSHA256";
|
||||
|
||||
|
||||
|
||||
public static boolean storeExists() {
|
||||
return new File(FILENAME).exists();
|
||||
}
|
||||
|
||||
public static UserRecord load() throws Exception {
|
||||
File f = new File(FILENAME);
|
||||
if (!f.exists()) return null;
|
||||
try (FileReader fr = new FileReader(f)) {
|
||||
return GSON.fromJson(fr, UserRecord.class);
|
||||
}
|
||||
}
|
||||
|
||||
public static void save(UserRecord u) throws Exception {
|
||||
try (FileWriter fw = new FileWriter(FILENAME)) {
|
||||
GSON.toJson(u, fw);
|
||||
}
|
||||
}
|
||||
|
||||
public static UserRecord createUser(String username, char[] password) throws Exception {
|
||||
byte[] salt = new byte[SALT_LEN];
|
||||
RNG.nextBytes(salt);
|
||||
byte[] hash = pbkdf2(password, salt, ITERATIONS, KEY_LEN);
|
||||
UserRecord u = new UserRecord();
|
||||
u.username = username;
|
||||
u.saltB64 = Base64.getEncoder().encodeToString(salt);
|
||||
u.hashB64 = Base64.getEncoder().encodeToString(hash);
|
||||
u.iterations = ITERATIONS;
|
||||
return u;
|
||||
}
|
||||
|
||||
public static boolean verifyPassword(char[] password, UserRecord stored) throws Exception {
|
||||
byte[] salt = Base64.getDecoder().decode(stored.saltB64);
|
||||
byte[] expected = Base64.getDecoder().decode(stored.hashB64);
|
||||
byte[] computed = pbkdf2(password, salt, stored.iterations, expected.length * 8);
|
||||
return slowEquals(expected, computed);
|
||||
}
|
||||
|
||||
public static boolean verifyAndClose(Window window, char[] password, UserRecord stored) throws Exception {
|
||||
try {
|
||||
boolean ok = verifyPassword(password, stored);
|
||||
if (ok && window != null) {
|
||||
window.dispose();
|
||||
}
|
||||
return ok;
|
||||
} finally {
|
||||
clearPassword(password);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pbkdf2(char[] password, byte[] salt, int iterations, int keyLengthBits) throws Exception {
|
||||
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyLengthBits);
|
||||
SecretKeyFactory skf = SecretKeyFactory.getInstance(ALGO);
|
||||
return skf.generateSecret(spec).getEncoded();
|
||||
}
|
||||
|
||||
private static boolean slowEquals(byte[] a, byte[] b) {
|
||||
if (a == null || b == null || a.length != b.length) return false;
|
||||
int diff = 0;
|
||||
for (int i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
public static void clearPassword(char[] p) {
|
||||
if (p == null) return;
|
||||
Arrays.fill(p, (char) 0);
|
||||
}
|
||||
|
||||
public static String[] listUsers() {
|
||||
File f = new File(FILENAME);
|
||||
if (!f.exists()) return new String[0];
|
||||
try (FileReader fr = new FileReader(f)) {
|
||||
JsonElement root = JsonParser.parseReader(fr);
|
||||
List<String> users = new ArrayList<>();
|
||||
if (root.isJsonArray()) {
|
||||
JsonArray arr = root.getAsJsonArray();
|
||||
for (JsonElement el : arr) {
|
||||
try {
|
||||
UserRecord ur = GSON.fromJson(el, UserRecord.class);
|
||||
if (ur != null && ur.username != null) users.add(ur.username);
|
||||
} catch (Throwable ignore) { /* skip malformed entry */ }
|
||||
}
|
||||
} else if (root.isJsonObject()) {
|
||||
try {
|
||||
UserRecord ur = GSON.fromJson(root, UserRecord.class);
|
||||
if (ur != null && ur.username != null) users.add(ur.username);
|
||||
} catch (Throwable ignore) { /* malformed object -> no users */ }
|
||||
}
|
||||
return users.toArray(new String[0]);
|
||||
} catch (Exception e) {
|
||||
return new String[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.rdesk.applications.system.login;
|
||||
|
||||
import com.rdesk.kernel.BootMgr;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class Login {
|
||||
private JFrame frame;
|
||||
private JComboBox<String> userCombo;
|
||||
private JTextField userFieldFallback;
|
||||
private JPasswordField passField;
|
||||
private JLabel statusLabel;
|
||||
private UserRecord stored;
|
||||
private JButton loginBtn;
|
||||
private JButton closeBtn;
|
||||
|
||||
public static void main(String[] args) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
new Login().start();
|
||||
} catch (Exception e) {
|
||||
JOptionPane.showMessageDialog(null, e);
|
||||
System.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void start() throws Exception {
|
||||
log("start(): checking store existence");
|
||||
if (!Handler.storeExists()) {
|
||||
showCreateUserDialog();
|
||||
}
|
||||
stored = Handler.load();
|
||||
log("start(): loaded user record: " + (stored != null ? stored.username : "null"));
|
||||
buildAndShowGui();
|
||||
}
|
||||
|
||||
private void buildAndShowGui() {
|
||||
log("buildAndShowGui(): building GUI");
|
||||
frame = new JFrame("rDesk Login");
|
||||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||||
frame.setResizable(false);
|
||||
|
||||
Image icon = createImageFromSwingIcon(UIManager.getIcon("FileView.computerIcon"));
|
||||
if (icon != null) frame.setIconImage(icon);
|
||||
|
||||
JPanel panel = new JPanel(new GridBagLayout());
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(6, 6, 6, 6);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
|
||||
int row = 0;
|
||||
|
||||
if (icon != null) {
|
||||
JLabel iconLabel = new JLabel(new ImageIcon(icon.getScaledInstance(64, 64, Image.SCALE_SMOOTH)));
|
||||
GridBagConstraints ic = (GridBagConstraints) c.clone();
|
||||
ic.gridx = 0;
|
||||
ic.gridy = row;
|
||||
ic.gridheight = 2;
|
||||
ic.anchor = GridBagConstraints.CENTER;
|
||||
panel.add(iconLabel, ic);
|
||||
}
|
||||
|
||||
int colOffset = (icon != null) ? 1 : 0;
|
||||
|
||||
c.gridx = colOffset;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 1;
|
||||
panel.add(new JLabel("Username:"), c);
|
||||
|
||||
c.gridx = colOffset + 1;
|
||||
c.gridy = row++;
|
||||
c.weightx = 1.0;
|
||||
String[] users = fetchUserList();
|
||||
if (users.length > 0) {
|
||||
userCombo = new JComboBox<>(users);
|
||||
userCombo.setEditable(false);
|
||||
userCombo.setPreferredSize(new Dimension(160, 24));
|
||||
panel.add(userCombo, c);
|
||||
} else {
|
||||
userFieldFallback = new JTextField(16);
|
||||
userFieldFallback.setPreferredSize(new Dimension(160, 24));
|
||||
panel.add(userFieldFallback, c);
|
||||
}
|
||||
|
||||
c.gridx = colOffset;
|
||||
c.gridy = row;
|
||||
c.weightx = 0;
|
||||
panel.add(new JLabel("Password:"), c);
|
||||
|
||||
c.gridx = colOffset + 1;
|
||||
c.gridy = row++;
|
||||
c.weightx = 1.0;
|
||||
passField = new JPasswordField(16);
|
||||
passField.setPreferredSize(new Dimension(160, 24));
|
||||
panel.add(passField, c);
|
||||
|
||||
closeBtn = new JButton("Close");
|
||||
closeBtn.addActionListener(e -> frame.dispose());
|
||||
loginBtn = new JButton("Login");
|
||||
loginBtn.addActionListener(e -> onLogin());
|
||||
|
||||
c.gridx = colOffset;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 1;
|
||||
c.weightx = 0.5;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
panel.add(closeBtn, c);
|
||||
|
||||
c.gridx = colOffset + 1;
|
||||
c.gridy = row++;
|
||||
c.weightx = 0.5;
|
||||
panel.add(loginBtn, c);
|
||||
|
||||
statusLabel = new JLabel(" ");
|
||||
statusLabel.setForeground(Color.BLUE);
|
||||
c.gridx = colOffset;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
c.weightx = 1.0;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
panel.add(statusLabel, c);
|
||||
|
||||
frame.getContentPane().add(panel);
|
||||
frame.getRootPane().setDefaultButton(loginBtn);
|
||||
|
||||
passField.addActionListener(e -> onLogin());
|
||||
if (userFieldFallback != null) userFieldFallback.addActionListener(e -> onLogin());
|
||||
if (userCombo != null) {
|
||||
userCombo.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke("ENTER"), "login");
|
||||
userCombo.getActionMap().put("login", new AbstractAction() {
|
||||
@Override public void actionPerformed(ActionEvent e) { onLogin(); }
|
||||
});
|
||||
}
|
||||
|
||||
frame.pack();
|
||||
Dimension total = frame.getContentPane().getSize();
|
||||
int gap = 12;
|
||||
int buttonWidth = Math.max(90, (total.width - gap) / 2);
|
||||
closeBtn.setPreferredSize(new Dimension(buttonWidth, 28));
|
||||
loginBtn.setPreferredSize(new Dimension(buttonWidth, 28));
|
||||
frame.pack();
|
||||
|
||||
frame.setLocationRelativeTo(null);
|
||||
frame.setVisible(true);
|
||||
log("buildAndShowGui(): GUI visible");
|
||||
}
|
||||
|
||||
private Image createImageFromSwingIcon(Icon icon) {
|
||||
if (icon == null) return null;
|
||||
int w = Math.max(1, icon.getIconWidth());
|
||||
int h = Math.max(1, icon.getIconHeight());
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
try {
|
||||
icon.paintIcon(null, g, 0, 0);
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
private void onLogin() {
|
||||
String userAttempt = (userCombo != null)
|
||||
? String.valueOf(userCombo.getSelectedItem()).trim()
|
||||
: userFieldFallback.getText().trim();
|
||||
char[] passAttempt = passField.getPassword();
|
||||
|
||||
log("onLogin(): attempt for user='" + userAttempt + "'");
|
||||
|
||||
if (stored == null) {
|
||||
statusLabel.setText("No user record found.");
|
||||
Handler.clearPassword(passAttempt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stored.username.equals(userAttempt)) {
|
||||
statusLabel.setText("Invalid username or password.");
|
||||
Handler.clearPassword(passAttempt);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
boolean ok = Handler.verifyAndClose(frame, passAttempt, stored);
|
||||
if (ok) {
|
||||
log("onLogin(): authentication successful for user='" + userAttempt + "'");
|
||||
statusLabel.setText(" ");
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
new SwingWorker<Void, Void>() {
|
||||
@Override protected Void doInBackground() {
|
||||
try {
|
||||
BootMgr.launchOrShow();
|
||||
log("[Login]: BootManager.launchOrShow() completed");
|
||||
} catch (Exception e) {
|
||||
log("onLogin(): BootManager error: " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}.execute();
|
||||
});
|
||||
} else {
|
||||
statusLabel.setText("Invalid username or password.");
|
||||
log("onLogin(): password verification failed for user='" + userAttempt + "'");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
ex.printStackTrace();
|
||||
statusLabel.setText("Error during verification.");
|
||||
log("onLogin(): exception during verification: " + ex.getMessage());
|
||||
} finally {
|
||||
Handler.clearPassword(passAttempt);
|
||||
passField.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
private void showCreateUserDialog() throws Exception {
|
||||
log("showCreateUserDialog(): creating user dialog");
|
||||
JPanel p = new JPanel(new GridLayout(3,2,6,6));
|
||||
JTextField user = new JTextField(16);
|
||||
JPasswordField pass = new JPasswordField(16);
|
||||
JPasswordField pass2 = new JPasswordField(16);
|
||||
p.add(new JLabel("Username:")); p.add(user);
|
||||
p.add(new JLabel("Password:")); p.add(pass);
|
||||
p.add(new JLabel("Confirm:")); p.add(pass2);
|
||||
|
||||
int res = JOptionPane.showConfirmDialog(null, p, "Create User", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
|
||||
if (res != JOptionPane.OK_OPTION) {
|
||||
log("showCreateUserDialog(): user cancelled creation, exiting");
|
||||
System.exit(0);
|
||||
}
|
||||
if (!String.valueOf(pass.getPassword()).equals(String.valueOf(pass2.getPassword()))) {
|
||||
JOptionPane.showMessageDialog(null, "Passwords do not match.", "Error", JOptionPane.ERROR_MESSAGE);
|
||||
log("showCreateUserDialog(): passwords did not match");
|
||||
showCreateUserDialog();
|
||||
return;
|
||||
}
|
||||
UserRecord u = Handler.createUser(user.getText().trim(), pass.getPassword());
|
||||
Handler.save(u);
|
||||
log("showCreateUserDialog(): created and saved user '" + u.username + "'");
|
||||
JOptionPane.showMessageDialog(null, "User created.", "Success", JOptionPane.INFORMATION_MESSAGE);
|
||||
}
|
||||
|
||||
private String[] fetchUserList() {
|
||||
try {
|
||||
log("fetchUserList(): attempting to retrieve user list from Handler");
|
||||
String[] users = Handler.listUsers();
|
||||
if (users == null || users.length == 0) {
|
||||
if (stored != null) {
|
||||
return new String[]{stored.username};
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
return users;
|
||||
} catch (Exception t) {
|
||||
if (stored != null) return new String[]{stored.username};
|
||||
return new String[0];
|
||||
}
|
||||
}
|
||||
|
||||
private void log(String msg) {
|
||||
System.out.println("[Login]: " + msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.rdesk.applications.system.login;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public class UserRecord {
|
||||
@SerializedName("username") public String username;
|
||||
@SerializedName("salt_b64") public String saltB64;
|
||||
@SerializedName("hash_b64") public String hashB64;
|
||||
@SerializedName("iterations") public int iterations;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.rdesk.applications.system.rsys;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.rdesk.kernel.DesktopApp;
|
||||
import com.rdesk.kernel.Kernel;
|
||||
import com.rdesk.kernel.rsys.KbInterface;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.KeyAdapter;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.io.Reader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
public class KbChanger implements DesktopApp {
|
||||
private static final String[] LAYOUTS = {
|
||||
"us", "uk", "de", "fr", "es", "it", "ru", "jp", "br", "cz"
|
||||
};
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "kbchanger";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "KbChanger";
|
||||
}
|
||||
|
||||
@Override
|
||||
public JInternalFrame createWindow(Kernel kernel) {
|
||||
JInternalFrame frame = new JInternalFrame(
|
||||
"Keyboard Layout",
|
||||
true, // resizable
|
||||
true, // closable
|
||||
true, // maximizable
|
||||
true // iconifiable
|
||||
);
|
||||
|
||||
frame.setSize(420, 200);
|
||||
frame.setLayout(new BorderLayout(8, 8));
|
||||
frame.setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
|
||||
|
||||
JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 12));
|
||||
top.add(new JLabel("Layout:"));
|
||||
|
||||
JComboBox<String> layoutCombo = new JComboBox<>(LAYOUTS);
|
||||
layoutCombo.setEditable(false);
|
||||
top.add(layoutCombo);
|
||||
|
||||
JButton applyBtn = new JButton("Apply");
|
||||
top.add(applyBtn);
|
||||
|
||||
frame.add(top, BorderLayout.NORTH);
|
||||
|
||||
JPanel center = new JPanel(new BorderLayout(6, 6));
|
||||
JLabel hint = new JLabel("Type here to test the selected layout:");
|
||||
center.add(hint, BorderLayout.NORTH);
|
||||
|
||||
JTextField testField = new JTextField();
|
||||
center.add(testField, BorderLayout.CENTER);
|
||||
|
||||
JLabel status = new JLabel("Last applied: (none)");
|
||||
status.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6));
|
||||
center.add(status, BorderLayout.SOUTH);
|
||||
|
||||
frame.add(center, BorderLayout.CENTER);
|
||||
|
||||
KbInterface kb = new KbInterface();
|
||||
|
||||
try {
|
||||
Path configPath = Paths.get(System.getProperty("user.home"), "rsys", "rsys_kblayout.json");
|
||||
if (Files.exists(configPath)) {
|
||||
try (Reader reader = Files.newBufferedReader(configPath)) {
|
||||
JsonObject config = JsonParser.parseReader(reader).getAsJsonObject();
|
||||
if (config.has("kblayout")) {
|
||||
String savedLayout = config.get("kblayout").getAsString();
|
||||
layoutCombo.setSelectedItem(savedLayout);
|
||||
status.setText("Last applied: " + savedLayout);
|
||||
|
||||
kb.setKbLayout(savedLayout);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
System.err.println("Failed to read existing kb layout config: " + ex.getMessage());
|
||||
}
|
||||
|
||||
applyBtn.addActionListener((ActionEvent e) -> {
|
||||
String selected = (String) layoutCombo.getSelectedItem();
|
||||
if (selected == null || selected.trim().isEmpty()) {
|
||||
JOptionPane.showMessageDialog(frame, "Please select a layout.", "Keyboard Layout", JOptionPane.WARNING_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
kb.setKbLayout(selected);
|
||||
kb.saveLayout(selected);
|
||||
|
||||
status.setText("Last applied: " + selected);
|
||||
SwingUtilities.invokeLater(testField::requestFocusInWindow);
|
||||
});
|
||||
|
||||
testField.addKeyListener(new KeyAdapter() {
|
||||
@Override
|
||||
public void keyPressed(KeyEvent e) {
|
||||
if (e.getKeyCode() == KeyEvent.VK_ENTER) {
|
||||
applyBtn.doClick();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
frame.setVisible(true);
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.rdesk.kernel;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
|
||||
public final class BootMgr {
|
||||
private static JFrame desktopFrame;
|
||||
private BootMgr() {}
|
||||
|
||||
public static synchronized void launchOrShow() {
|
||||
if (desktopFrame == null) {
|
||||
JFrame f = BootProc.boot();
|
||||
if (f != null) desktopFrame = f;
|
||||
else {
|
||||
desktopFrame = new JFrame();
|
||||
desktopFrame.setContentPane(f);
|
||||
desktopFrame.pack();
|
||||
}
|
||||
desktopFrame.setVisible(true);
|
||||
desktopFrame.toFront();
|
||||
} else {
|
||||
if (!desktopFrame.isVisible()) desktopFrame.setVisible(true);
|
||||
desktopFrame.toFront();
|
||||
desktopFrame.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void hideDesktopOnLogout() {
|
||||
if (desktopFrame != null) {
|
||||
desktopFrame.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void disposeDesktop() {
|
||||
if (desktopFrame != null) {
|
||||
desktopFrame.dispose();
|
||||
desktopFrame = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.rdesk.kernel;
|
||||
|
||||
import com.rdesk.applications.entertainment.comic.Xkcd;
|
||||
import com.rdesk.applications.entertainment.media.Wavy;
|
||||
import com.rdesk.applications.productivity.Calculator;
|
||||
import com.rdesk.applications.productivity.Notes;
|
||||
import com.rdesk.applications.productivity.browsing.TabbedBrowser;
|
||||
import com.rdesk.applications.productivity.code.CodeDesk;
|
||||
import com.rdesk.applications.system.Exit;
|
||||
import com.rdesk.applications.system.TaskManager;
|
||||
import com.rdesk.applications.system.filehaj.FileExplorerApp;
|
||||
import com.rdesk.applications.system.rsys.KbChanger;
|
||||
import com.rdesk.kernel.launcher.AppLauncher;
|
||||
|
||||
import javax.swing.*;
|
||||
|
||||
public class BootProc {
|
||||
private static final boolean RUN_IN_FS = true;
|
||||
private static final boolean USE_SYSDEFAULT_LAF = false;
|
||||
private static final boolean USE_FLATLAF = false;
|
||||
private static final boolean loadSettingsKernelFlag = true;
|
||||
|
||||
private static JFrame mainFrame;
|
||||
private static JDesktopPane desktopPane;
|
||||
|
||||
public static synchronized JFrame boot() {
|
||||
if (mainFrame != null) {
|
||||
if (!mainFrame.isVisible()) mainFrame.setVisible(true);
|
||||
mainFrame.toFront();
|
||||
mainFrame.requestFocus();
|
||||
return mainFrame;
|
||||
}
|
||||
|
||||
Kernel kernel = Kernel.get();
|
||||
kernel.initSettings(loadSettingsKernelFlag);
|
||||
kernel.boot();
|
||||
|
||||
mainFrame = new JFrame("rDesk");
|
||||
mainFrame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
|
||||
mainFrame.addWindowListener(new java.awt.event.WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(java.awt.event.WindowEvent e) {
|
||||
kernel.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
if (USE_FLATLAF) {
|
||||
try {
|
||||
UIManager.setLookAndFeel("com.formdev.flatlaf.themes.FlatMacLightLaf");
|
||||
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
|
||||
JOptionPane.showMessageDialog(null, ex.getMessage() + "@USE_FLATLAF");
|
||||
}
|
||||
}
|
||||
|
||||
if (USE_SYSDEFAULT_LAF) {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
|
||||
JOptionPane.showMessageDialog(null, ex.getMessage() + "@USE_SYSDEFAULT_LAF");
|
||||
}
|
||||
}
|
||||
|
||||
if (RUN_IN_FS) {
|
||||
mainFrame.setUndecorated(true);
|
||||
mainFrame.setExtendedState(JFrame.MAXIMIZED_BOTH);
|
||||
} else {
|
||||
mainFrame.setSize(1200, 800);
|
||||
mainFrame.setLocationRelativeTo(null);
|
||||
}
|
||||
|
||||
desktopPane = new JDesktopPane();
|
||||
kernel.windows().attachDesktop(desktopPane);
|
||||
kernel.attachFrame(mainFrame);
|
||||
|
||||
AppLauncher launcher = new AppLauncher(kernel);
|
||||
registerSystemApps(launcher);
|
||||
|
||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, launcher, desktopPane);
|
||||
split.setDividerSize(0);
|
||||
split.setEnabled(false);
|
||||
|
||||
mainFrame.setContentPane(split);
|
||||
mainFrame.setVisible(true);
|
||||
|
||||
return mainFrame;
|
||||
}
|
||||
|
||||
public static synchronized JFrame getMainFrame() {
|
||||
return mainFrame;
|
||||
}
|
||||
|
||||
public static synchronized JDesktopPane getDesktopPane() {
|
||||
return desktopPane;
|
||||
}
|
||||
|
||||
private static void registerSystemApps(AppLauncher launcher) {
|
||||
// productivity
|
||||
launcher.addApp(new Notes(), "Productivity");
|
||||
launcher.addApp(new TabbedBrowser(), "Productivity");
|
||||
launcher.addApp(new CodeDesk(), "Productivity");
|
||||
launcher.addApp(new Calculator(), "Productivity");
|
||||
// entertainment
|
||||
launcher.addApp(new Xkcd(), "Entertainment");
|
||||
launcher.addApp(new Wavy(), "Entertainment");
|
||||
// System apps
|
||||
launcher.addApp(new Exit(), "System");
|
||||
launcher.addApp(new TaskManager(), "System");
|
||||
launcher.addApp(new FileExplorerApp(), "System");
|
||||
launcher.addApp(new KbChanger(), "System");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.rdesk.kernel;
|
||||
|
||||
import com.rdesk.kernel.launcher.AppLauncher;
|
||||
import com.rdesk.kernel.rsys.KbInterface;
|
||||
|
||||
import javax.swing.*;
|
||||
|
||||
public class Kernel {
|
||||
|
||||
private static volatile Kernel instance;
|
||||
|
||||
// set to true in production builds
|
||||
private boolean initSettings = true;
|
||||
|
||||
private final AppManager appManager;
|
||||
private final WindowManager windowManager;
|
||||
private final EventBus eventBus;
|
||||
@@ -35,18 +41,41 @@ public class Kernel {
|
||||
|
||||
public void boot() {
|
||||
running = true;
|
||||
System.out.println("[Kernel] Booting...");
|
||||
kInfo("Booting");
|
||||
eventBus.emit("kernel.boot");
|
||||
// load keyboard layout:
|
||||
if (initSettings)
|
||||
initRSysSettings();
|
||||
}
|
||||
|
||||
|
||||
private void initRSysSettings() {
|
||||
try {
|
||||
kInfo("loading system config for : " + (String) System.getProperty("os.name"));
|
||||
KbInterface kbInterface = new KbInterface();
|
||||
kbInterface.loadLayout();
|
||||
kInfo("loaded kbLayout");
|
||||
} catch (Exception e) {
|
||||
kErr("Failed to initialize system settings ; error message: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void attachFrame(JFrame frame) {
|
||||
this.desktopFrame = frame;
|
||||
}
|
||||
|
||||
|
||||
public void logoff() {
|
||||
if (running) SwingUtilities.invokeLater(() -> {
|
||||
desktopFrame.setVisible(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public void shutdown() {
|
||||
if (!running) return;
|
||||
|
||||
System.out.println("[Kernel] Shutdown started");
|
||||
kInfo("Shutting down");
|
||||
running = false;
|
||||
|
||||
appManager.stopAll();
|
||||
@@ -57,10 +86,16 @@ public class Kernel {
|
||||
}
|
||||
|
||||
eventBus.emit("kernel.shutdown");
|
||||
System.out.println("[Kernel] Shutdown complete");
|
||||
kInfo("Shut down Kernel");
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
// kernel info string printers
|
||||
private void kErr(String errmsg) { System.err.println("[KERNEL FAULT]: " + errmsg); }
|
||||
private void kInfo(String infomsg) { System.out.println("[Kernel]: " + infomsg); }
|
||||
|
||||
// public kernel api
|
||||
public void initSettings(boolean flag) { initSettings = flag; }
|
||||
public AppManager apps() { return appManager; }
|
||||
public WindowManager windows() { return windowManager; }
|
||||
public EventBus events() { return eventBus; }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.rdesk.kernel.rsys;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
public class KbInterface {
|
||||
/* DISCLAIMER:
|
||||
* This is a class which is though to be for the rsys operating system,
|
||||
* it only supports the keyboard set option for x11, macos and windows isnt supported
|
||||
*/
|
||||
private static class KbConfig {
|
||||
String kblayout;
|
||||
|
||||
public KbConfig(String kblayout) {
|
||||
this.kblayout = kblayout;
|
||||
}
|
||||
}
|
||||
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
public void setKbLayout(String kbLayout) {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
try {
|
||||
if (os.contains("nix") || os.contains("nux") || os.contains("aix") || os.contains("linux")) {
|
||||
setLinuxLayout(kbLayout); // actual work!
|
||||
} else if (os.contains("mac")) {
|
||||
logErr("mac is too shitty for this");
|
||||
} else {
|
||||
logErr("what are you running?: " + os);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
showError("The keyboard layout could not be set " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void loadLayout() {
|
||||
Path configPath = getConfigPath();
|
||||
if (!Files.exists(configPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Reader reader = Files.newBufferedReader(configPath)) {
|
||||
KbConfig config = gson.fromJson(reader, KbConfig.class);
|
||||
if (config != null && config.kblayout != null && !config.kblayout.isBlank()) {
|
||||
setKbLayout(config.kblayout);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logErr("Failed to load layout configuration: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void saveLayout(String kbLayout) {
|
||||
Path configPath = getConfigPath();
|
||||
try {
|
||||
Files.createDirectories(configPath.getParent());
|
||||
try (Writer writer = Files.newBufferedWriter(configPath)) {
|
||||
gson.toJson(new KbConfig(kbLayout), writer);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logErr("Failed to save layout configuration: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Path getConfigPath() {
|
||||
return Paths.get(System.getProperty("user.home"), "rsys", "rsys_kblayout.json");
|
||||
}
|
||||
|
||||
private void setLinuxLayout(String kbLayout) throws Exception {
|
||||
new ProcessBuilder("setxkbmap", kbLayout)
|
||||
.inheritIO()
|
||||
.start()
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
private void logErr(String message) { System.err.println("[KbInterface.java]: " + (String) message); }
|
||||
|
||||
private void showError(String message) {
|
||||
JOptionPane.showMessageDialog(
|
||||
null,
|
||||
message,
|
||||
"Keyboard Layout",
|
||||
JOptionPane.ERROR_MESSAGE
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user