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:
@@ -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>
|
||||
@@ -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>" +
|
||||
" " +
|
||||
"<small>%s - %s</small>" +
|
||||
" " +
|
||||
"<font color=gray>[%s] %s</font>" +
|
||||
"</html>",
|
||||
esc(t.getDisplayTitle()), esc(t.getArtist()), esc(t.getAlbum()),
|
||||
t.getFormat(), dur);
|
||||
JLabel lbl = (JLabel) super.getListCellRendererComponent(list, html, index, isSelected, hasFocus);
|
||||
if (index == currentIndex)
|
||||
lbl.setBorder(BorderFactory.createMatteBorder(0, 3, 0, 0,
|
||||
UIManager.getColor("List.selectionBackground")));
|
||||
return lbl;
|
||||
}
|
||||
private String esc(String s) {
|
||||
return s.replace("&","&").replace("<","<").replace(">",">");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user