// Copyright 2014-2025 Jesper Larsson
//
// This file is part of Klipspringer, <https://klipspringer.avadeaux.net/>
//
// Klipspringer is free software: you can redistribute it and/or modify it under the terms of the
// GNU General Public License as published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// Klipspringer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with Klipspringer. If
// not, see <https://www.gnu.org/licenses/>.

package net.avadeaux.klipspringer;

import java.io.*;
import net.avadeaux.klipspringer.codec.PcmFormat;
import static net.avadeaux.klipspringer.PlayState.Status.*;

/**
 * Maintains the playback state, getting updated from three sides: feeder (what is currently being
 * decoded), consumer (what is currently being sent to the destination), and stream (what is
 * currently being played by a web client). Exposes two user interfaces: one for server-side players
 * (which display consumer status) and one for web clients.
 */
public class PlayState implements PlayMonitor {
    public static class ResumeException extends IOException {
        public final Track track;
        public final double time;

        private ResumeException(Track track, double time) {
            this.track = track;
            this.time = time;
        }
    }

    public static class Info implements PlayMonitor.Info {
        private Track track;
        private boolean monomix, paused;
        private double time;
        private long timestamp = 0;

        public String trackName() { return track == null ? null : track.path(); }
        public int trackIndex() { return track == null ? -1 : track.index; }
        public PcmFormat format() { return track == null ? null : track.format(); }
        public boolean monomix() { return monomix; }
        public boolean paused() { return paused; }
        public double time() { return time; }
        public String quality() { return track == null ? null : track.quality(); }
    }

    enum Status { STOPPED, STREAM_SWITCHING, PLAYING, PAUSED }

    private final Track.List tracks;

    // When state is STOPPED, track and index is set to -1. When consumer initiates going to another
    // state, both consumer and stream tracks are set to the first track that is played. Times
    // are relative to the start of the track list.

    private Status consStatus = STOPPED;
    private long changeNo = 1;                          // timestamp of consumer status change

    private double streamStartTime = Double.NaN;        // set leaving STOPPED
    private Track streamTr = null;                      // first track in stream, set leaving STOPPED

    private Track consTr = null;                        // set by consumer
    private double consTime = Double.NaN;               // set by consumer
    private double consTimeSetTime = Double.NaN;        // time of most recent update from consumer

    private boolean monomix = false;                    // set to nofify of monomix of current audio

    public PlayState(Track.List tracks) { this.tracks = tracks; }

    private double adjTime() {
        return consTime + (consStatus == PAUSED || Double.isNaN(consTimeSetTime)
                           ? 0
                           : System.currentTimeMillis() / 1000.0 - consTimeSetTime);
    }

    /** Invoked by consumer thread to inform the state service that it has potentially changed its
      * status, or shifted track index or track time.
      */
    public synchronized void consumerStatus(Status newStatus, Track newTr, double newTime) {
        while (newTime < 0) {                   // compensate for play time behind consume time
            Track tr = null;
            try { tr = tracks.ofIndex(newTr.index-1); } catch (Throwable ex) { }
            if (tr == null) { break; }
            newTime = tr.playTimeSecs() + newTime;
            newTr = tr;
        }
        double currConsTime = adjTime();

        // Check for significant status change.
        if ((newStatus == STOPPED) != (consStatus == STOPPED)   // in or out of stopped state
            || (newStatus == PAUSED) != (consStatus == PAUSED)  // in or out of paused state
            || newTr != consTr                                  // track change
            || Math.abs(newTime - currConsTime) > 0.2           // time shifted by more than 0.2 seconds
            || newTime == 0 && currConsTime > 0) {              // track time reset
            changeNo++;
            notifyAll();
        }

        if (newStatus != STOPPED && newStatus != STREAM_SWITCHING) {
            consTr = newTr;
            consTime = newTime;
            consTimeSetTime = System.currentTimeMillis() / 1000.0;
            if (consStatus == STOPPED || consStatus == STREAM_SWITCHING) {
                streamTr = newTr;
                streamStartTime = newTime + newTr.startTimeSecs();
            }
        }
        consStatus = newStatus;
    }

    public synchronized void monomixing(boolean monomix) { this.monomix = monomix; }

    public synchronized Query.Onoff monomixing() { return monomix ? Query.Onoff.ON : Query.Onoff.OFF; }

    public synchronized Track playingTrack() { return consTr; }

    public synchronized int playingTrackIndex() { return consTr == null ? -1 : consTr.index; }

    public static String channelsString(int channels) {
        switch (channels) {
        case 1:
            return "\"Mono\"";
        case 2:
            return "\"Stereo\"";
        default:
            return "\""+channels+"-channel\"";
        }
    }

    public synchronized double playingTime() {
        return consTr == null ? Double.NaN : adjTime() + consTr.startTimeSecs();
    }

    public synchronized ResumeException resumeException() {
        return consTr == null ? null : new ResumeException(consTr, adjTime());
    }

    private void appendTrackInfo(Track tr, Writer w) throws IOException {
        if (tr != null) {
            w.append("\"track_number\":").append(tr.trackNo()+",");
            w.append("\"track_ix\":").append(tr.index+",");
            w.append("\"first_track\":").append(tracks.firstTrackNo+",");
            PcmFormat fmt = tr.format();
            w.append("\"sample_rate\":\"").append(fmt.rate()+" Hz\",");
            w.append("\"bits_per_sample\":").append(fmt.significantBips()+",");
            w.append("\"channels\":").append(channelsString(fmt.channels())).append(",");
            if (monomix) { w.append("\"monomix\":true,"); }
            String q = tr.quality();
            if (q != null && q.length() > 0) { w.append("\"quality\":\""+q+"\","); }

            if (tr.index < tracks.length-1) { w.append("\"has_following_track\":true,"); }
        }
    }

    private void waitForUpdate(long alreadySeen, long timeoutMillis) {
        long stopWaitingAtMillis = System.currentTimeMillis() + (timeoutMillis > 0 ? timeoutMillis : 10000);
        while (true) {
            if (changeNo > alreadySeen) { break; }
            long waitTime = stopWaitingAtMillis - System.currentTimeMillis();
            if (waitTime <= 0) { break; }
            try { wait(waitTime); } catch (InterruptedException e) { }
        }
    }

    /** Potentially waits for an update of newer timestamp or for timeout, then writes status
      * (including track name etc.) in JSON format to the given writer. The lowest timestamp is 1,
      * so if the value given t this function is 0, it returns without waiting. Timeout defaults to
      * ten seconds if a negative value is given.
      */
    public synchronized void serverStatus(long alreadySeen, long timeoutMillis, Writer w) throws IOException {
        waitForUpdate(alreadySeen, timeoutMillis);
        if (consStatus != STOPPED) {
            appendTrackInfo(consTr, w);
            double trackTime = Math.max(0, adjTime());
            if (!Double.isNaN(trackTime)) { w.append("\"track_time\":").append(trackTime+","); }
        }
        w.append("\"paused\":").append((consStatus == PAUSED)+",");
        w.append("\"status_timestamp\":"+changeNo);
    }

    public Info getInfoObject() { return new Info(); }

    /** Potentially waits for an update newer than the one already set in the given info record,
      * then sets the record fields.
      */
    public synchronized void getStatus(PlayMonitor.Info istatus, long timeoutMillis) {
        Info info = (Info) istatus;
        waitForUpdate(info.timestamp, timeoutMillis);
        if (consStatus != STOPPED) {
            info.track = consTr;
            info.time = Math.max(0, adjTime());
        } else {
            info.track = null;
            info.time = Double.NaN;
        }
        info.monomix = monomix;
        info.paused = consStatus == PAUSED;
        info.timestamp = changeNo;
    }

    /** Gets current status when playing stream. */
    public synchronized void streamStatus(double time, Writer w) throws IOException {
        if (consStatus == STOPPED) {
            w.append("\"err\": \"currently not streaming\"");
            return;
        }
        while (true) {
            Track nextTr = tracks.ofIndex(streamTr.index+1);
            if (nextTr == null || streamStartTime+time < nextTr.startTimeSecs()) { break; }
            streamTr = nextTr;
        }
        appendTrackInfo(streamTr, w);

        // Offset of current track in playing stream (can be negative in the start track).
        w.append("\"current_track_start\":").append(Double.toString(streamTr.startTimeSecs() - streamStartTime));

        // Length in seconds of the playing track.
        w.append(",\"current_track_length\":").append(Double.toString(streamTr.playTimeSecs()));
    }

    public synchronized boolean paused() { return consStatus == PAUSED; }
}

Version: v4.3.2.2 (2026-05-16T17:03:34+02:00)
Raw file
Source code overview
Klipspringer home