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
This commit is contained in:
2026-05-30 23:10:35 +02:00
parent f160b07bf4
commit 372d2f450a
15 changed files with 1747 additions and 6 deletions
+10 -1
View File
@@ -120,7 +120,16 @@
<artifactId>languagesupport</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>mp3spi</artifactId>
<version>1.9.5.4</version>
</dependency>
<dependency>
<groupId>org.jflac</groupId>
<artifactId>jflac-codec</artifactId>
<version>1.5.2</version>
</dependency>
</dependencies>
</project>
+7 -1
View File
@@ -1,9 +1,15 @@
package com.rdesk;
import com.rdesk.applications.system.login.Login;
import com.rdesk.kernel.BootProc;
public class Main {
public static void main(String[] args) {
Login.main(args);
boolean dbg = true;
if (!dbg) {
Login.main(args);
} else {
BootProc.boot();
}
}
}
@@ -0,0 +1,375 @@
package com.rdesk.applications.entertainment.media;
import javax.sound.sampled.*;
import java.io.*;
import java.util.concurrent.*;
import java.util.function.*;
public class AudioEngine {
public enum State { STOPPED, PLAYING, PAUSED }
private volatile State state = State.STOPPED;
private volatile long positionFrames = 0;
private volatile long totalFrames = 0;
private AudioFormat currentFormat;
private volatile float volume = 0.8f; // 0..1
// eq
private static final int EQ_BANDS = 10;
private static final float[] EQ_FREQS = { 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 };
private final float[] eqGainDb = new float[EQ_BANDS];
private final float[][] bqCoeff = new float[EQ_BANDS][5];
private final float[][][] bqState = new float[EQ_BANDS][2][2];
// fft
private static final int FFT_SIZE = 4096;
private Consumer<float[]> fftListener;
private final float[] fftBuffer = new float[FFT_SIZE];
private int fftPos = 0;
private ExecutorService playerThread;
private volatile boolean stopRequested = false;
private volatile boolean pauseRequested = false;
private Runnable onTrackEnd;
private Consumer<Long> onPositionUpdate;
private SourceDataLine dataLine;
private Track currentTrack = null;
private double requestedSeekFraction = 0.0;
private boolean seekPending = false;
// Public API
public AudioEngine() { initEq(); }
public void setOnTrackEnd(Runnable r) { this.onTrackEnd = r; }
public void setOnPositionUpdate(Consumer<Long> c) { this.onPositionUpdate = c; }
public void setFftListener(Consumer<float[]> c) { this.fftListener = c; }
public State getState() { return state; }
public long getPositionFrames() { return positionFrames; }
public long getTotalFrames() { return totalFrames; }
public AudioFormat getFormat() { return currentFormat; }
public void setVolume(float v) {
volume = Math.max(0f, Math.min(1f, v));
applyVolumeToLine();
}
public void play(Track track) {
stop();
playerThread = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "wavy-audio");
t.setDaemon(true);
t.setPriority(Thread.MAX_PRIORITY - 1);
return t;
});
stopRequested = false;
pauseRequested = false;
playerThread.submit(() -> playbackLoop(track));
}
public void togglePause() {
if (state == State.PLAYING) {
pauseRequested = true;
state = State.PAUSED;
} else if (state == State.PAUSED) {
pauseRequested = false;
state = State.PLAYING;
synchronized (this) { notifyAll(); }
}
}
public void stop() {
stopRequested = true;
pauseRequested = false;
synchronized (this) { notifyAll(); }
if (playerThread != null) {
playerThread.shutdownNow();
try { playerThread.awaitTermination(2, TimeUnit.SECONDS); }
catch (InterruptedException ignored) {}
playerThread = null;
}
closeLine();
state = State.STOPPED;
positionFrames = 0;
}
public void seekFraction(double fraction) {
if (currentTrack == null) return;
requestedSeekFraction = Math.max(0.0, Math.min(1.0, fraction));
seekPending = true;
}
public void setEqBand(int band, float db) {
if (band < 0 || band >= EQ_BANDS) return;
eqGainDb[band] = db;
recomputeBiquad(band);
}
public float getEqBand(int band) { return eqGainDb[band]; }
public float[] getEqFrequencies() { return EQ_FREQS.clone(); }
public static float[] getStaticEqFrequencies() { return EQ_FREQS.clone(); }
public void shutdown() { stop(); }
private void playbackLoop(Track track) {
currentTrack = track;
state = State.PLAYING;
try {
AudioInputStream rawStream = AudioSystem.getAudioInputStream(track.getFilePath().toFile());
AudioFormat rawFmt = rawStream.getFormat();
AudioFormat pcmFmt = new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED,
rawFmt.getSampleRate(),
16,
rawFmt.getChannels(),
rawFmt.getChannels() * 2,
rawFmt.getSampleRate(),
false);
AudioInputStream pcmStream;
if (rawFmt.getEncoding() != AudioFormat.Encoding.PCM_SIGNED
|| rawFmt.getSampleSizeInBits() != 16) {
pcmStream = AudioSystem.getAudioInputStream(pcmFmt, rawStream);
} else {
pcmStream = rawStream;
pcmFmt = rawFmt;
}
currentFormat = pcmFmt;
int frameSize = pcmFmt.getFrameSize();
float sampleRate = pcmFmt.getSampleRate();
int channels = pcmFmt.getChannels();
long pcmFrames = pcmStream.getFrameLength();
if (pcmFrames > 0 && pcmFrames != AudioSystem.NOT_SPECIFIED) {
totalFrames = pcmFrames;
if (track.getDurationSeconds() == 0)
track.setDurationSeconds((long)(pcmFrames / sampleRate));
} else if (track.getDurationSeconds() > 0) {
totalFrames = (long)(track.getDurationSeconds() * sampleRate);
} else {
totalFrames = 0;
}
DataLine.Info lineInfo = new DataLine.Info(SourceDataLine.class, pcmFmt);
dataLine = (SourceDataLine) AudioSystem.getLine(lineInfo);
dataLine.open(pcmFmt, (int)(sampleRate * frameSize * 0.1f));
dataLine.start();
applyVolumeToLine(); // set initial volume
resetBiquadState();
final int BLOCK = 4096;
byte[] rawBuf = new byte[BLOCK];
float[] samples = new float[BLOCK / 2];
positionFrames = 0;
while (!stopRequested) {
// Pause
synchronized (this) {
while (pauseRequested && !stopRequested) {
try { wait(50); } catch (InterruptedException e) { break; }
}
}
if (stopRequested) break;
// Seek
if (seekPending) {
seekPending = false;
pcmStream.close();
rawStream.close();
rawStream = AudioSystem.getAudioInputStream(track.getFilePath().toFile());
if (rawFmt.getEncoding() != AudioFormat.Encoding.PCM_SIGNED
|| rawFmt.getSampleSizeInBits() != 16) {
pcmStream = AudioSystem.getAudioInputStream(pcmFmt, rawStream);
} else {
pcmStream = rawStream;
}
if (totalFrames > 0) {
long targetFrame = (long)(requestedSeekFraction * totalFrames);
long skip = targetFrame * frameSize;
byte[] skipBuf = new byte[8192];
long skipped = 0;
while (skipped < skip && !stopRequested) {
int toSkip = (int) Math.min(skipBuf.length, skip - skipped);
int r = pcmStream.read(skipBuf, 0, toSkip);
if (r < 0) break;
skipped += r;
}
positionFrames = targetFrame;
}
resetBiquadState();
continue;
}
int bytesRead = pcmStream.read(rawBuf);
if (bytesRead <= 0) break;
// bytes to float
int sampleCount = bytesRead / 2;
for (int i = 0; i < sampleCount; i++) {
int lo = rawBuf[i*2] & 0xFF;
int hi = rawBuf[i*2+1];
samples[i] = (hi << 8 | lo) / 32768.0f;
}
applyEq(samples, sampleCount, channels);
feedFft(samples, sampleCount, channels);
// other way around, float to bytes
for (int i = 0; i < sampleCount; i++) {
float s = Math.max(-1f, Math.min(1f, samples[i]));
short sh = (short)(s * 32767f);
rawBuf[i*2] = (byte)(sh & 0xFF);
rawBuf[i*2+1] = (byte)((sh >> 8) & 0xFF);
}
dataLine.write(rawBuf, 0, bytesRead);
positionFrames += bytesRead / frameSize;
if (onPositionUpdate != null) onPositionUpdate.accept(positionFrames);
}
pcmStream.close();
rawStream.close();
if (!stopRequested) dataLine.drain();
closeLine();
state = State.STOPPED;
if (!stopRequested && onTrackEnd != null) onTrackEnd.run();
} catch (UnsupportedAudioFileException e) {
System.err.println("[Wavy] Unsupported format: " + track.getFilePath());
state = State.STOPPED;
} catch (LineUnavailableException | IOException e) {
e.printStackTrace();
state = State.STOPPED;
}
}
private void closeLine() {
if (dataLine != null) {
if (dataLine.isOpen()) { dataLine.stop(); dataLine.close(); }
dataLine = null;
}
}
private void applyVolumeToLine() {
if (dataLine == null || !dataLine.isOpen()) return;
if (dataLine.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
FloatControl gc = (FloatControl) dataLine.getControl(FloatControl.Type.MASTER_GAIN);
// map 0..1 linearly to the controls min..max db range
float minDb = gc.getMinimum();
float maxDb = 0f; // 0 dB is the unity gain
float db = (volume == 0f) ? minDb : minDb + (maxDb - minDb) * volume;
gc.setValue(Math.max(minDb, Math.min(maxDb, db)));
}
}
// EQ
private void initEq() {
for (int b = 0; b < EQ_BANDS; b++) { eqGainDb[b] = 0f; recomputeBiquad(b); }
}
// math heavy shit, this code may burn your eyes out.
// it works, but i dont like how it works
private void recomputeBiquad(int band) {
float sampleRate = (currentFormat != null) ? currentFormat.getSampleRate() : 44100f;
float f0 = EQ_FREQS[band];
float dBgain= eqGainDb[band];
float Q = 1.41421356f;
float A = (float) Math.pow(10.0, dBgain / 40.0);
double w0 = 2.0 * Math.PI * f0 / sampleRate;
double cosW = Math.cos(w0);
double sinW = Math.sin(w0);
double alpha= sinW / (2.0 * Q);
double b0 = 1 + alpha*A, b1 = -2*cosW, b2 = 1 - alpha*A;
double a0 = 1 + alpha/A, a1 = -2*cosW, a2 = 1 - alpha/A;
bqCoeff[band][0] = (float)(b0/a0); bqCoeff[band][1] = (float)(b1/a0);
bqCoeff[band][2] = (float)(b2/a0); bqCoeff[band][3] = (float)(a1/a0);
bqCoeff[band][4] = (float)(a2/a0);
}
private void applyEq(float[] samples, int count, int channels) {
for (int band = 0; band < EQ_BANDS; band++) {
if (Math.abs(eqGainDb[band]) < 0.01f) continue;
float b0=bqCoeff[band][0], b1=bqCoeff[band][1], b2=bqCoeff[band][2];
float a1=bqCoeff[band][3], a2=bqCoeff[band][4];
for (int i = 0; i < count; i++) {
int ch = i % channels;
float x = samples[i];
float z1 = bqState[band][ch][0], z2 = bqState[band][ch][1];
float y = b0*x + z1;
bqState[band][ch][0] = b1*x - a1*y + z2;
bqState[band][ch][1] = b2*x - a2*y;
samples[i] = y;
}
}
}
private void resetBiquadState() {
for (float[][] b : bqState) for (float[] c : b) { c[0]=0; c[1]=0; }
}
// fast fourier transform ( algo: cooley turkey, with the radix 2 dit case )
// links / references which may be helpful while reading through the code:
// https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm
// https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm#The_radix-2_DIT_case
private void feedFft(float[] samples, int count, int channels) {
if (fftListener == null) return;
for (int i = 0; i < count; i += channels) {
float mono = 0;
for (int c = 0; c < channels; c++) mono += samples[i+c];
mono /= channels;
fftBuffer[fftPos++] = mono;
if (fftPos == FFT_SIZE) {
fftListener.accept(computeFFT(fftBuffer));
System.arraycopy(fftBuffer, FFT_SIZE/2, fftBuffer, 0, FFT_SIZE/2);
fftPos = FFT_SIZE/2;
}
}
}
static float[] computeFFT(float[] x) {
int n = x.length;
float[] re = x.clone(), im = new float[n];
// hann window
for (int i = 0; i < n; i++)
re[i] *= 0.5f * (1 - (float)Math.cos(2*Math.PI*i/(n-1)));
// bit reversal
int bits = (int)(Math.log(n)/Math.log(2));
for (int j=1, i=0; j<n; j++) {
int bit = n>>1;
for (; (i&bit)!=0; bit>>=1) i^=bit;
i^=bit;
if (j<i) {
float t=re[j]; re[j]=re[i]; re[i]=t;
t=im[j]; im[j]=im[i]; im[i]=t;
}
}
// cooley turkey algorithm
for (int len=2; len<=n; len<<=1) {
double angle=-2*Math.PI/len;
float wRe=(float)Math.cos(angle), wIm=(float)Math.sin(angle);
for (int i=0; i<n; i+=len) {
float cRe=1, cIm=0;
for (int j=0; j<len/2; j++) {
float uRe=re[i+j], uIm=im[i+j];
float vRe=re[i+j+len/2]*cRe - im[i+j+len/2]*cIm;
float vIm=re[i+j+len/2]*cIm + im[i+j+len/2]*cRe;
re[i+j]=uRe+vRe; im[i+j]=uIm+vIm;
re[i+j+len/2]=uRe-vRe; im[i+j+len/2]=uIm-vIm;
float nRe=cRe*wRe-cIm*wIm; cIm=cRe*wIm+cIm*wRe; cRe=nRe;
}
}
}
float[] mag = new float[n/2];
for (int i=0; i<n/2; i++)
mag[i] = (float)Math.sqrt(re[i]*re[i]+im[i]*im[i])/n;
return mag;
}
}
@@ -0,0 +1,54 @@
package com.rdesk.applications.entertainment.media;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
public class CoverArtPanel extends JPanel {
private BufferedImage coverImage = null;
private String trackTitle = "No track";
private String artistName = "";
public CoverArtPanel() {
setBorder(BorderFactory.createLoweredBevelBorder());
setPreferredSize(new Dimension(130, 130));
}
public void setTrack(Track track) {
coverImage = track != null ? track.getCoverArt() : null;
trackTitle = track != null ? track.getDisplayTitle() : "No track";
artistName = track != null ? track.getArtist() : "";
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
int w = getWidth(), h = getHeight();
if (coverImage != null) {
Graphics2D g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
double sx = (double) w / coverImage.getWidth();
double sy = (double) h / coverImage.getHeight();
double scale = Math.max(sx, sy);
int dw = (int)(coverImage.getWidth() * scale);
int dh = (int)(coverImage.getHeight() * scale);
int dx = (w - dw) / 2, dy = (h - dh) / 2;
g2.drawImage(coverImage, dx, dy, dw, dh, null);
} else {
g.setColor(UIManager.getColor("Panel.background"));
g.fillRect(0, 0, w, h);
g.setColor(UIManager.getColor("Label.foreground"));
g.setFont(g.getFont().deriveFont(Font.BOLD, 10f));
FontMetrics fm = g.getFontMetrics();
String line1 = trackTitle.length() > 20 ? trackTitle.substring(0,18)+"" : trackTitle;
String line2 = artistName.length() > 20 ? artistName.substring(0,18)+"" : artistName;
g.drawString(line1, (w - fm.stringWidth(line1))/2, h/2 - fm.getHeight()/2 + fm.getAscent());
g.setFont(g.getFont().deriveFont(Font.PLAIN, 9f));
fm = g.getFontMetrics();
g.drawString(line2, (w - fm.stringWidth(line2))/2, h/2 + fm.getAscent() + 2);
}
}
}
@@ -0,0 +1,170 @@
package com.rdesk.applications.entertainment.media;
import javax.swing.*;
import java.awt.*;
public class EqualizerPanel extends JPanel {
private static final int BANDS = 10;
private static final String[] FREQ_LABELS = {
"32Hz","64Hz","125Hz","250Hz","500Hz","1kHz","2kHz","4kHz","8kHz","16kHz"
};
// TODO: load from JSON in rsys directory (~/rsys/wavy_dat/eq/presets.json)
private static final String[][] PRESETS = {
{"Flat", "0,0,0,0,0,0,0,0,0,0"},
{"Bass Boost", "8,6,4,2,0,0,0,0,0,0"},
{"Treble Boost", "0,0,0,0,0,0,2,4,6,8"},
{"V-Shape", "6,4,2,-1,-3,-3,-1,2,4,6"},
{"Rock", "4,3,1,0,-1,0,2,3,3,2"},
{"Jazz", "3,2,1,2,0,-1,-1,0,1,2"},
{"Classical", "0,0,0,0,0,0,-2,-3,-3,-2"},
{"Electronic", "5,3,0,-1,-3,0,1,2,4,5"},
};
private final AudioEngine engine;
private final JSlider[] sliders = new JSlider[BANDS];
private final JLabel[] dbLabels = new JLabel[BANDS];
private final FreqCurvePanel curve;
public EqualizerPanel(AudioEngine engine) {
this.engine = engine;
setLayout(new BorderLayout(4, 4));
setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
curve = new FreqCurvePanel();
curve.setPreferredSize(new Dimension(0, 80));
add(buildTopBar(), BorderLayout.NORTH);
add(curve, BorderLayout.CENTER);
add(buildSliders(), BorderLayout.SOUTH);
}
private JPanel buildTopBar() {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
p.add(new JLabel("Preset:"));
JComboBox<String> presetBox = new JComboBox<>();
for (String[] pr : PRESETS) presetBox.addItem(pr[0]);
presetBox.addActionListener(e -> applyPreset(presetBox.getSelectedIndex()));
p.add(presetBox);
JButton reset = new JButton("Reset");
reset.addActionListener(e -> applyPreset(0));
p.add(reset);
return p;
}
private JPanel buildSliders() {
JPanel p = new JPanel(new GridLayout(1, BANDS, 2, 0));
for (int i = 0; i < BANDS; i++) {
final int band = i;
JPanel col = new JPanel();
col.setLayout(new BoxLayout(col, BoxLayout.Y_AXIS));
JLabel dbLbl = new JLabel("0 dB", SwingConstants.CENTER);
dbLbl.setFont(dbLbl.getFont().deriveFont(9f));
dbLbl.setAlignmentX(0.5f);
dbLabels[i] = dbLbl;
JSlider sl = new JSlider(JSlider.VERTICAL, -120, 120, 0);
sl.setPaintTicks(true);
sl.setMajorTickSpacing(60);
sl.setMinorTickSpacing(20);
sl.setSnapToTicks(false);
sl.setAlignmentX(0.5f);
sliders[i] = sl;
sl.addChangeListener(e -> {
float db = sl.getValue() / 10.0f;
engine.setEqBand(band, db);
dbLabels[band].setText(String.format("%+.0f dB", db));
curve.repaint();
});
JLabel freqLbl = new JLabel(FREQ_LABELS[i], SwingConstants.CENTER);
freqLbl.setFont(freqLbl.getFont().deriveFont(8f));
freqLbl.setAlignmentX(0.5f);
col.add(dbLbl);
col.add(sl);
col.add(freqLbl);
p.add(col);
}
return p;
}
private void applyPreset(int idx) {
if (idx < 0 || idx >= PRESETS.length) return;
String[] vals = PRESETS[idx][1].split(",");
for (int i = 0; i < BANDS && i < vals.length; i++) {
try {
float db = Float.parseFloat(vals[i].trim());
sliders[i].setValue(Math.round(db * 10));
} catch (NumberFormatException ignored) {}
}
}
private class FreqCurvePanel extends JPanel {
FreqCurvePanel() {
setBorder(BorderFactory.createTitledBorder("Frequency Response"));
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
int w = getWidth(), h = getHeight();
Insets ins = getInsets();
int x0 = ins.left + 2, y0 = ins.top + 2;
int cw = w - ins.left - ins.right - 4;
int ch = h - ins.top - ins.bottom - 4;
if (cw <= 0 || ch <= 0) { g2.dispose(); return; }
float midY = y0 + ch / 2.0f;
g2.setColor(Color.LIGHT_GRAY);
g2.drawLine(x0, (int)midY, x0 + cw, (int)midY);
float logMin = (float)Math.log10(20.0);
float logMax = (float)Math.log10(20000.0);
int pts = cw;
int[] xp = new int[pts], yp = new int[pts];
for (int i = 0; i < pts; i++) {
float f = (float)Math.pow(10, logMin + (logMax - logMin) * i / pts);
double totalDb = 0;
for (int b = 0; b < BANDS; b++) {
float db = sliders[b].getValue() / 10.0f;
if (Math.abs(db) < 0.01f) continue;
float f0 = AudioEngine.getStaticEqFrequencies()[b];
float Q = 1.41421356f;
double ratio = f / f0;
double lrSq = (ratio - 1.0/ratio) * (ratio - 1.0/ratio);
double denom = 1 + lrSq / (Q * Q * (ratio + 1.0/ratio) * (ratio + 1.0/ratio));
totalDb += db * denom;
}
float py = midY - (float)(totalDb / 12.0 * ch / 2 * 0.9);
xp[i] = x0 + i;
yp[i] = Math.max(y0, Math.min(y0 + ch, (int)py));
}
g2.setColor(getForeground());
g2.setStroke(new BasicStroke(1.5f));
for (int i = 1; i < pts; i++) {
g2.drawLine(xp[i-1], yp[i-1], xp[i], yp[i]);
}
// Band markers ; i like blue (like Boris Johnson)!
g2.setColor(Color.BLUE);
for (int b = 0; b < BANDS; b++) {
float f0 = AudioEngine.getStaticEqFrequencies()[b];
float db = sliders[b].getValue() / 10.0f;
float nx = x0 + (float)((Math.log10(f0) - logMin) / (logMax - logMin) * cw);
float ny = midY - (db / 12.0f * ch / 2 * 0.9f);
ny = Math.max(y0, Math.min(y0 + ch, ny));
g2.fillOval((int)nx - 3, (int)ny - 3, 6, 6);
}
g2.dispose();
}
}
}
@@ -0,0 +1,296 @@
package com.rdesk.applications.entertainment.media;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
public class MetadataReader {
public static void populate(Track track) {
String fmt = track.getFormat();
try {
switch (fmt) {
case "MP3" -> readID3v2(track);
case "FLAC" -> readFLAC(track);
case "WAV" -> readWAV(track);
}
} catch (Exception ignored) {/* do jackshit */}
}
// MP3 / ID3v2
private static void readID3v2(Track track) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(track.getFilePath().toFile(), "r")) {
byte[] header = new byte[10];
if (raf.read(header) < 10) return;
if (header[0] != 'I' || header[1] != 'D' || header[2] != '3') return;
int majorVer = header[3] & 0xFF;
if (majorVer < 3) return;
// syncsafe size
long tagSize = ((header[6] & 0x7F) << 21)
| ((header[7] & 0x7F) << 14)
| ((header[8] & 0x7F) << 7)
| (header[9] & 0x7F);
byte[] tagData = new byte[(int) tagSize];
raf.readFully(tagData);
int pos = 0;
while (pos + 10 <= tagData.length) {
String frameId = new String(tagData, pos, 4, StandardCharsets.ISO_8859_1);
if (frameId.charAt(0) == '\0') break; // padding
int frameSize;
if (majorVer == 4) {
frameSize = ((tagData[pos+4] & 0x7F) << 21)
| ((tagData[pos+5] & 0x7F) << 14)
| ((tagData[pos+6] & 0x7F) << 7)
| (tagData[pos+7] & 0x7F);
} else {
frameSize = ((tagData[pos+4] & 0xFF) << 24)
| ((tagData[pos+5] & 0xFF) << 16)
| ((tagData[pos+6] & 0xFF) << 8)
| (tagData[pos+7] & 0xFF);
}
pos += 10; // skip frame header
if (frameSize <= 0 || pos + frameSize > tagData.length) break;
byte[] frameData = new byte[frameSize];
System.arraycopy(tagData, pos, frameData, 0, frameSize);
pos += frameSize;
switch (frameId) {
case "TIT2" -> track.setTitle(decodeTextFrame(frameData));
case "TPE1" -> track.setArtist(decodeTextFrame(frameData));
case "TALB" -> track.setAlbum(decodeTextFrame(frameData));
case "TDRC", "TYER" -> {
String yr = decodeTextFrame(frameData);
if (yr.length() >= 4) {
try { track.setYear(Integer.parseInt(yr.substring(0, 4))); } catch (NumberFormatException ignored) {}
}
}
case "TRCK" -> {
String trck = decodeTextFrame(frameData);
int slash = trck.indexOf('/');
try { track.setTrackNumber(Integer.parseInt(slash >= 0 ? trck.substring(0, slash) : trck)); }
catch (NumberFormatException ignored) {}
}
case "APIC" -> track.setCoverArt(decodeAPIC(frameData));
}
}
}
}
private static String decodeTextFrame(byte[] data) {
if (data.length == 0) return "";
int enc = data[0] & 0xFF;
int start = 1;
// Strip BOM for UTF-16
// vv terrible code vv
return switch (enc) {
case 0 -> new String(data, start, data.length - start, StandardCharsets.ISO_8859_1).trim();
case 1 -> {
// UTF-16 with BOM
if (data.length - start >= 2 && data[start] == (byte)0xFF && data[start+1] == (byte)0xFE)
yield new String(data, start+2, data.length - start - 2, StandardCharsets.UTF_16LE).trim();
if (data.length - start >= 2 && data[start] == (byte)0xFE && data[start+1] == (byte)0xFF)
yield new String(data, start+2, data.length - start - 2, StandardCharsets.UTF_16BE).trim();
yield new String(data, start, data.length - start, StandardCharsets.UTF_16).trim();
}
case 2 -> new String(data, start, data.length - start, StandardCharsets.UTF_16BE).trim();
case 3 -> new String(data, start, data.length - start, StandardCharsets.UTF_8).trim();
default -> new String(data, start, data.length - start, StandardCharsets.ISO_8859_1).trim();
};
}
private static BufferedImage decodeAPIC(byte[] data) {
if (data.length < 4) return null;
int enc = data[0] & 0xFF;
int pos = 1;
while (pos < data.length && data[pos] != 0) pos++;
pos++;
if (pos >= data.length) return null;
pos++;
if (enc == 1 || enc == 2) {
while (pos + 1 < data.length && !(data[pos] == 0 && data[pos+1] == 0)) pos++;
pos += 2;
} else {
while (pos < data.length && data[pos] != 0) pos++;
pos++;
}
if (pos >= data.length) return null;
try {
return ImageIO.read(new ByteArrayInputStream(data, pos, data.length - pos));
} catch (IOException e) { return null; }
}
// FLAC
private static void readFLAC(Track track) throws IOException {
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(track.getFilePath().toFile())))) {
byte[] magic = new byte[4];
dis.readFully(magic);
if (magic[0] != 'f' || magic[1] != 'L' || magic[2] != 'a' || magic[3] != 'C') return;
boolean last = false;
while (!last) {
int blockHeader = dis.readUnsignedByte();
last = (blockHeader & 0x80) != 0;
int blockType = blockHeader & 0x7F;
int blockLen = (dis.readUnsignedByte() << 16)
| (dis.readUnsignedByte() << 8)
| dis.readUnsignedByte();
byte[] blockData = new byte[blockLen];
dis.readFully(blockData);
if (blockType == 4) { // VORBIS_COMMENT
parseVorbisComment(track, blockData);
} else if (blockType == 6) { // PICTURE
BufferedImage img = parseFLACPicture(blockData);
if (img != null) track.setCoverArt(img);
} else if (blockType == 0) { // STREAMINFO ; duration
if (blockLen >= 18) {
long totalSamples = ((long)(blockData[13] & 0x0F) << 32)
| (((long)(blockData[14] & 0xFF)) << 24)
| (((long)(blockData[15] & 0xFF)) << 16)
| (((long)(blockData[16] & 0xFF)) << 8)
| ((long)(blockData[17] & 0xFF));
int sampleRate = ((blockData[10] & 0xFF) << 12)
| ((blockData[11] & 0xFF) << 4)
| ((blockData[12] & 0xFF) >> 4);
if (sampleRate > 0)
track.setDurationSeconds(totalSamples / sampleRate);
}
}
}
}
}
private static void parseVorbisComment(Track track, byte[] data) {
try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data))) {
int vendorLen = readLE32(dis);
dis.skipBytes(vendorLen);
int count = readLE32(dis);
for (int i = 0; i < count; i++) {
int len = readLE32(dis);
byte[] cb = new byte[len];
dis.readFully(cb);
String comment = new String(cb, StandardCharsets.UTF_8);
int eq = comment.indexOf('=');
if (eq < 0) continue;
String k = comment.substring(0, eq).toUpperCase();
String v = comment.substring(eq + 1);
switch (k) {
case "TITLE" -> track.setTitle(v);
case "ARTIST" -> track.setArtist(v);
case "ALBUM" -> track.setAlbum(v);
case "DATE" -> { try { track.setYear(Integer.parseInt(v.substring(0, Math.min(4, v.length())))); } catch (NumberFormatException ignored) {} }
case "TRACKNUMBER" -> { try { track.setTrackNumber(Integer.parseInt(v)); } catch (NumberFormatException ignored) {} }
}
}
} catch (IOException ignored) {}
}
private static BufferedImage parseFLACPicture(byte[] data) {
try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(data))) {
int pictureType = dis.readInt();
int mimeLen = dis.readInt();
dis.skipBytes(mimeLen);
int descLen = dis.readInt();
dis.skipBytes(descLen);
dis.skipBytes(16); // width, height, depth, index
int picLen = dis.readInt();
byte[] picData = new byte[picLen];
dis.readFully(picData);
return ImageIO.read(new ByteArrayInputStream(picData));
} catch (IOException e) { return null; }
}
// WAV
private static void readWAV(Track track) throws IOException {
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(track.getFilePath().toFile())))) {
byte[] riff = new byte[4];
dis.readFully(riff);
if (!new String(riff, StandardCharsets.US_ASCII).equals("RIFF")) return;
readLE32(dis); // total size
byte[] wave = new byte[4];
dis.readFully(wave);
if (!new String(wave, StandardCharsets.US_ASCII).equals("WAVE")) return;
int sampleRate = 0;
long dataSize = 0;
int blockAlign = 0;
while (dis.available() >= 8) {
byte[] chunkId = new byte[4];
dis.readFully(chunkId);
long chunkSize = Integer.toUnsignedLong(readLE32(dis));
String id = new String(chunkId, StandardCharsets.US_ASCII);
if (id.equals("fmt ")) {
dis.skipBytes(2); // audio format
dis.skipBytes(2); // channels
sampleRate = readLE32(dis);
dis.skipBytes(4); // byte rate
blockAlign = dis.readShort();
dis.skipBytes(2); // bits per sample
long skip = chunkSize - 16;
if (skip > 0) dis.skipBytes((int) skip);
} else if (id.equals("data")) {
dataSize = chunkSize;
dis.skipBytes((int) Math.min(chunkSize, Integer.MAX_VALUE));
} else if (id.equals("LIST")) {
byte[] listType = new byte[4];
dis.readFully(listType);
String lt = new String(listType, StandardCharsets.US_ASCII);
if (lt.equals("INFO")) {
long remaining = chunkSize - 4;
while (remaining >= 8) {
byte[] subId = new byte[4];
dis.readFully(subId);
int subLen = readLE32(dis);
remaining -= 8;
byte[] subData = new byte[subLen];
dis.readFully(subData);
remaining -= subLen;
if (subLen % 2 != 0 && remaining > 0) { dis.skipBytes(1); remaining--; }
String subKey = new String(subId, StandardCharsets.US_ASCII);
String val = new String(subData, StandardCharsets.ISO_8859_1).replace("\0", "").trim();
switch (subKey) {
case "INAM" -> track.setTitle(val);
case "IART" -> track.setArtist(val);
case "IPRD" -> track.setAlbum(val);
case "ICRD" -> { try { track.setYear(Integer.parseInt(val.substring(0, Math.min(4, val.length())))); } catch (NumberFormatException ignored) {} }
case "ITRK" -> { try { track.setTrackNumber(Integer.parseInt(val)); } catch (NumberFormatException ignored) {} }
}
}
} else {
long skip = chunkSize - 4;
if (skip > 0) dis.skipBytes((int) skip);
}
} else {
if (chunkSize > 0) dis.skipBytes((int) Math.min(chunkSize, Integer.MAX_VALUE));
}
}
if (sampleRate > 0 && blockAlign > 0 && dataSize > 0) {
track.setDurationSeconds(dataSize / (long)((long) sampleRate * blockAlign));
}
}
}
private static int readLE32(DataInputStream dis) throws IOException {
int b0 = dis.readUnsignedByte();
int b1 = dis.readUnsignedByte();
int b2 = dis.readUnsignedByte();
int b3 = dis.readUnsignedByte();
return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
}
}
@@ -0,0 +1,92 @@
package com.rdesk.applications.entertainment.media;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
public class MusicLibrary {
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.of(".mp3", ".flac", ".wav");
private final List<Track> tracks = new ArrayList<>();
private Path rootPath;
private ExecutorService scanExecutor;
public MusicLibrary(Path rootPath) {
this.rootPath = rootPath;
}
public Path getRootPath() { return rootPath; }
public void setRootPath(Path rootPath) {
this.rootPath = rootPath;
}
public List<Track> getTracks() {
return Collections.unmodifiableList(tracks);
}
public void scan(Consumer<Track> onProgress, Runnable onComplete) {
if (scanExecutor != null && !scanExecutor.isShutdown()) {
scanExecutor.shutdownNow();
}
scanExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "wavy-library-scan");
t.setDaemon(true);
return t;
});
tracks.clear();
scanExecutor.submit(() -> {
try {
if (!Files.isDirectory(rootPath)) {
onComplete.run();
return;
}
Files.walkFileTree(rootPath, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
String name = file.getFileName().toString().toLowerCase();
boolean supported = SUPPORTED_EXTENSIONS.stream().anyMatch(name::endsWith);
if (supported) {
Track track = new Track(file);
MetadataReader.populate(track);
synchronized (tracks) {
tracks.add(track);
}
onProgress.accept(track);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
return FileVisitResult.CONTINUE; // skip unreadable files
}
});
} catch (IOException e) {
e.printStackTrace();
} finally {
onComplete.run();
}
});
}
public void sortByArtistTitle() {
synchronized (tracks) {
tracks.sort(Comparator
.comparing(Track::getArtist, String.CASE_INSENSITIVE_ORDER)
.thenComparing(Track::getTitle, String.CASE_INSENSITIVE_ORDER));
}
}
public void shutdown() {
if (scanExecutor != null) scanExecutor.shutdownNow();
}
}
@@ -0,0 +1,67 @@
package com.rdesk.applications.entertainment.media;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
public class SourceConfig {
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), "rsys");
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("wavy_sources.json");
public static Path loadMusicRoot() {
if (Files.exists(CONFIG_FILE)) {
try {
String json = Files.readString(CONFIG_FILE, StandardCharsets.UTF_8);
String path = extractJsonString(json, "musicRoot");
if (path != null && !path.isBlank()) {
Path p = Path.of(path);
if (Files.isDirectory(p)) return p;
}
} catch (IOException ignored) {}
}
return defaultMusicRoot();
}
public static void saveMusicRoot(Path root) throws IOException {
Files.createDirectories(CONFIG_DIR);
String json = "{\n \"musicRoot\": \"" + escape(root.toString()) + "\"\n}\n";
Files.writeString(CONFIG_FILE, json, StandardCharsets.UTF_8);
}
private static Path defaultMusicRoot() {
String os = System.getProperty("os.name", "").toLowerCase();
if (os.contains("win")) {
String home = System.getProperty("user.home");
return Path.of(home, "Music");
}
return Path.of(System.getProperty("user.home"), "Music");
}
private static String extractJsonString(String json, String key) {
String needle = "\"" + key + "\"";
int ki = json.indexOf(needle);
if (ki < 0) return null;
int colon = json.indexOf(':', ki + needle.length());
if (colon < 0) return null;
int open = json.indexOf('"', colon + 1);
if (open < 0) return null;
int close = open + 1;
while (close < json.length()) {
char c = json.charAt(close);
if (c == '\\') { close += 2; continue; }
if (c == '"') break;
close++;
}
if (close >= json.length()) return null;
return unescape(json.substring(open + 1, close));
}
private static String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String unescape(String s) {
return s.replace("\\\\", "\\").replace("\\\"", "\"").replace("\\/", "/");
}
}
@@ -0,0 +1,206 @@
package com.rdesk.applications.entertainment.media;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.concurrent.ArrayBlockingQueue;
public class SpectrogramPanel extends JPanel {
private static final int BAR_COUNT = 96;
private static final Color[] HEAT = buildHeatMap();
private BufferedImage waterfallImg = null;
private Graphics2D waterfallG = null;
private float[] currentMag = new float[8192];
private float[] barSmoothed = new float[BAR_COUNT];
private float[] barPeak = new float[BAR_COUNT];
private int[] peakTimer = new int[BAR_COUNT];
private final ArrayBlockingQueue<float[]> fftQueue = new ArrayBlockingQueue<>(16);
private final Timer renderTimer;
public SpectrogramPanel() {
setBorder(BorderFactory.createTitledBorder("Spectrogram"));
setPreferredSize(new Dimension(400, 220));
setMinimumSize(new Dimension(100, 80));
renderTimer = new Timer(16, e -> {
float[] mag;
boolean updated = false;
while ((mag = fftQueue.poll()) != null) {
currentMag = mag;
scrollWaterfall();
updated = true;
}
if (updated) repaint();
});
renderTimer.start();
}
public void receiveFft(float[] magnitudes) {
fftQueue.offer(magnitudes.clone());
}
public void reset() {
currentMag = new float[2048];
java.util.Arrays.fill(barSmoothed, 0f);
java.util.Arrays.fill(barPeak, 0f);
// Clear waterfall image
if (waterfallG != null) {
waterfallG.setBackground(getBackground());
Insets ins = getInsets();
int wh = (getHeight() - ins.top - ins.bottom) / 2;
waterfallG.clearRect(0, 0, waterfallImg.getWidth(), Math.max(1, wh));
}
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Insets ins = getInsets();
int x0 = ins.left, y0 = ins.top;
int w = getWidth() - ins.left - ins.right;
int h = getHeight() - ins.top - ins.bottom;
if (w <= 4 || h <= 4) return;
int waterfallH = h / 2;
int barH = h - waterfallH;
ensureWaterfallImage(w, waterfallH);
drawWaterfall(g, x0, y0, w, waterfallH);
drawSpectrum (g, x0, y0 + waterfallH, w, barH);
}
private void ensureWaterfallImage(int w, int h) {
if (waterfallImg == null || waterfallImg.getWidth() != w || waterfallImg.getHeight() != h) {
BufferedImage newImg = new BufferedImage(w, Math.max(1,h), BufferedImage.TYPE_INT_RGB);
Graphics2D ng = newImg.createGraphics();
Color bg = getBackground();
ng.setColor(bg != null ? bg : Color.WHITE);
ng.fillRect(0, 0, w, Math.max(1,h));
if (waterfallImg != null && waterfallG != null) {
ng.drawImage(waterfallImg, 0, 0, null);
waterfallG.dispose();
}
waterfallImg = newImg;
waterfallG = ng;
}
}
private void scrollWaterfall() {
if (waterfallImg == null) return;
int w = waterfallImg.getWidth(), h = waterfallImg.getHeight();
if (h < 2) return;
waterfallG.copyArea(0, 1, w, h - 1, 0, -1);
float[] bars = computeBars();
int cellW = Math.max(1, w / BAR_COUNT);
for (int b = 0; b < BAR_COUNT; b++) {
int ci = (int)(bars[b] * (HEAT.length - 1));
waterfallG.setColor(HEAT[ci]);
waterfallG.fillRect(b * cellW, h - 1, cellW, 1);
}
if (BAR_COUNT * cellW < w) {
waterfallG.setColor(HEAT[0]);
waterfallG.fillRect(BAR_COUNT * cellW, h - 1, w - BAR_COUNT * cellW, 1);
}
}
private void drawWaterfall(Graphics g, int x, int y, int w, int h) {
if (waterfallImg != null)
g.drawImage(waterfallImg, x, y, w, h, null);
g.setColor(Color.GRAY);
g.drawLine(x, y + h - 1, x + w, y + h - 1);
}
private void drawSpectrum(Graphics g, int x, int y, int w, int h) {
float[] bars = barSmoothed; // already updated by scrollWaterfall
int barW = Math.max(1, w / BAR_COUNT);
int gap = Math.max(1, barW / 5);
for (int i = 0; i < BAR_COUNT; i++) {
float val = bars[i];
int bx = x + i * barW;
int bh = (int)(val * (h - 12));
int ci = (int)(val * (HEAT.length - 1));
g.setColor(HEAT[ci]);
g.fillRect(bx + gap/2, y + h - 12 - bh, barW - gap, bh);
if (barPeak[i] > 0.01f) {
int ph = (int)(barPeak[i] * (h - 12));
g.setColor(Color.WHITE);
g.fillRect(bx + gap/2, y + h - 12 - ph - 1, barW - gap, 2);
}
}
g.setColor(getForeground());
g.setFont(g.getFont().deriveFont(8f));
FontMetrics fm = g.getFontMetrics();
String[] lbl = {"32","125","500","2k","8k","16k"};
double logMin = Math.log10(20.0), logMax = Math.log10(22050.0);
float[] freqs = {32,125,500,2000,8000,16000};
for (int i = 0; i < lbl.length; i++) {
double nx = (Math.log10(freqs[i]) - logMin) / (logMax - logMin) * w;
g.drawString(lbl[i], x + (int)nx, y + h - 1);
}
}
private float[] computeBars() {
int fftLen = currentMag.length;
float[] bars = new float[BAR_COUNT];
double logMin = Math.log10(20.0), logMax = Math.log10(22050.0);
for (int b = 0; b < BAR_COUNT; b++) {
double f1 = Math.pow(10, logMin + (logMax - logMin) * b / BAR_COUNT);
double f2 = Math.pow(10, logMin + (logMax - logMin) * (b + 1) / BAR_COUNT);
int bin1 = Math.max(0, Math.min(fftLen-1, (int)(f1/22050.0*fftLen)));
int bin2 = Math.max(bin1, Math.min(fftLen-1, (int)(f2/22050.0*fftLen)));
float sum = 0; int cnt = 0;
for (int k = bin1; k <= bin2; k++) { sum += currentMag[k]; cnt++; }
bars[b] = cnt > 0 ? sum / cnt : 0;
}
float dbMin = -80, dbMax = 0;
for (int b = 0; b < BAR_COUNT; b++) {
float db = bars[b] > 0 ? 20*(float)Math.log10(bars[b]) : dbMin;
bars[b] = Math.max(0, Math.min(1, (db - dbMin) / (dbMax - dbMin)));
}
for (int b = 0; b < BAR_COUNT; b++) {
barSmoothed[b] = barSmoothed[b]*0.65f + bars[b]*0.35f;
if (barSmoothed[b] > barPeak[b]) { barPeak[b] = barSmoothed[b]; peakTimer[b] = 25; }
else if (peakTimer[b] > 0) peakTimer[b]--;
else barPeak[b] = Math.max(0, barPeak[b] - 0.006f);
}
return barSmoothed;
}
private static Color[] buildHeatMap() {
int N = 256;
Color[] map = new Color[N];
Color[] stops = { Color.BLACK, new Color(0,0,180), Color.CYAN, Color.YELLOW, Color.RED };
int segs = stops.length - 1;
for (int i = 0; i < N; i++) {
float t = (float)i/(N-1) * segs;
int seg = Math.min((int)t, segs-1);
float f = t - seg;
Color a = stops[seg], b = stops[seg+1];
map[i] = new Color(
// hehe larp
lerp(a.getRed(), b.getRed(), f),
lerp(a.getGreen(), b.getGreen(), f),
lerp(a.getBlue(), b.getBlue(), f));
}
return map;
}
private static int lerp(int a, int b, float t) {
return Math.max(0, Math.min(255, (int)(a + (b-a)*t)));
}
}
@@ -0,0 +1,64 @@
package com.rdesk.applications.entertainment.media;
import java.awt.image.BufferedImage;
import java.nio.file.Path;
public class Track {
private final Path filePath;
private String title;
private String artist;
private String album;
private int year;
private int trackNumber;
private long durationSeconds;
private BufferedImage coverArt; // null if not embedded
private final String format; // "MP3", "FLAC", "WAV"
public Track(Path filePath) {
this.filePath = filePath;
String name = filePath.getFileName().toString();
int dot = name.lastIndexOf('.');
this.format = dot >= 0 ? name.substring(dot + 1).toUpperCase() : "UNKNOWN";
this.title = dot >= 0 ? name.substring(0, dot) : name;
this.artist = "Unknown Artist";
this.album = "Unknown Album";
this.year = 0;
this.trackNumber = 0;
this.durationSeconds = 0;
this.coverArt = null;
}
public Path getFilePath() { return filePath; }
public String getTitle() { return title; }
public String getArtist() { return artist; }
public String getAlbum() { return album; }
public int getYear() { return year; }
public int getTrackNumber() { return trackNumber; }
public long getDurationSeconds() { return durationSeconds; }
public BufferedImage getCoverArt() { return coverArt; }
public String getFormat() { return format; }
public String getDisplayTitle() {
return title != null && !title.isBlank() ? title : filePath.getFileName().toString();
}
public String getDurationString() {
long m = durationSeconds / 60;
long s = durationSeconds % 60;
return String.format("%d:%02d", m, s);
}
public void setTitle(String title) { this.title = title; }
public void setArtist(String artist) { this.artist = artist; }
public void setAlbum(String album) { this.album = album; }
public void setYear(int year) { this.year = year; }
public void setTrackNumber(int trackNumber) { this.trackNumber = trackNumber; }
public void setDurationSeconds(long dur) { this.durationSeconds = dur; }
public void setCoverArt(BufferedImage coverArt) { this.coverArt = coverArt; }
@Override
public String toString() {
return String.format("%s — %s", artist, title);
}
}
@@ -0,0 +1,90 @@
package com.rdesk.applications.entertainment.media;
import com.rdesk.kernel.DesktopApp;
import com.rdesk.kernel.Kernel;
import javax.swing.*;
import java.nio.file.Path;
public class Wavy implements DesktopApp {
@Override public String getId() { return "wavy-player"; }
@Override public String getName() { return "Wavy"; }
@Override
public JInternalFrame createWindow(Kernel kernel) {
JInternalFrame frame = new JInternalFrame(
"Wavy", true, true, true, true);
frame.setDefaultCloseOperation(JInternalFrame.DISPOSE_ON_CLOSE);
Path musicRoot = SourceConfig.loadMusicRoot();
MusicLibrary library = new MusicLibrary(musicRoot);
WavyPlayerPanel player = new WavyPlayerPanel(library);
player.hookFrameClose(frame);
frame.setJMenuBar(buildMenuBar(frame, library, player));
frame.setContentPane(player);
frame.pack();
frame.setSize(700, 780);
frame.setVisible(true);
player.reloadLibrary();
return frame;
}
private JMenuBar buildMenuBar(JInternalFrame frame, MusicLibrary library,
WavyPlayerPanel player) {
JMenuBar bar = new JMenuBar();
JMenu file = new JMenu("File");
JMenuItem chooseLocation = new JMenuItem("Choose Music Location…");
chooseLocation.setAccelerator(KeyStroke.getKeyStroke("control O"));
chooseLocation.addActionListener(e -> {
JFileChooser fc = new JFileChooser(library.getRootPath().toFile());
fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
fc.setDialogTitle("Select Music Folder");
fc.setApproveButtonText("Use This Folder");
if (fc.showOpenDialog(frame) == JFileChooser.APPROVE_OPTION) {
Path chosen = fc.getSelectedFile().toPath();
library.setRootPath(chosen);
try { SourceConfig.saveMusicRoot(chosen); }
catch (Exception ex) {
JOptionPane.showMessageDialog(frame,
"Could not save music location:\n" + ex.getMessage(),
"Save Error", JOptionPane.WARNING_MESSAGE);
}
frame.setTitle("Wavy - " + chosen.getFileName());
player.reloadLibrary();
}
});
file.add(chooseLocation);
JMenuItem refresh = new JMenuItem("Refresh Library");
refresh.setAccelerator(KeyStroke.getKeyStroke("F5"));
refresh.addActionListener(e -> player.reloadLibrary());
file.add(refresh);
file.addSeparator();
JMenuItem close = new JMenuItem("Close");
close.addActionListener(e -> frame.dispose());
file.add(close);
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About Wavy");
about.addActionListener(e -> JOptionPane.showMessageDialog(frame, """
Wavy is the music player for rDesk
it supports MP3 FLAC and WAVE Files (soon to be extended)
Wavy features a 10 band equalizer with some presets, shuffle,
repeat, and a library path which persists between start / stops!
""", "About Wavy", JOptionPane.INFORMATION_MESSAGE));
help.add(about);
bar.add(file);
bar.add(help);
return bar;
}
}
@@ -0,0 +1,312 @@
package com.rdesk.applications.entertainment.media;
import javax.swing.*;
import javax.swing.event.ListSelectionEvent;
import java.awt.*;
import java.awt.event.*;
import java.util.List;
public class WavyPlayerPanel extends JPanel {
private final AudioEngine engine;
private final MusicLibrary library;
private final CoverArtPanel coverPanel;
private final SpectrogramPanel spectrogram;
private final EqualizerPanel equalizer;
// Transport controls
private final JLabel lblTitle = new JLabel("No track selected");
private final JLabel lblArtist = new JLabel(" ");
private final JLabel lblAlbum = new JLabel(" ");
private final JLabel lblTime = new JLabel("0:00 / 0:00");
private final JSlider seekBar = new JSlider(0, 1000, 0);
private final JButton btnPrev = new JButton("<");
private final JButton btnPlay = new JButton("Play");
private final JButton btnStop = new JButton("Stop");
private final JButton btnNext = new JButton(">");
private final JCheckBox chkShuffle = new JCheckBox("Shuffle");
private final JCheckBox chkRepeat = new JCheckBox("Repeat");
private final JSlider volSlider;
// playlist
private final DefaultListModel<Track> playlistModel = new DefaultListModel<>();
private final JList<Track> playlistView = new JList<>(playlistModel);
private final JLabel lblStatus = new JLabel("No library loaded.");
// state
private int currentIndex = -1;
private boolean seekDragging = false;
private long currentDurationSec = 0;
private final Timer uiTimer;
public WavyPlayerPanel(MusicLibrary library) {
this.library = library;
this.engine = new AudioEngine();
this.coverPanel = new CoverArtPanel();
this.spectrogram = new SpectrogramPanel();
this.equalizer = new EqualizerPanel(engine);
volSlider = new JSlider(0, 100, 80);
setLayout(new BorderLayout(0, 0));
setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
engine.setOnTrackEnd(() -> SwingUtilities.invokeLater(this::playNext));
engine.setFftListener(mag -> {
spectrogram.receiveFft(mag);
});
engine.setOnPositionUpdate(pos -> {
if (!seekDragging) SwingUtilities.invokeLater(() -> updateSeekBar(pos));
});
buildUI();
uiTimer = new Timer(500, e -> updateTimeLabel());
uiTimer.start();
}
public void hookFrameClose(JInternalFrame frame) {
frame.addInternalFrameListener(new javax.swing.event.InternalFrameAdapter() {
@Override public void internalFrameClosing(javax.swing.event.InternalFrameEvent e) {
engine.shutdown();
library.shutdown();
}
@Override public void internalFrameClosed(javax.swing.event.InternalFrameEvent e) {
engine.shutdown();
library.shutdown();
}
});
}
private void buildUI() {
add(buildNowPlayingPanel(), BorderLayout.NORTH);
add(spectrogram, BorderLayout.CENTER);
add(buildBottomTabs(), BorderLayout.SOUTH);
}
private JPanel buildNowPlayingPanel() {
JPanel panel = new JPanel(new BorderLayout(8, 0));
panel.setBorder(BorderFactory.createTitledBorder("Now Playing"));
coverPanel.setPreferredSize(new Dimension(130, 130));
coverPanel.setMinimumSize(new Dimension(60, 60));
panel.add(coverPanel, BorderLayout.WEST);
JPanel right = new JPanel(new BorderLayout(0, 4));
right.setBorder(BorderFactory.createEmptyBorder(2, 0, 2, 0));
JPanel infoPanel = new JPanel(new GridLayout(3, 1, 0, 1));
lblTitle.setFont(lblTitle.getFont().deriveFont(Font.BOLD, 13f));
infoPanel.add(lblTitle);
infoPanel.add(lblArtist);
infoPanel.add(lblAlbum);
right.add(infoPanel, BorderLayout.NORTH);
JPanel seekPanel = new JPanel(new BorderLayout(4, 0));
seekBar.setPaintTicks(false);
seekBar.setPaintLabels(false);
seekBar.addMouseListener(new MouseAdapter() {
public void mousePressed(MouseEvent e) { seekDragging = true; }
public void mouseReleased(MouseEvent e) {
seekDragging = false;
engine.seekFraction(seekBar.getValue() / (double) seekBar.getMaximum());
}
});
seekPanel.add(seekBar, BorderLayout.CENTER);
seekPanel.add(lblTime, BorderLayout.EAST);
right.add(seekPanel, BorderLayout.CENTER);
right.add(buildTransportRow(), BorderLayout.SOUTH);
panel.add(right, BorderLayout.CENTER);
return panel;
}
private JPanel buildTransportRow() {
JPanel row = new JPanel(new BorderLayout(0, 0));
JPanel btns = new JPanel(new FlowLayout(FlowLayout.LEFT, 3, 0));
btns.add(btnPrev);
btns.add(btnPlay);
btns.add(btnStop);
btns.add(btnNext);
btns.add(new JSeparator(JSeparator.VERTICAL) {{ setPreferredSize(new Dimension(4, 24)); }});
btns.add(chkShuffle);
btns.add(chkRepeat);
row.add(btns, BorderLayout.WEST);
JPanel vol = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
vol.add(new JLabel("Volume:"));
volSlider.setPreferredSize(new Dimension(90, volSlider.getPreferredSize().height));
volSlider.setToolTipText("Volume");
volSlider.addChangeListener(e -> engine.setVolume(volSlider.getValue() / 100f));
vol.add(volSlider);
row.add(vol, BorderLayout.EAST);
btnPlay.addActionListener(e -> togglePlayPause());
btnStop.addActionListener(e -> {
engine.stop();
btnPlay.setText("Play");
});
btnPrev.addActionListener(e -> playPrev());
btnNext.addActionListener(e -> playNext());
return row;
}
private JTabbedPane buildBottomTabs() {
JTabbedPane tabs = new JTabbedPane();
// Playlist
JPanel playlistPanel = new JPanel(new BorderLayout(0, 2));
playlistPanel.setBorder(BorderFactory.createEmptyBorder(2, 0, 0, 0));
playlistView.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
playlistView.setCellRenderer(new TrackCellRenderer());
playlistView.setFixedCellHeight(38);
playlistView.addMouseListener(new MouseAdapter() {
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
int idx = playlistView.locationToIndex(e.getPoint());
if (idx >= 0) playTrack(idx);
}
}
});
JScrollPane scroll = new JScrollPane(playlistView);
scroll.setPreferredSize(new Dimension(0, 220));
playlistPanel.add(scroll, BorderLayout.CENTER);
playlistPanel.add(lblStatus, BorderLayout.SOUTH);
tabs.addTab("Playlist", playlistPanel);
tabs.addTab("Equalizer", equalizer);
return tabs;
}
public void reloadLibrary() {
playlistModel.clear();
lblStatus.setText("Scanning");
library.scan(
track -> SwingUtilities.invokeLater(() -> {
playlistModel.addElement(track);
lblStatus.setText(playlistModel.size() + " tracks found");
}),
() -> SwingUtilities.invokeLater(() -> {
library.sortByArtistTitle();
List<Track> sorted = library.getTracks();
playlistModel.clear();
for (Track t : sorted) playlistModel.addElement(t);
lblStatus.setText(playlistModel.size() + " tracks.");
})
);
}
public AudioEngine getEngine() { return engine; }
private void togglePlayPause() {
if (engine.getState() == AudioEngine.State.STOPPED) {
if (currentIndex < 0 && playlistModel.size() > 0) playTrack(0);
else if (currentIndex >= 0) playTrack(currentIndex);
} else {
engine.togglePause();
boolean paused = engine.getState() == AudioEngine.State.PAUSED;
btnPlay.setText(paused ? "Play" : "Pause");
}
}
private void playTrack(int index) {
if (index < 0 || index >= playlistModel.size()) return;
currentIndex = index;
Track track = playlistModel.get(index);
engine.play(track);
updateTrackInfo(track);
currentDurationSec = track.getDurationSeconds();
playlistView.setSelectedIndex(index);
playlistView.ensureIndexIsVisible(index);
btnPlay.setText("Pause");
spectrogram.reset();
playlistView.repaint();
}
private void playNext() {
if (chkRepeat.isSelected() && currentIndex >= 0) { playTrack(currentIndex); return; }
int next = chkShuffle.isSelected()
? (int)(Math.random() * playlistModel.size())
: (currentIndex + 1) % Math.max(1, playlistModel.size());
playTrack(next);
}
private void playPrev() {
if (currentIndex <= 0) return;
playTrack(currentIndex - 1);
}
private void updateTrackInfo(Track track) {
lblTitle.setText(track.getDisplayTitle());
lblArtist.setText(track.getArtist());
lblAlbum.setText(track.getAlbum() + (track.getYear() > 0 ? " (" + track.getYear() + ")" : ""));
coverPanel.setTrack(track);
seekBar.setValue(0);
}
private void updateSeekBar(long posFrames) {
long total = engine.getTotalFrames();
if (total > 0) seekBar.setValue((int)(posFrames * 1000L / total));
}
private void updateTimeLabel() {
float sr = engine.getFormat() != null ? engine.getFormat().getSampleRate() : 44100f;
long posFr = engine.getPositionFrames();
long totalFr = engine.getTotalFrames();
long posSec = (long)(posFr / sr);
long totalSec = totalFr > 0
? (long)(totalFr / sr)
: (currentIndex >= 0 ? playlistModel.get(currentIndex).getDurationSeconds() : 0);
if (totalFr > 0 && currentIndex >= 0) {
Track t = playlistModel.get(currentIndex);
if (t.getDurationSeconds() == 0) t.setDurationSeconds(totalSec);
}
lblTime.setText(fmt(posSec) + " / " + fmt(totalSec));
if (engine.getState() == AudioEngine.State.STOPPED) btnPlay.setText("Play");
}
private static String fmt(long sec) {
return String.format("%d:%02d", sec / 60, sec % 60);
}
private class TrackCellRenderer extends DefaultListCellRenderer {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value,
int index, boolean isSelected, boolean hasFocus) {
Track t = (Track) value;
String dur = t.getDurationSeconds() > 0 ? t.getDurationString() : "--:--";
String html = String.format(
"<html>" +
"<b>%s</b>" +
" &nbsp; " +
"<small>%s - %s</small>" +
" &nbsp; " +
"<font color=gray>[%s] %s</font>" +
"</html>",
esc(t.getDisplayTitle()), esc(t.getArtist()), esc(t.getAlbum()),
t.getFormat(), dur);
JLabel lbl = (JLabel) super.getListCellRendererComponent(list, html, index, isSelected, hasFocus);
if (index == currentIndex)
lbl.setBorder(BorderFactory.createMatteBorder(0, 3, 0, 0,
UIManager.getColor("List.selectionBackground")));
return lbl;
}
private String esc(String s) {
return s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;");
}
}
}
@@ -7,7 +7,6 @@ import com.rdesk.kernel.Kernel;
import javax.swing.*;
import java.awt.*;
import java.io.IOException;
import java.awt.Window;
public class Exit implements DesktopApp {
@@ -59,8 +58,7 @@ public class Exit implements DesktopApp {
logoffButton.addActionListener(e -> {
int choice = JOptionPane.showConfirmDialog(
frame,
"Log off and return to the login screen?"
+ "This will end all running Tasks!",
"Log off and return to the login screen?",
"Confirm Log off",
JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE
@@ -21,7 +21,7 @@ import java.util.Base64;
import java.util.List;
public class Handler {
private static final String FILENAME = System.getProperty("user.home") + File.separator + "rsys_passwdstore.json";
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;
@@ -1,6 +1,7 @@
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;
@@ -100,6 +101,7 @@ public class BootProc {
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");