Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 372d2f450a | |||
| f160b07bf4 | |||
| 307062eb35 | |||
| 72d5df2c70 | |||
| 19f9f1f3a6 |
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>org.openjfx</groupId>
|
||||||
<artifactId>javafx-maven-plugin</artifactId>
|
<artifactId>javafx-maven-plugin</artifactId>
|
||||||
@@ -24,6 +25,34 @@
|
|||||||
<mainClass>com.rdesk.Main</mainClass>
|
<mainClass>com.rdesk.Main</mainClass>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</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>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
@@ -91,7 +120,16 @@
|
|||||||
<artifactId>languagesupport</artifactId>
|
<artifactId>languagesupport</artifactId>
|
||||||
<version>3.4.1</version>
|
<version>3.4.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<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>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@@ -1,95 +1,15 @@
|
|||||||
package com.rdesk;
|
package com.rdesk;
|
||||||
|
|
||||||
import com.rdesk.applications.productivity.Calculator;
|
import com.rdesk.applications.system.login.Login;
|
||||||
import com.rdesk.applications.productivity.browsing.TabbedBrowser;
|
import com.rdesk.kernel.BootProc;
|
||||||
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.*;
|
|
||||||
|
|
||||||
public class Main {
|
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) {
|
public static void main(String[] args) {
|
||||||
SwingUtilities.invokeLater(Main::boot);
|
boolean dbg = true;
|
||||||
}
|
if (!dbg) {
|
||||||
|
Login.main(args);
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
frame.setSize(1200, 800);
|
BootProc.boot();
|
||||||
frame.setLocationRelativeTo(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,375 @@
|
|||||||
|
package com.rdesk.applications.entertainment.media;
|
||||||
|
|
||||||
|
import javax.sound.sampled.*;
|
||||||
|
import java.io.*;
|
||||||
|
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
|
||||||
|
|
||||||
|
// eq
|
||||||
|
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];
|
||||||
|
|
||||||
|
// fft
|
||||||
|
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 API
|
||||||
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
AudioInputStream rawStream = AudioSystem.getAudioInputStream(track.getFilePath().toFile());
|
||||||
|
AudioFormat rawFmt = rawStream.getFormat();
|
||||||
|
|
||||||
|
AudioFormat pcmFmt = new AudioFormat(
|
||||||
|
AudioFormat.Encoding.PCM_SIGNED,
|
||||||
|
rawFmt.getSampleRate(),
|
||||||
|
16,
|
||||||
|
rawFmt.getChannels(),
|
||||||
|
rawFmt.getChannels() * 2,
|
||||||
|
rawFmt.getSampleRate(),
|
||||||
|
false);
|
||||||
|
|
||||||
|
AudioInputStream pcmStream;
|
||||||
|
if (rawFmt.getEncoding() != AudioFormat.Encoding.PCM_SIGNED
|
||||||
|
|| rawFmt.getSampleSizeInBits() != 16) {
|
||||||
|
pcmStream = AudioSystem.getAudioInputStream(pcmFmt, rawStream);
|
||||||
|
} else {
|
||||||
|
pcmStream = rawStream;
|
||||||
|
pcmFmt = rawFmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFormat = pcmFmt;
|
||||||
|
int frameSize = pcmFmt.getFrameSize();
|
||||||
|
float sampleRate = pcmFmt.getSampleRate();
|
||||||
|
int channels = pcmFmt.getChannels();
|
||||||
|
|
||||||
|
long pcmFrames = pcmStream.getFrameLength();
|
||||||
|
if (pcmFrames > 0 && pcmFrames != AudioSystem.NOT_SPECIFIED) {
|
||||||
|
totalFrames = pcmFrames;
|
||||||
|
if (track.getDurationSeconds() == 0)
|
||||||
|
track.setDurationSeconds((long)(pcmFrames / sampleRate));
|
||||||
|
} else if (track.getDurationSeconds() > 0) {
|
||||||
|
totalFrames = (long)(track.getDurationSeconds() * sampleRate);
|
||||||
|
} else {
|
||||||
|
totalFrames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataLine.Info lineInfo = new DataLine.Info(SourceDataLine.class, pcmFmt);
|
||||||
|
dataLine = (SourceDataLine) AudioSystem.getLine(lineInfo);
|
||||||
|
dataLine.open(pcmFmt, (int)(sampleRate * frameSize * 0.1f));
|
||||||
|
dataLine.start();
|
||||||
|
applyVolumeToLine(); // set initial volume
|
||||||
|
|
||||||
|
resetBiquadState();
|
||||||
|
|
||||||
|
final int BLOCK = 4096;
|
||||||
|
byte[] rawBuf = new byte[BLOCK];
|
||||||
|
float[] samples = new float[BLOCK / 2];
|
||||||
|
|
||||||
|
positionFrames = 0;
|
||||||
|
|
||||||
|
while (!stopRequested) {
|
||||||
|
// Pause
|
||||||
|
synchronized (this) {
|
||||||
|
while (pauseRequested && !stopRequested) {
|
||||||
|
try { wait(50); } catch (InterruptedException e) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stopRequested) break;
|
||||||
|
|
||||||
|
// Seek
|
||||||
|
if (seekPending) {
|
||||||
|
seekPending = false;
|
||||||
|
pcmStream.close();
|
||||||
|
rawStream.close();
|
||||||
|
rawStream = AudioSystem.getAudioInputStream(track.getFilePath().toFile());
|
||||||
|
if (rawFmt.getEncoding() != AudioFormat.Encoding.PCM_SIGNED
|
||||||
|
|| rawFmt.getSampleSizeInBits() != 16) {
|
||||||
|
pcmStream = AudioSystem.getAudioInputStream(pcmFmt, rawStream);
|
||||||
|
} else {
|
||||||
|
pcmStream = rawStream;
|
||||||
|
}
|
||||||
|
if (totalFrames > 0) {
|
||||||
|
long targetFrame = (long)(requestedSeekFraction * totalFrames);
|
||||||
|
long skip = targetFrame * frameSize;
|
||||||
|
byte[] skipBuf = new byte[8192];
|
||||||
|
long skipped = 0;
|
||||||
|
while (skipped < skip && !stopRequested) {
|
||||||
|
int toSkip = (int) Math.min(skipBuf.length, skip - skipped);
|
||||||
|
int r = pcmStream.read(skipBuf, 0, toSkip);
|
||||||
|
if (r < 0) break;
|
||||||
|
skipped += r;
|
||||||
|
}
|
||||||
|
positionFrames = targetFrame;
|
||||||
|
}
|
||||||
|
resetBiquadState();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bytesRead = pcmStream.read(rawBuf);
|
||||||
|
if (bytesRead <= 0) break;
|
||||||
|
|
||||||
|
// bytes to float
|
||||||
|
int sampleCount = bytesRead / 2;
|
||||||
|
for (int i = 0; i < sampleCount; i++) {
|
||||||
|
int lo = rawBuf[i*2] & 0xFF;
|
||||||
|
int hi = rawBuf[i*2+1];
|
||||||
|
samples[i] = (hi << 8 | lo) / 32768.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEq(samples, sampleCount, channels);
|
||||||
|
feedFft(samples, sampleCount, channels);
|
||||||
|
|
||||||
|
// other way around, float to bytes
|
||||||
|
for (int i = 0; i < sampleCount; i++) {
|
||||||
|
float s = Math.max(-1f, Math.min(1f, samples[i]));
|
||||||
|
short sh = (short)(s * 32767f);
|
||||||
|
rawBuf[i*2] = (byte)(sh & 0xFF);
|
||||||
|
rawBuf[i*2+1] = (byte)((sh >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
dataLine.write(rawBuf, 0, bytesRead);
|
||||||
|
positionFrames += bytesRead / frameSize;
|
||||||
|
if (onPositionUpdate != null) onPositionUpdate.accept(positionFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
pcmStream.close();
|
||||||
|
rawStream.close();
|
||||||
|
if (!stopRequested) dataLine.drain();
|
||||||
|
closeLine();
|
||||||
|
state = State.STOPPED;
|
||||||
|
if (!stopRequested && onTrackEnd != null) onTrackEnd.run();
|
||||||
|
|
||||||
|
} catch (UnsupportedAudioFileException e) {
|
||||||
|
System.err.println("[Wavy] Unsupported format: " + track.getFilePath());
|
||||||
|
state = State.STOPPED;
|
||||||
|
} catch (LineUnavailableException | IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
state = State.STOPPED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
// map 0..1 linearly to the controls min..max db range
|
||||||
|
float minDb = gc.getMinimum();
|
||||||
|
float maxDb = 0f; // 0 dB is the unity gain
|
||||||
|
float db = (volume == 0f) ? minDb : minDb + (maxDb - minDb) * volume;
|
||||||
|
gc.setValue(Math.max(minDb, Math.min(maxDb, db)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EQ
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// fast fourier transform ( algo: cooley turkey, with the radix 2 dit case )
|
||||||
|
// 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
|
||||||
|
int bits = (int)(Math.log(n)/Math.log(2));
|
||||||
|
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,170 @@
|
|||||||
|
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)
|
||||||
|
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,206 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
float[] bars = computeBars();
|
||||||
|
int cellW = Math.max(1, w / BAR_COUNT);
|
||||||
|
for (int b = 0; b < BAR_COUNT; b++) {
|
||||||
|
int ci = (int)(bars[b] * (HEAT.length - 1));
|
||||||
|
waterfallG.setColor(HEAT[ci]);
|
||||||
|
waterfallG.fillRect(b * cellW, h - 1, cellW, 1);
|
||||||
|
}
|
||||||
|
if (BAR_COUNT * cellW < w) {
|
||||||
|
waterfallG.setColor(HEAT[0]);
|
||||||
|
waterfallG.fillRect(BAR_COUNT * cellW, h - 1, w - BAR_COUNT * cellW, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,312 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 extends DefaultListCellRenderer {
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<?> list, Object value,
|
||||||
|
int index, boolean isSelected, boolean hasFocus) {
|
||||||
|
Track t = (Track) value;
|
||||||
|
String dur = t.getDurationSeconds() > 0 ? t.getDurationString() : "--:--";
|
||||||
|
String html = String.format(
|
||||||
|
"<html>" +
|
||||||
|
"<b>%s</b>" +
|
||||||
|
" " +
|
||||||
|
"<small>%s - %s</small>" +
|
||||||
|
" " +
|
||||||
|
"<font color=gray>[%s] %s</font>" +
|
||||||
|
"</html>",
|
||||||
|
esc(t.getDisplayTitle()), esc(t.getArtist()), esc(t.getAlbum()),
|
||||||
|
t.getFormat(), dur);
|
||||||
|
JLabel lbl = (JLabel) super.getListCellRendererComponent(list, html, index, isSelected, hasFocus);
|
||||||
|
if (index == currentIndex)
|
||||||
|
lbl.setBorder(BorderFactory.createMatteBorder(0, 3, 0, 0,
|
||||||
|
UIManager.getColor("List.selectionBackground")));
|
||||||
|
return lbl;
|
||||||
|
}
|
||||||
|
private String esc(String s) {
|
||||||
|
return s.replace("&","&").replace("<","<").replace(">",">");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.rdesk.applications.system;
|
package com.rdesk.applications.system;
|
||||||
|
|
||||||
|
import com.rdesk.applications.system.login.Login;
|
||||||
import com.rdesk.kernel.DesktopApp;
|
import com.rdesk.kernel.DesktopApp;
|
||||||
import com.rdesk.kernel.Kernel;
|
import com.rdesk.kernel.Kernel;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ public class Exit implements DesktopApp {
|
|||||||
@Override
|
@Override
|
||||||
public JInternalFrame createWindow(Kernel kernel) {
|
public JInternalFrame createWindow(Kernel kernel) {
|
||||||
JInternalFrame frame = new JInternalFrame("System Controls", true, true);
|
JInternalFrame frame = new JInternalFrame("System Controls", true, true);
|
||||||
frame.setSize(350, 180);
|
frame.setSize(420, 240);
|
||||||
frame.setResizable(false);
|
frame.setResizable(false);
|
||||||
|
|
||||||
JPanel mainPanel = new JPanel(new GridBagLayout());
|
JPanel mainPanel = new JPanel(new GridBagLayout());
|
||||||
@@ -30,7 +31,7 @@ public class Exit implements DesktopApp {
|
|||||||
GridBagConstraints gbc = new GridBagConstraints();
|
GridBagConstraints gbc = new GridBagConstraints();
|
||||||
gbc.gridwidth = GridBagConstraints.REMAINDER;
|
gbc.gridwidth = GridBagConstraints.REMAINDER;
|
||||||
gbc.fill = GridBagConstraints.HORIZONTAL;
|
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:");
|
JLabel label = new JLabel("Choose an action:");
|
||||||
label.setFont(label.getFont().deriveFont(Font.BOLD));
|
label.setFont(label.getFont().deriveFont(Font.BOLD));
|
||||||
@@ -38,8 +39,11 @@ public class Exit implements DesktopApp {
|
|||||||
|
|
||||||
JButton shutdownButton = new JButton("Shutdown rDesk");
|
JButton shutdownButton = new JButton("Shutdown rDesk");
|
||||||
JButton shutdownOS = new JButton("Shutdown Host OS");
|
JButton shutdownOS = new JButton("Shutdown Host OS");
|
||||||
|
JButton logoffButton = new JButton("Log off");
|
||||||
|
|
||||||
shutdownButton.setFocusPainted(false);
|
shutdownButton.setFocusPainted(false);
|
||||||
shutdownButton.addActionListener(e -> confirmAndShutdown(kernel, frame));
|
shutdownButton.addActionListener(e -> confirmAndShutdown(kernel, frame));
|
||||||
|
|
||||||
shutdownOS.setFocusPainted(false);
|
shutdownOS.setFocusPainted(false);
|
||||||
shutdownOS.addActionListener(e -> {
|
shutdownOS.addActionListener(e -> {
|
||||||
try {
|
try {
|
||||||
@@ -48,8 +52,37 @@ public class Exit implements DesktopApp {
|
|||||||
throw new RuntimeException(ex);
|
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(shutdownButton, gbc);
|
||||||
mainPanel.add(shutdownOS, gbc);
|
mainPanel.add(shutdownOS, gbc);
|
||||||
|
mainPanel.add(logoffButton, gbc);
|
||||||
|
|
||||||
JButton cancelButton = new JButton("Cancel");
|
JButton cancelButton = new JButton("Cancel");
|
||||||
cancelButton.setFocusPainted(false);
|
cancelButton.setFocusPainted(false);
|
||||||
@@ -93,7 +126,7 @@ public class Exit implements DesktopApp {
|
|||||||
|
|
||||||
if (choice == JOptionPane.YES_OPTION) {
|
if (choice == JOptionPane.YES_OPTION) {
|
||||||
frame.dispose();
|
frame.dispose();
|
||||||
performShutdownCmd(); // cause else the call wouldnt be called
|
performShutdownCmd();
|
||||||
kernel.shutdown();
|
kernel.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +140,7 @@ public class Exit implements DesktopApp {
|
|||||||
JOptionPane.WARNING_MESSAGE
|
JOptionPane.WARNING_MESSAGE
|
||||||
);
|
);
|
||||||
if (choice == JOptionPane.YES_OPTION) {
|
if (choice == JOptionPane.YES_OPTION) {
|
||||||
frame.dispose(); // close the confirmation window first
|
frame.dispose();
|
||||||
kernel.shutdown();
|
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;
|
package com.rdesk.kernel;
|
||||||
|
|
||||||
|
import com.rdesk.kernel.launcher.AppLauncher;
|
||||||
|
import com.rdesk.kernel.rsys.KbInterface;
|
||||||
|
|
||||||
import javax.swing.*;
|
import javax.swing.*;
|
||||||
|
|
||||||
public class Kernel {
|
public class Kernel {
|
||||||
|
|
||||||
private static volatile Kernel instance;
|
private static volatile Kernel instance;
|
||||||
|
|
||||||
|
// set to true in production builds
|
||||||
|
private boolean initSettings = true;
|
||||||
|
|
||||||
private final AppManager appManager;
|
private final AppManager appManager;
|
||||||
private final WindowManager windowManager;
|
private final WindowManager windowManager;
|
||||||
private final EventBus eventBus;
|
private final EventBus eventBus;
|
||||||
@@ -35,18 +41,41 @@ public class Kernel {
|
|||||||
|
|
||||||
public void boot() {
|
public void boot() {
|
||||||
running = true;
|
running = true;
|
||||||
System.out.println("[Kernel] Booting...");
|
kInfo("Booting");
|
||||||
eventBus.emit("kernel.boot");
|
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) {
|
public void attachFrame(JFrame frame) {
|
||||||
this.desktopFrame = frame;
|
this.desktopFrame = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public void logoff() {
|
||||||
|
if (running) SwingUtilities.invokeLater(() -> {
|
||||||
|
desktopFrame.setVisible(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public void shutdown() {
|
public void shutdown() {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
|
|
||||||
System.out.println("[Kernel] Shutdown started");
|
kInfo("Shutting down");
|
||||||
running = false;
|
running = false;
|
||||||
|
|
||||||
appManager.stopAll();
|
appManager.stopAll();
|
||||||
@@ -57,10 +86,16 @@ public class Kernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
eventBus.emit("kernel.shutdown");
|
eventBus.emit("kernel.shutdown");
|
||||||
System.out.println("[Kernel] Shutdown complete");
|
kInfo("Shut down Kernel");
|
||||||
System.exit(0);
|
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 AppManager apps() { return appManager; }
|
||||||
public WindowManager windows() { return windowManager; }
|
public WindowManager windows() { return windowManager; }
|
||||||
public EventBus events() { return eventBus; }
|
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