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 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 getTracks() { + return Collections.unmodifiableList(tracks); + } + + public void scan(Consumer 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(); + } +} diff --git a/src/main/java/com/rdesk/applications/entertainment/media/SourceConfig.java b/src/main/java/com/rdesk/applications/entertainment/media/SourceConfig.java new file mode 100644 index 0000000..faa5109 --- /dev/null +++ b/src/main/java/com/rdesk/applications/entertainment/media/SourceConfig.java @@ -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("\\/", "/"); + } +} diff --git a/src/main/java/com/rdesk/applications/entertainment/media/SpectrogramPanel.java b/src/main/java/com/rdesk/applications/entertainment/media/SpectrogramPanel.java new file mode 100644 index 0000000..b28ea89 --- /dev/null +++ b/src/main/java/com/rdesk/applications/entertainment/media/SpectrogramPanel.java @@ -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 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))); + } +} diff --git a/src/main/java/com/rdesk/applications/entertainment/media/Track.java b/src/main/java/com/rdesk/applications/entertainment/media/Track.java new file mode 100644 index 0000000..f4da01d --- /dev/null +++ b/src/main/java/com/rdesk/applications/entertainment/media/Track.java @@ -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); + } +} diff --git a/src/main/java/com/rdesk/applications/entertainment/media/Wavy.java b/src/main/java/com/rdesk/applications/entertainment/media/Wavy.java new file mode 100644 index 0000000..83cc671 --- /dev/null +++ b/src/main/java/com/rdesk/applications/entertainment/media/Wavy.java @@ -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; + } +} diff --git a/src/main/java/com/rdesk/applications/entertainment/media/WavyPlayerPanel.java b/src/main/java/com/rdesk/applications/entertainment/media/WavyPlayerPanel.java new file mode 100644 index 0000000..447bfb5 --- /dev/null +++ b/src/main/java/com/rdesk/applications/entertainment/media/WavyPlayerPanel.java @@ -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 playlistModel = new DefaultListModel<>(); + private final JList 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 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( + "" + + "%s" + + "   " + + "%s - %s" + + "   " + + "[%s] %s" + + "", + 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(">",">"); + } + } +} diff --git a/src/main/java/com/rdesk/applications/system/Exit.java b/src/main/java/com/rdesk/applications/system/Exit.java index b71f0db..78ff076 100644 --- a/src/main/java/com/rdesk/applications/system/Exit.java +++ b/src/main/java/com/rdesk/applications/system/Exit.java @@ -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 diff --git a/src/main/java/com/rdesk/applications/system/login/Handler.java b/src/main/java/com/rdesk/applications/system/login/Handler.java index 63003d1..05e8f65 100644 --- a/src/main/java/com/rdesk/applications/system/login/Handler.java +++ b/src/main/java/com/rdesk/applications/system/login/Handler.java @@ -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; diff --git a/src/main/java/com/rdesk/kernel/BootProc.java b/src/main/java/com/rdesk/kernel/BootProc.java index a3740c1..38d92b7 100644 --- a/src/main/java/com/rdesk/kernel/BootProc.java +++ b/src/main/java/com/rdesk/kernel/BootProc.java @@ -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");