diff --git a/pom.xml b/pom.xml
index 8e14f19..46199da 100644
--- a/pom.xml
+++ b/pom.xml
@@ -120,7 +120,16 @@
languagesupport
3.4.1
-
+
+ com.googlecode.soundlibs
+ mp3spi
+ 1.9.5.4
+
+
+ org.jflac
+ jflac-codec
+ 1.5.2
+
\ No newline at end of file
diff --git a/src/main/java/com/rdesk/Main.java b/src/main/java/com/rdesk/Main.java
index 6f191dc..e97cb64 100644
--- a/src/main/java/com/rdesk/Main.java
+++ b/src/main/java/com/rdesk/Main.java
@@ -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();
+ }
}
}
\ No newline at end of file
diff --git a/src/main/java/com/rdesk/applications/entertainment/media/AudioEngine.java b/src/main/java/com/rdesk/applications/entertainment/media/AudioEngine.java
new file mode 100644
index 0000000..60e2762
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/media/AudioEngine.java
@@ -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 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 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 c) { this.onPositionUpdate = c; }
+ public void setFftListener(Consumer 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>1;
+ for (; (i&bit)!=0; bit>>=1) i^=bit;
+ i^=bit;
+ if (j 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);
+ }
+ }
+}
diff --git a/src/main/java/com/rdesk/applications/entertainment/media/EqualizerPanel.java b/src/main/java/com/rdesk/applications/entertainment/media/EqualizerPanel.java
new file mode 100644
index 0000000..750d27b
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/media/EqualizerPanel.java
@@ -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 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();
+ }
+ }
+}
diff --git a/src/main/java/com/rdesk/applications/entertainment/media/MetadataReader.java b/src/main/java/com/rdesk/applications/entertainment/media/MetadataReader.java
new file mode 100644
index 0000000..a8068d7
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/media/MetadataReader.java
@@ -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);
+ }
+}
diff --git a/src/main/java/com/rdesk/applications/entertainment/media/MusicLibrary.java b/src/main/java/com/rdesk/applications/entertainment/media/MusicLibrary.java
new file mode 100644
index 0000000..08fdde3
--- /dev/null
+++ b/src/main/java/com/rdesk/applications/entertainment/media/MusicLibrary.java
@@ -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 SUPPORTED_EXTENSIONS =
+ Set.of(".mp3", ".flac", ".wav");
+
+ private final List