6 Commits

Author SHA1 Message Date
rattatwinko 91812a3105 fix: Wavy not being able to play FLAC, due too jflac
fix-details: Now using FFMpeg for anything audio related
dependency-add: ffmpeg (bytedeco) JavaCV (for ffmpeg; also by bytedeco)
soon-to-deprecate/remove: the old jflac and mp3spi dependencies, which are not needed anymore
add: a higher resolution waterfall diagram
TODO: Ship builds for Linux

notes: The Wavy Player is now much more stable. It still relies on the java sound API, but much less in terms of decoding any codecs. Which is now handled by FFMpeg instead of our own. Playback is still done through the java sound API tho!

---
7\ - rattatwinko 31/05/2026@19:11
2026-05-31 19:11:51 +02:00
rattatwinko 372d2f450a add : Wavy the Music player!
dependency-add: mp3spi, jflac (WAVE can be played directly by the JVM)
fix : the "This will end all running Tasks!" in Exit.java, cause we have "sessions" now

TODO: Implement EQ Preset loading from JSON for Wavy ; Implement playback of more codecs. Maybe Use FFMpeg.

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