diff --git a/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java b/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java index 99b8d015..b63760ad 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java @@ -1,7 +1,6 @@ package xyz.gianlu.librespot.api.handlers; import com.google.gson.JsonObject; -import com.spotify.metadata.Metadata; import io.undertow.websockets.WebSocketConnectionCallback; import io.undertow.websockets.WebSocketProtocolHandshakeHandler; import io.undertow.websockets.core.WebSocketChannel; @@ -15,6 +14,7 @@ import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.model.PlayableId; import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.TrackOrEpisode; public final class EventsHandler extends WebSocketProtocolHandshakeHandler implements Player.EventsListener, SessionWrapper.Listener, Session.ReconnectionListener { private static final Logger LOGGER = Logger.getLogger(EventsHandler.class); @@ -37,12 +37,15 @@ public void onContextChanged(@NotNull String newUri) { } @Override - public void onTrackChanged(@NotNull PlayableId id, Metadata.@Nullable Track track, Metadata.@Nullable Episode episode) { + public void onTrackChanged(@NotNull PlayableId id, @Nullable TrackOrEpisode metadata) { JsonObject obj = new JsonObject(); obj.addProperty("event", "trackChanged"); obj.addProperty("uri", id.toSpotifyUri()); - if (track != null) obj.add("track", ProtobufToJson.convert(track)); - if (episode != null) obj.add("episode", ProtobufToJson.convert(episode)); + if (metadata != null) { + if (metadata.track != null) obj.add("track", ProtobufToJson.convert(metadata.track)); + else if (metadata.episode != null) obj.add("episode", ProtobufToJson.convert(metadata.episode)); + } + dispatch(obj); } @@ -71,11 +74,11 @@ public void onTrackSeeked(long trackTime) { } @Override - public void onMetadataAvailable(Metadata.@Nullable Track track, Metadata.@Nullable Episode episode) { + public void onMetadataAvailable(@NotNull TrackOrEpisode metadata) { JsonObject obj = new JsonObject(); obj.addProperty("event", "metadataAvailable"); - if (track != null) obj.add("track", ProtobufToJson.convert(track)); - if (episode != null) obj.add("episode", ProtobufToJson.convert(episode)); + if (metadata.track != null) obj.add("track", ProtobufToJson.convert(metadata.track)); + else if (metadata.episode != null) obj.add("episode", ProtobufToJson.convert(metadata.episode)); dispatch(obj); } diff --git a/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java b/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java index aeef1afd..1cf2f6bf 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java @@ -1,7 +1,6 @@ package xyz.gianlu.librespot.api.handlers; import com.google.gson.JsonObject; -import com.spotify.metadata.Metadata; import io.undertow.server.HttpServerExchange; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,7 +11,8 @@ import xyz.gianlu.librespot.mercury.model.EpisodeId; import xyz.gianlu.librespot.mercury.model.PlayableId; import xyz.gianlu.librespot.mercury.model.TrackId; -import xyz.gianlu.librespot.player.PlayerRunner; +import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.TrackOrEpisode; import java.util.Deque; import java.util.Map; @@ -38,8 +38,8 @@ private void setVolume(HttpServerExchange exchange, Session session, @Nullable S return; } - if (val < 0 || val > PlayerRunner.VOLUME_MAX) { - Utils.invalidParameter(exchange, "volume", "Must be >= 0 and <= " + PlayerRunner.VOLUME_MAX); + if (val < 0 || val > Player.VOLUME_MAX) { + Utils.invalidParameter(exchange, "volume", "Must be >= 0 and <= " + Player.VOLUME_MAX); return; } @@ -56,7 +56,12 @@ private void load(HttpServerExchange exchange, Session session, @Nullable String } private void current(HttpServerExchange exchange, Session session) { - PlayableId id = session.player().currentPlayableId(); + PlayableId id; + try { + id = session.player().currentPlayable(); + } catch (IllegalStateException ex) { + id = null; + } JsonObject obj = new JsonObject(); if (id != null) obj.addProperty("current", id.toSpotifyUri()); @@ -64,22 +69,21 @@ private void current(HttpServerExchange exchange, Session session) { long time = session.player().time(); obj.addProperty("trackTime", time); + TrackOrEpisode metadata = session.player().currentMetadata(); if (id instanceof TrackId) { - Metadata.Track track = session.player().currentTrack(); - if (track == null) { + if (metadata == null || metadata.track == null) { Utils.internalError(exchange, "Missing track metadata. Try again."); return; } - obj.add("track", ProtobufToJson.convert(track)); + obj.add("track", ProtobufToJson.convert(metadata.track)); } else if (id instanceof EpisodeId) { - Metadata.Episode episode = session.player().currentEpisode(); - if (episode == null) { + if (metadata == null || metadata.episode == null) { Utils.internalError(exchange, "Missing episode metadata. Try again."); return; } - obj.add("episode", ProtobufToJson.convert(episode)); + obj.add("episode", ProtobufToJson.convert(metadata.episode)); } else { Utils.internalError(exchange, "Invalid PlayableId: " + id); return; diff --git a/common/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java b/common/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java index 419eea3e..3ae5d65f 100644 --- a/common/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java +++ b/common/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java @@ -3,30 +3,26 @@ import org.jetbrains.annotations.NotNull; import java.util.concurrent.ThreadFactory; +import java.util.function.Function; /** * @author Gianlu */ public final class NameThreadFactory implements ThreadFactory { private final ThreadGroup group; - private final NameProvider nameProvider; + private final Function nameProvider; - public NameThreadFactory(@NotNull NameProvider nameProvider) { + public NameThreadFactory(@NotNull Function nameProvider) { this.nameProvider = nameProvider; SecurityManager s = System.getSecurityManager(); group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); } @Override - public Thread newThread(@NotNull Runnable r) { - Thread t = new Thread(group, r, nameProvider.getName(r), 0); + public @NotNull Thread newThread(@NotNull Runnable r) { + Thread t = new Thread(group, r, nameProvider.apply(r), 0); if (t.isDaemon()) t.setDaemon(false); if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); return t; } - - public interface NameProvider { - @NotNull - String getName(@NotNull Runnable r); - } } diff --git a/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java b/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java index bacbd4e9..2eb2d794 100644 --- a/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java +++ b/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java @@ -20,7 +20,7 @@ import xyz.gianlu.librespot.core.TimeProvider; import xyz.gianlu.librespot.core.ZeroconfServer; import xyz.gianlu.librespot.player.AudioOutput; -import xyz.gianlu.librespot.player.PlayerRunner; +import xyz.gianlu.librespot.player.Player; import xyz.gianlu.librespot.player.codecs.AudioQuality; import java.io.File; @@ -278,7 +278,7 @@ public boolean logAvailableMixers() { @Override public int initialVolume() { int vol = config.get("player.initialVolume"); - if (vol < 0 || vol > PlayerRunner.VOLUME_MAX) + if (vol < 0 || vol > Player.VOLUME_MAX) throw new IllegalArgumentException("Invalid volume: " + vol); return vol; @@ -287,7 +287,7 @@ public int initialVolume() { @Override public int volumeSteps() { int volumeSteps = config.get("player.volumeSteps"); - if (volumeSteps < 0 || volumeSteps > PlayerRunner.VOLUME_MAX) + if (volumeSteps < 0 || volumeSteps > Player.VOLUME_MAX) throw new IllegalArgumentException("Invalid volume steps: " + volumeSteps); return volumeSteps; diff --git a/core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java b/core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java index 37ca7d61..87cf8a09 100644 --- a/core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java +++ b/core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java @@ -95,7 +95,7 @@ List getHeaders(@NotNull String streamId) throws IOException { } @Nullable - JournalHeader getHeader(@NotNull String streamId, byte id) throws IOException { + JournalHeader getHeader(@NotNull String streamId, int id) throws IOException { Entry entry = find(streamId); if (entry == null) throw new JournalException("Couldn't find entry on journal: " + streamId); @@ -104,7 +104,7 @@ JournalHeader getHeader(@NotNull String streamId, byte id) throws IOException { } } - void setHeader(@NotNull String streamId, byte headerId, byte[] value) throws IOException { + void setHeader(@NotNull String streamId, int headerId, byte[] value) throws IOException { String strValue = Utils.bytesToHex(value); if (strValue.length() > MAX_HEADER_LENGTH) throw new IllegalArgumentException(); @@ -256,17 +256,17 @@ void remove() throws IOException { io.write(0); } - private int findHeader(byte headerId) throws IOException { + private int findHeader(int headerId) throws IOException { for (int i = 0; i < MAX_HEADERS; i++) { io.seek(offset + MAX_ID_LENGTH + MAX_CHUNKS_SIZE + i * (MAX_HEADER_LENGTH + 1)); - if (io.read() == headerId) + if ((io.read() & 0xFF) == headerId) return i; } return -1; } - void setHeader(byte id, @NotNull String value) throws IOException { + void setHeader(int id, @NotNull String value) throws IOException { int index = findHeader(id); if (index == -1) { for (int i = 0; i < MAX_HEADERS; i++) { @@ -304,7 +304,7 @@ List getHeaders() throws IOException { } @Nullable - JournalHeader getHeader(byte id) throws IOException { + JournalHeader getHeader(int id) throws IOException { int index = findHeader(id); if (index == -1) return null; diff --git a/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java b/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java index bdfaf45b..33b50cc4 100644 --- a/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java +++ b/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java @@ -3,8 +3,8 @@ import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.GeneralWritableStream; -import xyz.gianlu.librespot.player.StreamId; +import xyz.gianlu.librespot.player.feeders.GeneralWritableStream; +import xyz.gianlu.librespot.player.feeders.StreamId; import java.io.Closeable; import java.io.File; @@ -27,7 +27,7 @@ public class CacheManager implements Closeable { private static final long CLEAN_UP_THRESHOLD = TimeUnit.DAYS.toMillis(7); private static final Logger LOGGER = Logger.getLogger(CacheManager.class); - private static final byte HEADER_TIMESTAMP = (byte) 0b11111110; + private static final int HEADER_TIMESTAMP = 254; private final File parent; private final CacheJournal journal; private final Map fileHandlers = new ConcurrentHashMap<>(); @@ -62,7 +62,7 @@ public CacheManager(@NotNull Configuration conf) throws IOException { JournalHeader header = journal.getHeader(id, HEADER_TIMESTAMP); if (header == null) continue; - long timestamp = new BigInteger(header.value).longValue(); + long timestamp = new BigInteger(header.value).longValue() * 1000; if (System.currentTimeMillis() - timestamp > CLEAN_UP_THRESHOLD) remove(id); } @@ -160,7 +160,7 @@ private void updateTimestamp() { } } - public void setHeader(byte id, byte[] value) throws IOException { + public void setHeader(int id, byte[] value) throws IOException { try { journal.setHeader(streamId, id, value); } finally { diff --git a/core/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java b/core/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java index ca6f41ca..20d55a62 100644 --- a/core/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java +++ b/core/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java @@ -10,10 +10,10 @@ * @author Gianlu */ public final class JournalHeader { - public final byte id; + public final int id; public final byte[] value; - JournalHeader(byte id, @NotNull String value) { + JournalHeader(int id, @NotNull String value) { this.id = id; this.value = Utils.hexToBytes(value); } diff --git a/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java b/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java index edf97997..7011b4c9 100644 --- a/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java +++ b/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java @@ -19,7 +19,6 @@ import xyz.gianlu.librespot.dealer.DealerClient.RequestResult; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.SubListener; -import xyz.gianlu.librespot.player.PlayerRunner; import java.io.Closeable; import java.io.IOException; @@ -150,7 +149,7 @@ public void onMessage(@NotNull String uri, @NotNull Map headers, } } - LOGGER.trace(String.format("Update volume. {volume: %d/%d}", cmd.getVolume(), PlayerRunner.VOLUME_MAX)); + LOGGER.trace(String.format("Update volume. {volume: %d/%d}", cmd.getVolume(), xyz.gianlu.librespot.player.Player.VOLUME_MAX)); notifyVolumeChange(); } else if (Objects.equals(uri, "hm://connect-state/v1/cluster")) { Connect.ClusterUpdate update = Connect.ClusterUpdate.parseFrom(payload); @@ -176,6 +175,11 @@ public RequestResult onRequest(@NotNull String mid, int pid, @NotNull String sen return RequestResult.SUCCESS; } + @Nullable + public synchronized String getLastCommandSentByDeviceId() { + return putState.getLastCommandSentByDeviceId(); + } + private synchronized long startedPlayingAt() { return putState.getStartedPlayingAt(); } @@ -220,7 +224,7 @@ public void setVolume(int val) { } notifyVolumeChange(); - LOGGER.trace(String.format("Update volume. {volume: %d/%d}", val, PlayerRunner.VOLUME_MAX)); + LOGGER.trace(String.format("Update volume. {volume: %d/%d}", val, xyz.gianlu.librespot.player.Player.VOLUME_MAX)); } @Override @@ -319,6 +323,16 @@ public static JsonObject getPlayerOptionsOverride(@NotNull JsonObject obj) { return obj.getAsJsonObject("options").getAsJsonObject("player_options_override"); } + public static boolean willSkipToSomething(@NotNull JsonObject obj) { + JsonObject parent = obj.getAsJsonObject("options"); + if (parent == null) return false; + + parent = parent.getAsJsonObject("skip_to"); + if (parent == null) return false; + + return parent.has("track_uid") || parent.has("track_uri") || parent.has("track_index"); + } + @Nullable public static String getSkipToUid(@NotNull JsonObject obj) { JsonObject parent = obj.getAsJsonObject("options"); @@ -390,17 +404,6 @@ public static Integer getSeekTo(@NotNull JsonObject obj) { if ((elm = options.get("seek_to")) != null && elm.isJsonPrimitive()) return elm.getAsInt(); else return null; } - - @NotNull - public static JsonArray getPages(@NotNull JsonObject obj) { - JsonObject context = getContext(obj); - return context.getAsJsonArray("pages"); - } - - @NotNull - public static JsonObject getMetadata(@NotNull JsonObject obj) { - return getContext(obj).getAsJsonObject("metadata"); - } } public static class CommandBody { diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java new file mode 100644 index 00000000..c6f06b9e --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -0,0 +1,370 @@ +package xyz.gianlu.librespot.core; + +import com.spotify.metadata.Metadata; +import org.apache.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.AsyncWorker; +import xyz.gianlu.librespot.common.Utils; +import xyz.gianlu.librespot.connectstate.DeviceStateHandler; +import xyz.gianlu.librespot.crypto.Packet; +import xyz.gianlu.librespot.mercury.MercuryClient; +import xyz.gianlu.librespot.mercury.RawMercuryRequest; +import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.player.StateWrapper; +import xyz.gianlu.librespot.player.playback.PlayerMetrics; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * @author Gianlu + */ +public final class EventService implements Closeable { + private final static Logger LOGGER = Logger.getLogger(EventService.class); + private final Session session; + private final AsyncWorker asyncWorker; + private long trackTransitionIncremental = 1; + + EventService(@NotNull Session session) { + this.session = session; + this.asyncWorker = new AsyncWorker<>("event-service-sender", eventBuilder -> { + try { + byte[] body = eventBuilder.toArray(); + MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() + .setUri("hm://event-service/v1/events").setMethod("POST") + .addUserField("Accept-Language", "en") + .addUserField("X-ClientTimeStamp", String.valueOf(TimeProvider.currentTimeMillis())) + .addPayloadPart(body) + .build()); + + LOGGER.debug(String.format("Event sent. {body: %s, result: %d}", EventBuilder.toString(body), resp.statusCode)); + } catch (IOException ex) { + LOGGER.error("Failed sending event: " + eventBuilder, ex); + } + }); + } + + private void sendEvent(@NotNull EventBuilder builder) { + asyncWorker.submit(builder); + } + + /** + * Reports our language. + * + * @param lang The language (2 letters code) + */ + public void language(@NotNull String lang) { + EventBuilder event = new EventBuilder(Type.LANGUAGE); + event.append(lang); + sendEvent(event); + } + + private void trackTransition(@NotNull PlaybackMetrics metrics, @NotNull DeviceStateHandler device) { + int when = metrics.lastValue(); + + try { + session.send(Packet.Type.TrackEndedTime, ByteBuffer.allocate(5).put((byte) 1).putInt(when).array()); + } catch (IOException ex) { + LOGGER.error("Failed sending TrackEndedTime packet.", ex); + } + + EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); + event.append(String.valueOf(trackTransitionIncremental++)); + event.append(session.deviceId()); + event.append(metrics.playbackId).append("00000000000000000000000000000000"); + event.append(metrics.sourceStart).append(metrics.startedHow()); + event.append(metrics.sourceEnd).append(metrics.endedHow()); + event.append(String.valueOf(metrics.player.decodedLength)).append(String.valueOf(metrics.player.size)); + event.append(String.valueOf(when)).append(String.valueOf(when)); + event.append(String.valueOf(metrics.player.duration)); + event.append('0').append(String.valueOf(metrics.player.fadeOverlap)).append('0').append('0'); + event.append(metrics.firstValue() == 0 ? '0' : '1').append(String.valueOf(metrics.firstValue())); + event.append('0').append("-1").append("context"); + event.append(String.valueOf(metrics.player.contentMetrics.audioKeyTime)).append('0'); + event.append(metrics.player.contentMetrics.preloadedAudioKey ? '1' : '0').append('0').append('0').append('0'); + event.append(String.valueOf(when)).append(String.valueOf(when)); + event.append('0').append(String.valueOf(metrics.player.bitrate)); + event.append(metrics.contextUri).append(metrics.player.encoding); + event.append(metrics.id.hexId()).append(""); + event.append('0').append(String.valueOf(metrics.timestamp)).append('0'); + event.append("context").append(metrics.referrerIdentifier).append(metrics.featureVersion); + event.append("com.spotify").append(metrics.player.transition).append("none"); + event.append(device.getLastCommandSentByDeviceId()).append("na").append("none"); + sendEvent(event); + } + + public void trackPlayed(@NotNull PlaybackMetrics metrics, @NotNull DeviceStateHandler device) { + if (metrics.player == null || metrics.player.contentMetrics == null) { + LOGGER.warn("Did not send event because of missing metrics: " + metrics.playbackId); + return; + } + + trackTransition(metrics, device); + + + EventBuilder event = new EventBuilder(Type.CDN_REQUEST); + event.append(metrics.player.contentMetrics.fileId).append(metrics.playbackId); + event.append('0').append('0').append('0').append('0').append('0').append('0'); + event.append(String.valueOf(metrics.player.decodedLength)).append(String.valueOf(metrics.player.size)); + event.append("music").append("-1").append("-1").append("-1").append("-1.000000"); + event.append("-1").append("-1.000000").append("-1").append("-1").append("-1").append("-1.000000"); + event.append("-1").append("-1").append("-1").append("-1").append("-1.000000").append("-1"); + event.append("0.000000").append("-1.000000").append("").append("").append("unknown"); + event.append('0').append('0').append('0').append('0').append('0'); + event.append("interactive").append('0').append(String.valueOf(metrics.player.bitrate)).append('0').append('0'); + sendEvent(event); + + + EventBuilder anotherEvent = new EventBuilder(Type.TRACK_PLAYED); + anotherEvent.append(metrics.playbackId).append(metrics.id.toSpotifyUri()); + anotherEvent.append('0').append(metrics.intervalsToSend()); + sendEvent(anotherEvent); + } + + /** + * Reports that a new playback ID is being used. + * + * @param state The current player state + * @param playbackId The new playback ID + */ + public void newPlaybackId(@NotNull StateWrapper state, @NotNull String playbackId) { + EventBuilder event = new EventBuilder(Type.NEW_PLAYBACK_ID); + event.append(playbackId).append(state.getSessionId()).append(String.valueOf(TimeProvider.currentTimeMillis())); + sendEvent(event); + } + + /** + * Reports that a new session ID is being used. + * + * @param sessionId The session ID + * @param state The current player state + */ + public void newSessionId(@NotNull String sessionId, @NotNull StateWrapper state) { + String contextUri = state.getContextUri(); + + EventBuilder event = new EventBuilder(Type.NEW_SESSION_ID); + event.append(sessionId); + event.append(contextUri); + event.append(contextUri); + event.append(String.valueOf(TimeProvider.currentTimeMillis())); + event.append("").append(String.valueOf(state.getContextSize())); + event.append(state.getContextUrl()); + sendEvent(event); + } + + /** + * Reports that a file ID has been fetched for some content. + * + * @param id The content {@link PlayableId} + * @param file The {@link com.spotify.metadata.Metadata.AudioFile} for this content + */ + public void fetchedFileId(@NotNull PlayableId id, @NotNull Metadata.AudioFile file) { + EventBuilder event = new EventBuilder(Type.FETCHED_FILE_ID); + event.append('2').append('2'); + event.append(Utils.bytesToHex(file.getFileId()).toLowerCase()); + event.append(id.toSpotifyUri()); + event.append('1').append('2').append('2'); + sendEvent(event); + } + + @Override + public void close() { + asyncWorker.close(); + + try { + asyncWorker.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } + } + + private enum Type { + LANGUAGE("812", "1"), FETCHED_FILE_ID("274", "3"), NEW_SESSION_ID("557", "3"), + NEW_PLAYBACK_ID("558", "1"), TRACK_PLAYED("372", "1"), TRACK_TRANSITION("12", "37"), + CDN_REQUEST("10", "20"); + + private final String id; + private final String unknown; + + Type(@NotNull String id, @NotNull String unknown) { + this.id = id; + this.unknown = unknown; + } + } + + private static class EventBuilder { + private final ByteArrayOutputStream body = new ByteArrayOutputStream(256); + + EventBuilder(@NotNull Type type) { + appendNoDelimiter(type.id); + append(type.unknown); + } + + @NotNull + static String toString(byte[] body) { + StringBuilder result = new StringBuilder(); + for (byte b : body) { + if (b == 0x09) result.append('|'); + else result.append((char) b); + } + + return result.toString(); + } + + private void appendNoDelimiter(@Nullable String str) { + if (str == null) str = ""; + + try { + body.write(str.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + @NotNull + EventBuilder append(char c) { + body.write(0x09); + body.write(c); + return this; + } + + @NotNull + EventBuilder append(@Nullable String str) { + body.write(0x09); + appendNoDelimiter(str); + return this; + } + + @Override + public String toString() { + return "EventBuilder{" + toString(toArray()) + '}'; + } + + @NotNull + byte[] toArray() { + return body.toByteArray(); + } + } + + public static class PlaybackMetrics { + public final PlayableId id; + final List intervals = new ArrayList<>(10); + final String playbackId; + final String featureVersion; + final String referrerIdentifier; + final String contextUri; + final long timestamp; + PlayerMetrics player = null; + Reason reasonStart = null; + String sourceStart = null; + Reason reasonEnd = null; + String sourceEnd = null; + Interval lastInterval = null; + + public PlaybackMetrics(@NotNull PlayableId id, @NotNull String playbackId, @NotNull StateWrapper state) { + this.id = id; + this.playbackId = playbackId; + this.contextUri = state.getContextUri(); + this.featureVersion = state.getPlayOrigin().getFeatureVersion(); + this.referrerIdentifier = state.getPlayOrigin().getReferrerIdentifier(); + this.timestamp = TimeProvider.currentTimeMillis(); + } + + @NotNull + String intervalsToSend() { + StringBuilder builder = new StringBuilder(); + builder.append('['); + + boolean first = true; + for (Interval interval : intervals) { + if (interval.begin == -1 || interval.end == -1) + continue; + + if (!first) builder.append(','); + builder.append('[').append(interval.begin).append(',').append(interval.end).append(']'); + first = false; + } + + builder.append(']'); + return builder.toString(); + } + + int firstValue() { + if (intervals.isEmpty()) return 0; + else return intervals.get(0).begin; + } + + int lastValue() { + if (intervals.isEmpty()) return player == null ? 0 : player.duration; + else return intervals.get(intervals.size() - 1).end; + } + + public void startInterval(int begin) { + lastInterval = new Interval(begin); + } + + public void endInterval(int end) { + if (lastInterval == null) return; + if (lastInterval.begin == end) { + lastInterval = null; + return; + } + + lastInterval.end = end; + intervals.add(lastInterval); + lastInterval = null; + } + + public void startedHow(@NotNull EventService.PlaybackMetrics.Reason reason, @Nullable String origin) { + reasonStart = reason; + sourceStart = origin == null ? "unknown" : origin; + } + + public void endedHow(@NotNull EventService.PlaybackMetrics.Reason reason, @Nullable String origin) { + reasonEnd = reason; + sourceEnd = origin == null ? "unknown" : origin; + } + + @Nullable + String startedHow() { + return reasonStart == null ? null : reasonStart.val; + } + + @Nullable + String endedHow() { + return reasonEnd == null ? null : reasonEnd.val; + } + + public void update(@Nullable PlayerMetrics playerMetrics) { + player = playerMetrics; + } + + public enum Reason { + TRACK_DONE("trackdone"), TRACK_ERROR("trackerror"), + FORWARD_BTN("fwdbtn"), BACK_BTN("backbtn"), + END_PLAY("endplay"), PLAY_BTN("playbtn"), CLICK_ROW("clickrow"), + LOGOUT("logout"), APP_LOAD("appload"), REMOTE("remote"); + + final String val; + + Reason(@NotNull String val) { + this.val = val; + } + } + + private static class Interval { + private final int begin; + private int end = -1; + + Interval(int begin) { + this.begin = begin; + } + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/core/Session.java b/core/src/main/java/xyz/gianlu/librespot/core/Session.java index 55c27edc..2b5cde28 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -109,6 +109,7 @@ public final class Session implements Closeable, SubListener { private ApiClient api; private SearchManager search; private PlayableContentFeeder contentFeeder; + private EventService eventService; private String countryCode = null; private volatile boolean closed = false; private volatile ScheduledFuture scheduledReconnect = null; @@ -335,10 +336,11 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I api = new ApiClient(this); cdnManager = new CdnManager(this); contentFeeder = new PlayableContentFeeder(this); - cacheManager = new CacheManager(inner.configuration); + cacheManager = new CacheManager(conf()); dealer = new DealerClient(this); - player = new Player(inner.configuration, this); + player = new Player(conf(), this); search = new SearchManager(this); + eventService = new EventService(this); authLock.set(false); authLock.notifyAll(); @@ -347,6 +349,7 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I dealer.connect(); player.initState(); TimeProvider.init(this); + eventService.language(conf().preferredLocale()); LOGGER.info(String.format("Authenticated as %s!", apWelcome.getCanonicalUsername())); mercuryClient.interestedIn("spotify:user:attributes:update", this); @@ -389,7 +392,7 @@ private void authenticatePartial(@NotNull Authentication.LoginCredentials creden ByteBuffer preferredLocale = ByteBuffer.allocate(18 + 5); preferredLocale.put((byte) 0x0).put((byte) 0x0).put((byte) 0x10).put((byte) 0x0).put((byte) 0x02); preferredLocale.put("preferred-locale".getBytes()); - preferredLocale.put(inner.configuration.preferredLocale().getBytes()); + preferredLocale.put(conf().preferredLocale().getBytes()); sendUnchecked(Packet.Type.PreferredLocale, preferredLocale.array()); if (removeLock) { @@ -426,11 +429,6 @@ public void close() throws IOException { LOGGER.info(String.format("Closing session. {deviceId: %s} ", inner.deviceId)); scheduler.shutdownNow(); - if (receiver != null) { - receiver.stop(); - receiver = null; - } - if (player != null) { player.close(); player = null; @@ -451,11 +449,21 @@ public void close() throws IOException { channelManager = null; } + if (eventService != null) { + eventService.close(); + eventService = null; + } + if (mercuryClient != null) { mercuryClient.close(); mercuryClient = null; } + if (receiver != null) { + receiver.stop(); + receiver = null; + } + executorService.shutdown(); if (conn != null) { @@ -506,7 +514,7 @@ public void send(Packet.Type cmd, byte[] payload) throws IOException { try { authLock.wait(); } catch (InterruptedException ex) { - throw new IllegalStateException(ex); + return; } } @@ -591,6 +599,13 @@ public SearchManager search() { return search; } + @NotNull + public EventService eventService() { + waitAuthLock(); + if (eventService == null) throw new IllegalStateException("Session isn't authenticated!"); + return eventService; + } + @NotNull public String username() { return apWelcome().getCanonicalUsername(); diff --git a/core/src/main/java/xyz/gianlu/librespot/crypto/Packet.java b/core/src/main/java/xyz/gianlu/librespot/crypto/Packet.java index fb52fa90..6fdfb79f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/crypto/Packet.java +++ b/core/src/main/java/xyz/gianlu/librespot/crypto/Packet.java @@ -51,6 +51,7 @@ public enum Type { MercurySub(0xb3), MercuryUnsub(0xb4), MercuryEvent(0xb5), + TrackEndedTime(0x82), UnknownData_AllZeros(0x1f), PreferredLocale(0x74), Unknown_0x4f(0x4f), diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java b/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java index d199cd4b..237250a1 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java +++ b/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java @@ -69,11 +69,15 @@ public Response sendSync(@NotNull RawMercuryRequest request) throws IOException SyncCallback callback = new SyncCallback(); int seq = send(request, callback); - Response resp = callback.waitResponse(); - if (resp == null) - throw new IOException(String.format("Request timeout out, %d passed, yet no response. {seq: %d}", MERCURY_REQUEST_TIMEOUT, seq)); + try { + Response resp = callback.waitResponse(); + if (resp == null) + throw new IOException(String.format("Request timeout out, %d passed, yet no response. {seq: %d}", MERCURY_REQUEST_TIMEOUT, seq)); - return resp; + return resp; + } catch (InterruptedException ex) { + throw new IOException(ex); // Wrapping to avoid having to dispatch yet another exception down the call stack + } } @NotNull @@ -245,7 +249,7 @@ public void close() { try { if (listener.isSub) unsubscribe(listener.uri); else notInterested(listener.listener); - } catch (IOException | PubSubException ex) { + } catch (IOException | MercuryException ex) { LOGGER.debug("Failed unsubscribing.", ex); } } @@ -292,14 +296,10 @@ public void response(@NotNull Response response) { } @Nullable - Response waitResponse() { + Response waitResponse() throws InterruptedException { synchronized (reference) { - try { - reference.wait(MERCURY_REQUEST_TIMEOUT); - return reference.get(); - } catch (InterruptedException ex) { - throw new IllegalStateException(ex); - } + reference.wait(MERCURY_REQUEST_TIMEOUT); + return reference.get(); } } } diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java b/core/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java index bf0406ae..5b2854fa 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java +++ b/core/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java @@ -86,7 +86,7 @@ public Builder addPayloadPart(@NotNull byte[] part) { return this; } - public Builder addProtobufPayload(@NotNull AbstractMessageLite msg) { + public Builder addProtobufPayload(@NotNull AbstractMessageLite msg) { return addPayloadPart(msg.toByteArray()); } diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/EpisodeId.java b/core/src/main/java/xyz/gianlu/librespot/mercury/model/EpisodeId.java index b6a654fa..dbb6dee4 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/EpisodeId.java +++ b/core/src/main/java/xyz/gianlu/librespot/mercury/model/EpisodeId.java @@ -65,4 +65,17 @@ public byte[] getGid() { public String toString() { return "EpisodeId{" + toSpotifyUri() + '}'; } + + @Override + public int hashCode() { + return hexId.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EpisodeId episodeId = (EpisodeId) o; + return hexId.equals(episodeId.hexId); + } } diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlayableId.java b/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlayableId.java index 1110673b..a63bc41b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlayableId.java +++ b/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlayableId.java @@ -68,6 +68,8 @@ static PlayableId from(@NotNull Metadata.Episode episode) { @NotNull String toString(); + int hashCode(); + @NotNull byte[] getGid(); @NotNull String hexId(); diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/TrackId.java b/core/src/main/java/xyz/gianlu/librespot/mercury/model/TrackId.java index f2ee3fe5..08fc952d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/TrackId.java +++ b/core/src/main/java/xyz/gianlu/librespot/mercury/model/TrackId.java @@ -65,4 +65,17 @@ public byte[] getGid() { public String toString() { return "TrackId{" + toSpotifyUri() + '}'; } + + @Override + public int hashCode() { + return hexId.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TrackId trackId = (TrackId) o; + return hexId.equals(trackId.hexId); + } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/Player.java b/core/src/main/java/xyz/gianlu/librespot/player/Player.java index c512bcd0..647d0846 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -13,53 +13,52 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Range; import xyz.gianlu.librespot.common.NameThreadFactory; -import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.connectstate.DeviceStateHandler; import xyz.gianlu.librespot.connectstate.DeviceStateHandler.PlayCommandHelper; +import xyz.gianlu.librespot.core.EventService.PlaybackMetrics; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.MercuryRequests; import xyz.gianlu.librespot.mercury.model.ImageId; import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.player.PlayerRunner.PushToMixerReason; -import xyz.gianlu.librespot.player.PlayerRunner.TrackHandler; import xyz.gianlu.librespot.player.StateWrapper.NextPlayable; import xyz.gianlu.librespot.player.codecs.AudioQuality; import xyz.gianlu.librespot.player.codecs.Codec; import xyz.gianlu.librespot.player.contexts.AbsSpotifyContext; +import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; +import xyz.gianlu.librespot.player.mixing.AudioSink; +import xyz.gianlu.librespot.player.playback.PlayerMetrics; +import xyz.gianlu.librespot.player.playback.PlayerSession; import java.io.Closeable; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.*; /** * @author Gianlu */ -public class Player implements Closeable, DeviceStateHandler.Listener, PlayerRunner.Listener { +public class Player implements Closeable, DeviceStateHandler.Listener, PlayerSession.Listener, AudioSink.Listener { + public static final int VOLUME_MAX = 65536; private static final Logger LOGGER = Logger.getLogger(Player.class); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(new NameThreadFactory((r) -> "release-line-scheduler-" + r.hashCode())); private final Session session; private final Configuration conf; - private final PlayerRunner runner; private final EventsDispatcher events; + private final AudioSink sink; private StateWrapper state; - private TrackHandler trackHandler; - private TrackHandler crossfadeHandler; - private TrackHandler preloadTrackHandler; + private PlayerSession playerSession; private ScheduledFuture releaseLineFuture = null; + private Map metrics = new HashMap<>(5); public Player(@NotNull Player.Configuration conf, @NotNull Session session) { this.conf = conf; this.session = session; this.events = new EventsDispatcher(conf); - new Thread(runner = new PlayerRunner(session, conf, this), "player-runner-" + runner.hashCode()).start(); + this.sink = new AudioSink(conf, this); } public void addEventsListener(@NotNull EventsListener listener) { @@ -70,6 +69,11 @@ public void removeEventsListener(@NotNull EventsListener listener) { events.listeners.remove(listener); } + + // ================================ // + // =========== Commands =========== // + // ================================ // + public void initState() { this.state = new StateWrapper(session); state.addListener(this); @@ -77,7 +81,7 @@ public void initState() { public void volumeUp() { if (state == null) return; - setVolume(Math.min(PlayerRunner.VOLUME_MAX, state.getVolume() + oneVolumeStep())); + setVolume(Math.min(Player.VOLUME_MAX, state.getVolume() + oneVolumeStep())); } public void volumeDown() { @@ -86,11 +90,11 @@ public void volumeDown() { } private int oneVolumeStep() { - return PlayerRunner.VOLUME_MAX / conf.volumeSteps(); + return Player.VOLUME_MAX / conf.volumeSteps(); } public void setVolume(int val) { - if (val < 0 || val > PlayerRunner.VOLUME_MAX) + if (val < 0 || val > VOLUME_MAX) throw new IllegalArgumentException(String.valueOf(val)); events.volumeChanged(val); @@ -108,86 +112,130 @@ public void pause() { } public void next() { - handleNext(null); + handleSkipNext(null, TransitionInfo.skippedNext(state)); } public void previous() { - handlePrev(); + handleSkipPrev(); } public void load(@NotNull String uri, boolean play) { try { - state.loadContext(uri); + String sessionId = state.loadContext(uri); + events.contextChanged(); + + loadSession(sessionId, play, true); } catch (IOException | MercuryClient.MercuryException ex) { LOGGER.fatal("Failed loading context!", ex); - panicState(); - return; + panicState(null); } catch (AbsSpotifyContext.UnsupportedContextException ex) { LOGGER.fatal("Cannot play local tracks!", ex); - panicState(); - return; + panicState(null); } + } + + + // ================================ // + // ======== Internal state ======== // + // ================================ // + + /** + * Enter a "panic" state where everything is stopped. + * + * @param reason Why we entered this state + */ + private void panicState(@Nullable PlaybackMetrics.Reason reason) { + sink.pause(true); + state.setState(false, false, false); + state.updated(); - events.contextChanged(); - loadTrack(play, PushToMixerReason.None); + if (reason == null) { + metrics = null; + } else if (playerSession != null) { + endMetrics(playerSession.currentPlaybackId(), reason, playerSession.currentMetrics(), state.getPosition()); + } } - private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { - LOGGER.debug(String.format("Loading context (transfer), uri: %s", cmd.getCurrentSession().getContext().getUri())); + /** + * Loads a new session by creating a new {@link PlayerSession}. Will also trigger {@link Player#loadTrack(boolean, TransitionInfo)}. + * + * @param sessionId The new session ID + * @param play Whether the playback should start immediately + */ + private void loadSession(@NotNull String sessionId, boolean play, boolean withSkip) { + TransitionInfo trans = TransitionInfo.contextChange(state, withSkip); - try { - state.transfer(cmd); - } catch (IOException | MercuryClient.MercuryException ex) { - LOGGER.fatal("Failed loading context!", ex); - panicState(); - return; - } catch (AbsSpotifyContext.UnsupportedContextException ex) { - LOGGER.fatal("Cannot play local tracks!", ex); - panicState(); - return; + if (playerSession != null) { + endMetrics(playerSession.currentPlaybackId(), trans.endedReason, playerSession.currentMetrics(), trans.endedWhen); + + playerSession.close(); + playerSession = null; } - events.contextChanged(); - loadTrack(!cmd.getPlayback().getIsPaused(), PushToMixerReason.None); + playerSession = new PlayerSession(session, sink, sessionId, this); + session.eventService().newSessionId(sessionId, state); + + loadTrack(play, trans); } - private void handleLoad(@NotNull JsonObject obj) { - LOGGER.debug(String.format("Loading context (play), uri: %s", PlayCommandHelper.getContextUri(obj))); + /** + * Loads a new track and pauses/resumes the sink accordingly. + * + * This is called only to change track due to an external command (user interaction). + * + * @param play Whether the playback should start immediately + * @param trans A {@link TransitionInfo} object containing information about this track change + */ + private void loadTrack(boolean play, @NotNull TransitionInfo trans) { + endMetrics(playerSession.currentPlaybackId(), trans.endedReason, playerSession.currentMetrics(), trans.endedWhen); - try { - state.load(obj); - } catch (IOException | MercuryClient.MercuryException ex) { - LOGGER.fatal("Failed loading context!", ex); - panicState(); - return; - } catch (AbsSpotifyContext.UnsupportedContextException ex) { - LOGGER.fatal("Cannot play local tracks!", ex); - panicState(); - return; - } + String playbackId = playerSession.play(state.getCurrentPlayableOrThrow(), state.getPosition(), trans.startedReason); + state.setPlaybackId(playbackId); + session.eventService().newPlaybackId(state, playbackId); + + if (play) sink.resume(); + else sink.pause(false); - events.contextChanged(); + state.setState(true, !play, true); + state.updated(); + + events.trackChanged(); + if (play) events.playbackResumed(); + else events.playbackPaused(); + + startMetrics(playbackId, trans.startedReason, state.getPosition()); - Boolean paused = PlayCommandHelper.isInitiallyPaused(obj); - if (paused == null) paused = true; - loadTrack(!paused, PushToMixerReason.None); + if (releaseLineFuture != null) { + releaseLineFuture.cancel(true); + releaseLineFuture = null; + } } @Override public void ready() { } + @Override + public void volumeChanged() { + sink.setVolume(state.getVolume()); + } + + @Override + public void notActive() { + events.inactiveSession(false); + sink.pause(true); + } + @Override public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull DeviceStateHandler.CommandBody data) throws InvalidProtocolBufferException { LOGGER.debug("Received command: " + endpoint); switch (endpoint) { case Play: - System.out.println(data.obj()); - handleLoad(data.obj()); + handlePlay(data.obj()); break; case Transfer: - transferState(TransferStateOuterClass.TransferState.parseFrom(data.data())); + handleTransferState(TransferStateOuterClass.TransferState.parseFrom(data.data())); break; case Resume: handleResume(); @@ -199,10 +247,10 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi handleSeek(data.valueInt()); break; case SkipNext: - handleNext(data.obj()); + handleSkipNext(data.obj(), TransitionInfo.skippedNext(state)); break; case SkipPrev: - handlePrev(); + handleSkipPrev(); break; case SetRepeatingContext: state.setRepeatingContext(data.valueBool()); @@ -217,10 +265,10 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi state.updated(); break; case AddToQueue: - addToQueue(data.obj()); + handleAddToQueue(data.obj()); break; case SetQueue: - setQueue(data.obj()); + handleSetQueue(data.obj()); break; case UpdateContext: state.updateContext(PlayCommandHelper.getContext(data.obj())); @@ -232,263 +280,57 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi } } - @Override - public void volumeChanged() { - runner.setVolume(state.getVolume()); - } - - @Override - public void notActive() { - events.inactiveSession(false); - if (runner.stopAndRelease()) LOGGER.debug("Released line due to inactivity."); - } - - @Override - public void startedLoading(@NotNull TrackHandler handler) { - if (handler == trackHandler) { - state.setBuffering(true); - state.updated(); - } - } - - private void updateStateWithHandler() { - Metadata.Episode episode; - Metadata.Track track; - if ((track = trackHandler.track()) != null) state.enrichWithMetadata(track); - else if ((episode = trackHandler.episode()) != null) state.enrichWithMetadata(episode); - else LOGGER.warn("Couldn't update metadata!"); - - events.metadataAvailable(); - } - - @Override - public void finishedLoading(@NotNull TrackHandler handler, int pos) { - if (handler == trackHandler) { - state.setBuffering(false); - - updateStateWithHandler(); - - state.setPosition(pos); - state.updated(); - } else if (handler == preloadTrackHandler) { - LOGGER.trace("Preloaded track is ready."); - } - } - - @Override - public void mixerError(@NotNull Exception ex) { - LOGGER.fatal("Mixer error!", ex); - panicState(); - } - - @Override - public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, @NotNull Exception ex) { - if (handler == trackHandler) { - if (ex instanceof ContentRestrictedException) { - LOGGER.fatal(String.format("Can't load track (content restricted), gid: %s", Utils.bytesToHex(id.getGid())), ex); - handleNext(null); - return; - } - - LOGGER.fatal(String.format("Failed loading track, gid: %s", Utils.bytesToHex(id.getGid())), ex); - panicState(); - } else if (handler == preloadTrackHandler) { - LOGGER.warn("Preloaded track loading failed!", ex); - preloadTrackHandler = null; - } - } - - @Override - public void endOfTrack(@NotNull TrackHandler handler, @Nullable String uri, boolean fadeOut) { - if (handler == trackHandler) { - LOGGER.trace(String.format("End of track. Proceeding with next. {fadeOut: %b}", fadeOut)); - handleNext(null); - - PlayableId curr; - if (uri != null && (curr = state.getCurrentPlayable()) != null && !curr.toSpotifyUri().equals(uri)) - LOGGER.warn(String.format("Fade out track URI is different from next track URI! {next: %s, crossfade: %s}", curr, uri)); - } - } - - @Override - public void preloadNextTrack(@NotNull TrackHandler handler) { - if (handler == trackHandler) { - PlayableId next = state.nextPlayableDoNotSet(); - if (next != null) { - preloadTrackHandler = runner.load(next, 0); - LOGGER.trace("Started next track preload, gid: " + Utils.bytesToHex(next.getGid())); - } - } - } - - @Override - public void crossfadeNextTrack(@NotNull TrackHandler handler, @Nullable String uri) { - if (handler == trackHandler) { - PlayableId next = state.nextPlayableDoNotSet(); - if (next == null) return; - - if (uri != null && !next.toSpotifyUri().equals(uri)) - LOGGER.warn(String.format("Fade out track URI is different from next track URI! {next: %s, crossfade: %s}", next, uri)); - - if (preloadTrackHandler != null && preloadTrackHandler.isPlayable(next)) { - crossfadeHandler = preloadTrackHandler; - } else { - LOGGER.warn("Did not preload crossfade track. That's bad."); - crossfadeHandler = runner.load(next, 0); - } - - crossfadeHandler.waitReady(); - LOGGER.info("Crossfading to next track."); - crossfadeHandler.pushToMixer(PushToMixerReason.Fade); - } - } - - @Override - public void abortedCrossfade(@NotNull TrackHandler handler) { - if (handler == trackHandler) { - if (crossfadeHandler == preloadTrackHandler) preloadTrackHandler = null; - crossfadeHandler = null; - - LOGGER.trace("Aborted crossfade."); - } - } - - @Override - public @NotNull Map metadataFor(@NotNull PlayableId id) { - return state.metadataFor(id); - } - - @Override - public void finishedSeek(@NotNull TrackHandler handler) { - if (handler == trackHandler) state.updated(); - } - - @Override - public void playbackError(@NotNull TrackHandler handler, @NotNull Exception ex) { - if (handler == trackHandler) { - if (ex instanceof AbsChunkedInputStream.ChunkException) - LOGGER.fatal("Failed retrieving chunk, playback failed!", ex); - else - LOGGER.fatal("Playback error!", ex); - - panicState(); - } else if (handler == preloadTrackHandler) { - LOGGER.warn("Preloaded track loading failed!", ex); - preloadTrackHandler = null; - } - } - - @Override - public void playbackHalted(@NotNull TrackHandler handler, int chunk) { - if (handler == trackHandler) { - LOGGER.debug(String.format("Playback halted on retrieving chunk %d.", chunk)); + private void handlePlay(@NotNull JsonObject obj) { + LOGGER.debug(String.format("Loading context (play), uri: %s", PlayCommandHelper.getContextUri(obj))); - state.setBuffering(true); - state.updated(); + try { + String sessionId = state.load(obj); + events.contextChanged(); - events.playbackHaltStateChanged(true); + Boolean paused = PlayCommandHelper.isInitiallyPaused(obj); + if (paused == null) paused = true; + loadSession(sessionId, !paused, PlayCommandHelper.willSkipToSomething(obj)); + } catch (IOException | MercuryClient.MercuryException ex) { + LOGGER.fatal("Failed loading context!", ex); + panicState(null); + } catch (AbsSpotifyContext.UnsupportedContextException ex) { + LOGGER.fatal("Cannot play local tracks!", ex); + panicState(null); } } - @Override - public void playbackResumedFromHalt(@NotNull TrackHandler handler, int chunk, long diff) { - if (handler == trackHandler) { - LOGGER.debug(String.format("Playback resumed, chunk %d retrieved, took %dms.", chunk, diff)); - - state.setPosition(state.getPosition() - diff); - state.setBuffering(false); - state.updated(); + private void handleTransferState(@NotNull TransferStateOuterClass.TransferState cmd) { + LOGGER.debug(String.format("Loading context (transfer), uri: %s", cmd.getCurrentSession().getContext().getUri())); - events.playbackHaltStateChanged(false); + try { + String sessionId = state.transfer(cmd); + events.contextChanged(); + loadSession(sessionId, !cmd.getPlayback().getIsPaused(), true); + } catch (IOException | MercuryClient.MercuryException ex) { + LOGGER.fatal("Failed loading context!", ex); + panicState(null); + } catch (AbsSpotifyContext.UnsupportedContextException ex) { + LOGGER.fatal("Cannot play local tracks!", ex); + panicState(null); } } private void handleSeek(int pos) { + playerSession.seekCurrent(pos); state.setPosition(pos); - if (trackHandler != null) trackHandler.seek(pos); events.seeked(pos); - } - - private void panicState() { - runner.stopMixer(); - state.setState(false, false, false); - state.updated(); - } - - private void loadTrack(boolean play, @NotNull PushToMixerReason reason) { - if (trackHandler != null) { - trackHandler.stop(); - trackHandler = null; - } - - PlayableId id = state.getCurrentPlayableOrThrow(); - if (crossfadeHandler != null && crossfadeHandler.isPlayable(id)) { - trackHandler = crossfadeHandler; - if (preloadTrackHandler == crossfadeHandler) preloadTrackHandler = null; - crossfadeHandler = null; - - if (trackHandler.isReady()) { - state.setState(true, !play, false); - updateStateWithHandler(); - - try { - state.setPosition(trackHandler.time()); - } catch (Codec.CannotGetTimeException ignored) { - } - } else { - state.setState(true, !play, true); - } - - state.updated(); - events.trackChanged(); - - if (!play) { - runner.pauseMixer(); - events.playbackPaused(); - } else { - events.playbackResumed(); - } - } else { - if (preloadTrackHandler != null && preloadTrackHandler.isPlayable(id)) { - trackHandler = preloadTrackHandler; - preloadTrackHandler = null; - - if (trackHandler.isReady()) { - state.setState(true, !play, false); - updateStateWithHandler(); - - trackHandler.seek(state.getPosition()); - } else { - state.setState(true, !play, true); - } - } else { - state.setState(true, !play, true); - trackHandler = runner.load(id, state.getPosition()); - } - - state.updated(); - events.trackChanged(); - if (play) { - trackHandler.pushToMixer(reason); - runner.playMixer(); - events.playbackResumed(); - } else { - events.playbackPaused(); - } - } - - if (releaseLineFuture != null) { - releaseLineFuture.cancel(true); - releaseLineFuture = null; + PlaybackMetrics pm = metrics.get(playerSession.currentPlaybackId()); + if (pm != null) { + pm.endInterval(state.getPosition()); + pm.startInterval(pos); } } private void handleResume() { if (state.isPaused()) { state.setState(true, false, false); - if (!trackHandler.isInMixer()) trackHandler.pushToMixer(PushToMixerReason.None); - runner.playMixer(); + sink.resume(); state.updated(); events.playbackResumed(); @@ -503,10 +345,11 @@ private void handleResume() { private void handlePause() { if (state.isPlaying()) { state.setState(true, true, false); - runner.pauseMixer(); + sink.pause(false); try { - state.setPosition(trackHandler.time()); + if (playerSession != null) + state.setPosition(playerSession.currentTime()); } catch (Codec.CannotGetTimeException ignored) { } @@ -518,12 +361,12 @@ private void handlePause() { if (!state.isPaused()) return; events.inactiveSession(true); - if (runner.pauseAndRelease()) LOGGER.debug("Released line after a period of inactivity."); + sink.pause(true); }, conf.releaseLineDelay(), TimeUnit.SECONDS); } } - private void setQueue(@NotNull JsonObject obj) { + private void handleSetQueue(@NotNull JsonObject obj) { List prevTracks = PlayCommandHelper.getPrevTracks(obj); List nextTracks = PlayCommandHelper.getNextTracks(obj); if (prevTracks == null && nextTracks == null) throw new IllegalArgumentException(); @@ -532,7 +375,7 @@ private void setQueue(@NotNull JsonObject obj) { state.updated(); } - private void addToQueue(@NotNull JsonObject obj) { + private void handleAddToQueue(@NotNull JsonObject obj) { ContextTrack track = PlayCommandHelper.getTrack(obj); if (track == null) throw new IllegalArgumentException(); @@ -540,13 +383,13 @@ private void addToQueue(@NotNull JsonObject obj) { state.updated(); } - private void handleNext(@Nullable JsonObject obj) { + private void handleSkipNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) { ContextTrack track = null; if (obj != null) track = PlayCommandHelper.getTrack(obj); if (track != null) { state.skipTo(track); - loadTrack(true, PushToMixerReason.Next); + loadTrack(true, TransitionInfo.skipTo(state)); return; } @@ -557,19 +400,41 @@ private void handleNext(@Nullable JsonObject obj) { } if (next.isOk()) { + trans.endedWhen = state.getPosition(); + state.setPosition(0); - loadTrack(next == NextPlayable.OK_PLAY || next == NextPlayable.OK_REPEAT, PushToMixerReason.Next); + loadTrack(next == NextPlayable.OK_PLAY || next == NextPlayable.OK_REPEAT, trans); } else { LOGGER.fatal("Failed loading next song: " + next); - panicState(); + panicState(PlaybackMetrics.Reason.END_PLAY); + } + } + + private void handleSkipPrev() { + if (state.getPosition() < 3000) { + StateWrapper.PreviousPlayable prev = state.previousPlayable(); + if (prev.isOk()) { + state.setPosition(0); + loadTrack(true, TransitionInfo.skippedPrev(state)); + } else { + LOGGER.fatal("Failed loading previous song: " + prev); + panicState(null); + } + } else { + playerSession.seekCurrent(0); + state.setPosition(0); + state.updated(); } } + /** + * Tries to load some additional content to play and starts playing if successful. + */ private void loadAutoplay() { String context = state.getContextUri(); if (context == null) { LOGGER.fatal("Cannot load autoplay with null context!"); - panicState(); + panicState(null); return; } @@ -579,20 +444,20 @@ private void loadAutoplay() { MercuryClient.Response resp = session.mercury().sendSync(MercuryRequests.autoplayQuery(context)); if (resp.statusCode == 200) { String newContext = resp.payload.readIntoString(0); - state.loadContext(newContext); + String sessionId = state.loadContext(newContext); state.setContextMetadata("context_description", contextDesc); events.contextChanged(); - loadTrack(true, PushToMixerReason.None); + loadSession(sessionId, true, false); LOGGER.debug(String.format("Loading context for autoplay, uri: %s", newContext)); } else if (resp.statusCode == 204) { MercuryRequests.StationsWrapper station = session.mercury().sendSync(MercuryRequests.getStationFor(context)); - state.loadContextWithTracks(station.uri(), station.tracks()); + String sessionId = state.loadContextWithTracks(station.uri(), station.tracks()); state.setContextMetadata("context_description", contextDesc); events.contextChanged(); - loadTrack(true, PushToMixerReason.None); + loadSession(sessionId, true, false); LOGGER.debug(String.format("Loading context for autoplay (using radio-apollo), uri: %s", state.getContextUri())); } else { @@ -604,96 +469,161 @@ private void loadAutoplay() { } } catch (IOException | MercuryClient.MercuryException ex) { LOGGER.fatal("Failed loading autoplay station!", ex); - panicState(); + panicState(null); } catch (AbsSpotifyContext.UnsupportedContextException ex) { LOGGER.fatal("Cannot play local tracks!", ex); - panicState(); + panicState(null); } } - private void handlePrev() { - if (state.getPosition() < 3000) { - StateWrapper.PreviousPlayable prev = state.previousPlayable(); - if (prev.isOk()) { - state.setPosition(0); - loadTrack(true, PushToMixerReason.Prev); - } else { - LOGGER.fatal("Failed loading previous song: " + prev); - panicState(); - } - } else { - state.setPosition(0); - if (trackHandler != null) trackHandler.seek(0); + + // ================================ // + // =========== Metrics ============ // + // ================================ // + + private void startMetrics(String playbackId, @NotNull PlaybackMetrics.Reason reason, int pos) { + PlaybackMetrics pm = new PlaybackMetrics(state.getCurrentPlayableOrThrow(), playbackId, state); + pm.startedHow(reason, state.getPlayOrigin().getFeatureIdentifier()); + pm.startInterval(pos); + metrics.put(playbackId, pm); + } + + private void endMetrics(String playbackId, @NotNull PlaybackMetrics.Reason reason, @Nullable PlayerMetrics playerMetrics, int when) { + if (playbackId == null) return; + + PlaybackMetrics pm = metrics.remove(playbackId); + if (pm == null) return; + + pm.endedHow(reason, state.getPlayOrigin().getFeatureIdentifier()); + pm.endInterval(when); + pm.update(playerMetrics); + session.eventService().trackPlayed(pm, state.device()); + } + + + // ================================ // + // ======== Player events ========= // + // ================================ // + + @Override + public void startedLoading() { + if (state.isPlaying()) { + state.setBuffering(true); state.updated(); } } @Override - public void close() throws IOException { - if (trackHandler != null) { - trackHandler.close(); - trackHandler = null; - } + public void finishedLoading(@NotNull TrackOrEpisode metadata) { + state.enrichWithMetadata(metadata); + state.setBuffering(false); + state.updated(); - if (crossfadeHandler != null) { - crossfadeHandler.close(); - crossfadeHandler = null; - } + events.metadataAvailable(); + } - if (preloadTrackHandler != null) { - preloadTrackHandler.close(); - preloadTrackHandler = null; + @Override + public void sinkError(@NotNull Exception ex) { + LOGGER.fatal("Sink error!", ex); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); + } + + @Override + public void loadingError(@NotNull Exception ex) { + if (ex instanceof ContentRestrictedException) { + LOGGER.error("Can't load track (content restricted).", ex); + } else { + LOGGER.fatal("Failed loading track.", ex); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); } + } - state.close(); - events.listeners.clear(); + @Override + public void playbackError(@NotNull Exception ex) { + if (ex instanceof AbsChunkedInputStream.ChunkException) + LOGGER.fatal("Failed retrieving chunk, playback failed!", ex); + else + LOGGER.fatal("Playback error!", ex); - runner.close(); - if (state != null) state.removeListener(this); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); + } - scheduler.shutdown(); - events.close(); + @Override + public void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode metadata, int pos, @NotNull PlaybackMetrics.Reason startedReason) { + if (metadata != null) state.enrichWithMetadata(metadata); + state.setPlaybackId(playbackId); + state.setPosition(pos); + state.updated(); + + events.trackChanged(); + + session.eventService().newPlaybackId(state, playbackId); + startMetrics(playbackId, startedReason, pos); } - @Nullable - public Metadata.Track currentTrack() { - return trackHandler == null ? null : trackHandler.track(); + @Override + public void trackPlayed(@NotNull String playbackId, @NotNull PlaybackMetrics.Reason endReason, @NotNull PlayerMetrics playerMetrics, int when) { + endMetrics(playbackId, endReason, playerMetrics, when); } - @Nullable - public Metadata.Episode currentEpisode() { - return trackHandler == null ? null : trackHandler.episode(); + @Override + public void playbackHalted(int chunk) { + LOGGER.debug(String.format("Playback halted on retrieving chunk %d.", chunk)); + state.setBuffering(true); + state.updated(); + + events.playbackHaltStateChanged(true); + } + + @Override + public void playbackResumedFromHalt(int chunk, long diff) { + LOGGER.debug(String.format("Playback resumed, chunk %d retrieved, took %dms.", chunk, diff)); + state.setPosition(state.getPosition() - diff); + state.setBuffering(false); + state.updated(); + + events.playbackHaltStateChanged(false); + } + + + // ================================ // + // =========== Getters ============ // + // ================================ // + + /** + * @return Whether the player is active + */ + public boolean isActive() { + return state.isActive(); } + /** + * @return The metadata for the current entry or {@code null} if not available. + */ @Nullable - public PlayableId currentPlayableId() { - return state.getCurrentPlayable(); + public TrackOrEpisode currentMetadata() { + return playerSession == null ? null : playerSession.currentMetadata(); } + /** + * @return The cover image bytes for the current entry or {@code null} if not available. + * @throws IOException If an error occurred while downloading the image + */ @Nullable public byte[] currentCoverImage() throws IOException { - Metadata.Track track = currentTrack(); - Metadata.Episode episode = currentEpisode(); - Metadata.ImageGroup group = null; - if (track != null) { - if (track.hasAlbum() && track.getAlbum().hasCoverGroup()) - group = track.getAlbum().getCoverGroup(); - } else if (episode != null) { - if (episode.hasCoverImage()) - group = episode.getCoverImage(); - } else { - throw new IllegalStateException(); - } + TrackOrEpisode metadata = currentMetadata(); + if (metadata == null) return null; ImageId image = null; + Metadata.ImageGroup group = metadata.getCoverImage(); if (group == null) { PlayableId id = state.getCurrentPlayable(); if (id == null) return null; - Map metadata = state.metadataFor(id); + Map map = state.metadataFor(id); for (String key : ImageId.IMAGE_SIZES_URLS) { - if (metadata.containsKey(key)) { - image = ImageId.fromUri(metadata.get(key)); + if (map.containsKey(key)) { + image = ImageId.fromUri(map.get(key)); break; } } @@ -715,22 +645,84 @@ public byte[] currentCoverImage() throws IOException { } } + /** + * @return The current content in the state + * @throws IllegalStateException If there is no current content set + */ + @Override + public @NotNull PlayableId currentPlayable() { + return state.getCurrentPlayableOrThrow(); + } + + /** + * MUST not be called manually. This is used internally by {@link PlayerSession}. + */ + @Override + public @Nullable PlayableId nextPlayable() { + NextPlayable next = state.nextPlayable(conf); + if (next == NextPlayable.AUTOPLAY) { + loadAutoplay(); + return null; + } + + if (next.isOk()) { + if (next != NextPlayable.OK_PLAY && next != NextPlayable.OK_REPEAT) + sink.pause(false); + + return state.getCurrentPlayableOrThrow(); + } else { + LOGGER.fatal("Failed loading next song: " + next); + panicState(PlaybackMetrics.Reason.END_PLAY); + return null; + } + } + + /** + * @return The next content that will be played. + */ + @Override + public @Nullable PlayableId nextPlayableDoNotSet() { + return state.nextPlayableDoNotSet(); + } + + /** + * @param playable The content + * @return A map containing the metadata associated with this content + */ + @Override + public @NotNull Map metadataFor(@NotNull PlayableId playable) { + return state.metadataFor(playable); + } + /** * @return The current position of the player or {@code -1} if unavailable (most likely if it's playing an episode). */ public long time() { try { - return trackHandler == null ? 0 : trackHandler.time(); + return playerSession == null ? -1 : playerSession.currentTime(); } catch (Codec.CannotGetTimeException ex) { return -1; } } - /** - * @return Whether the player is active - */ - public boolean isActive() { - return state.isActive(); + + // ================================ // + // ============ Close! ============ // + // ================================ // + + @Override + public void close() { + if (playerSession != null) + endMetrics(playerSession.currentPlaybackId(), PlaybackMetrics.Reason.LOGOUT, playerSession.currentMetrics(), state.getPosition()); + + state.close(); + events.listeners.clear(); + + sink.close(); + if (state != null) state.removeListener(this); + + scheduler.shutdown(); + events.close(); } public interface Configuration { @@ -773,7 +765,7 @@ public interface Configuration { public interface EventsListener { void onContextChanged(@NotNull String newUri); - void onTrackChanged(@NotNull PlayableId id, @Nullable Metadata.Track track, @Nullable Metadata.Episode episode); + void onTrackChanged(@NotNull PlayableId id, @Nullable TrackOrEpisode metadata); void onPlaybackPaused(long trackTime); @@ -781,7 +773,7 @@ public interface EventsListener { void onTrackSeeked(long trackTime); - void onMetadataAvailable(@Nullable Metadata.Track track, @Nullable Metadata.Episode episode); + void onMetadataAvailable(@NotNull TrackOrEpisode metadata); void onPlaybackHaltStateChanged(boolean halted, long trackTime); @@ -790,6 +782,71 @@ public interface EventsListener { void onVolumeChanged(@Range(from = 0, to = 1) float volume); } + /** + * Stores information about the transition between two tracks. + */ + private static class TransitionInfo { + /** + * How the next track started + */ + final PlaybackMetrics.Reason startedReason; + + /** + * How the previous track ended + */ + final PlaybackMetrics.Reason endedReason; + + /** + * When the previous track ended + */ + int endedWhen = -1; + + private TransitionInfo(@NotNull PlaybackMetrics.Reason endedReason, @NotNull PlaybackMetrics.Reason startedReason) { + this.startedReason = startedReason; + this.endedReason = endedReason; + } + + /** + * Context changed. + */ + @NotNull + static TransitionInfo contextChange(@NotNull StateWrapper state, boolean withSkip) { + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.END_PLAY, withSkip ? PlaybackMetrics.Reason.CLICK_ROW : PlaybackMetrics.Reason.PLAY_BTN); + if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); + return trans; + } + + /** + * Skipping to another track in the same context. + */ + @NotNull + static TransitionInfo skipTo(@NotNull StateWrapper state) { + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.END_PLAY, PlaybackMetrics.Reason.CLICK_ROW); + if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); + return trans; + } + + /** + * Skipping to previous track. + */ + @NotNull + static TransitionInfo skippedPrev(@NotNull StateWrapper state) { + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.BACK_BTN, PlaybackMetrics.Reason.BACK_BTN); + if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); + return trans; + } + + /** + * Skipping to next track. + */ + @NotNull + static TransitionInfo skippedNext(@NotNull StateWrapper state) { + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.FORWARD_BTN, PlaybackMetrics.Reason.FORWARD_BTN); + if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); + return trans; + } + } + private static class MetadataPipe { private static final String TYPE_SSNC = "73736e63"; private static final String TYPE_CORE = "636f7265"; @@ -866,45 +923,27 @@ private void sendImage() { } private void sendProgress() { - PlayableId id = state.getCurrentPlayable(); - if (id == null || trackHandler == null || !trackHandler.isPlayable(id)) return; - - Metadata.Track track; - Metadata.Episode episode; - int duration = -1; - if ((track = trackHandler.track()) != null) { - if (track.hasDuration()) duration = track.getDuration(); - } else if ((episode = trackHandler.episode()) != null) { - if (episode.hasDuration()) duration = episode.getDuration(); - } + TrackOrEpisode metadata = currentMetadata(); + if (metadata == null) return; - if (duration == -1) - return; - - String data = String.format("1/%.0f/%.0f", state.getPosition() * PlayerRunner.OUTPUT_FORMAT.getSampleRate() / 1000 + 1, - duration * PlayerRunner.OUTPUT_FORMAT.getSampleRate() / 1000 + 1); + String data = String.format("1/%.0f/%.0f", state.getPosition() * AudioSink.OUTPUT_FORMAT.getSampleRate() / 1000 + 1, + metadata.duration() * AudioSink.OUTPUT_FORMAT.getSampleRate() / 1000 + 1); metadataPipe.safeSend(MetadataPipe.TYPE_SSNC, MetadataPipe.CODE_PRGR, data); } private void sendTrackInfo() { - Metadata.Track track = currentTrack(); - Metadata.Episode episode = currentEpisode(); - if (track == null && episode == null) return; - - String title = track != null ? track.getName() : episode.getName(); - metadataPipe.safeSend(MetadataPipe.TYPE_CORE, MetadataPipe.CODE_MINM, title); + TrackOrEpisode metadata = currentMetadata(); + if (metadata == null) return; - String album = track != null ? track.getAlbum().getName() : episode.getShow().getName(); - metadataPipe.safeSend(MetadataPipe.TYPE_CORE, MetadataPipe.CODE_ASAL, album); - - String artist = track != null ? Utils.artistsToString(track.getArtistList()) : episode.getShow().getPublisher(); - metadataPipe.safeSend(MetadataPipe.TYPE_CORE, MetadataPipe.CODE_ASAR, artist); + metadataPipe.safeSend(MetadataPipe.TYPE_CORE, MetadataPipe.CODE_MINM, metadata.getName()); + metadataPipe.safeSend(MetadataPipe.TYPE_CORE, MetadataPipe.CODE_ASAL, metadata.getAlbumName()); + metadataPipe.safeSend(MetadataPipe.TYPE_CORE, MetadataPipe.CODE_ASAR, metadata.getArtist()); } private void sendVolume(int value) { float xmlValue; if (value == 0) xmlValue = 144.0f; - else xmlValue = (value - PlayerRunner.VOLUME_MAX) * 30.0f / (PlayerRunner.VOLUME_MAX - 1); + else xmlValue = (value - Player.VOLUME_MAX) * 30.0f / (Player.VOLUME_MAX - 1); String volData = String.format("%.2f,0.00,0.00,0.00", xmlValue); metadataPipe.safeSend(MetadataPipe.TYPE_SSNC, MetadataPipe.CODE_PVOL, volData); } @@ -940,18 +979,9 @@ void trackChanged() { PlayableId id = state.getCurrentPlayable(); if (id == null) return; - Metadata.Track track; - Metadata.Episode episode; - if (trackHandler != null && trackHandler.isPlayable(id)) { - track = trackHandler.track(); - episode = trackHandler.episode(); - } else { - track = null; - episode = null; - } - + TrackOrEpisode metadata = currentMetadata(); for (EventsListener l : new ArrayList<>(listeners)) - executorService.execute(() -> l.onTrackChanged(id, track, episode)); + executorService.execute(() -> l.onTrackChanged(id, metadata)); } void seeked(int pos) { @@ -961,8 +991,8 @@ void seeked(int pos) { if (metadataPipe.enabled()) executorService.execute(this::sendProgress); } - void volumeChanged(@Range(from = 0, to = PlayerRunner.VOLUME_MAX) int value) { - float volume = (float) value / PlayerRunner.VOLUME_MAX; + void volumeChanged(@Range(from = 0, to = Player.VOLUME_MAX) int value) { + float volume = (float) value / Player.VOLUME_MAX; for (EventsListener l : new ArrayList<>(listeners)) executorService.execute(() -> l.onVolumeChanged(volume)); @@ -971,14 +1001,11 @@ void volumeChanged(@Range(from = 0, to = PlayerRunner.VOLUME_MAX) int value) { } void metadataAvailable() { - if (trackHandler == null) return; - - Metadata.Track track = trackHandler.track(); - Metadata.Episode episode = trackHandler.episode(); - if (track == null && episode == null) return; + TrackOrEpisode metadata = currentMetadata(); + if (metadata == null) return; for (EventsListener l : new ArrayList<>(listeners)) - executorService.execute(() -> l.onMetadataAvailable(track, episode)); + executorService.execute(() -> l.onMetadataAvailable(metadata)); if (metadataPipe.enabled()) { executorService.execute(() -> { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java deleted file mode 100644 index fbf260e5..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java +++ /dev/null @@ -1,804 +0,0 @@ -package xyz.gianlu.librespot.player; - -import com.spotify.metadata.Metadata; -import javazoom.jl.decoder.BitstreamException; -import org.apache.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.common.AsyncWorker; -import xyz.gianlu.librespot.common.NameThreadFactory; -import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.model.EpisodeId; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.mercury.model.TrackId; -import xyz.gianlu.librespot.player.codecs.Codec; -import xyz.gianlu.librespot.player.codecs.Mp3Codec; -import xyz.gianlu.librespot.player.codecs.VorbisCodec; -import xyz.gianlu.librespot.player.codecs.VorbisOnlyAudioQuality; -import xyz.gianlu.librespot.player.crossfade.CrossfadeController; -import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; -import xyz.gianlu.librespot.player.feeders.cdn.CdnManager; -import xyz.gianlu.librespot.player.mixing.LineHelper; -import xyz.gianlu.librespot.player.mixing.MixingLine; - -import javax.sound.sampled.AudioFormat; -import javax.sound.sampled.FloatControl; -import javax.sound.sampled.LineUnavailableException; -import javax.sound.sampled.SourceDataLine; -import java.io.*; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * @author Gianlu - */ -public class PlayerRunner implements Runnable, Closeable { - public static final int VOLUME_MAX = 65536; - public static final AudioFormat OUTPUT_FORMAT = new AudioFormat(44100, 16, 2, true, false); - private static final Logger LOGGER = Logger.getLogger(PlayerRunner.class); - private static final AtomicInteger IDS = new AtomicInteger(0); - private final Session session; - private final Player.Configuration conf; - private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory((r) -> "player-runner-writer-" + r.hashCode())); - private final Listener listener; - private final Map loadedTracks = new HashMap<>(3); - private final AsyncWorker asyncWorker; - private final Object pauseLock = new Object(); - private final Output output; - private final MixingLine mixing = new MixingLine(OUTPUT_FORMAT); - private volatile boolean closed = false; - private volatile boolean paused = true; - private TrackHandler firstHandler = null; - private TrackHandler secondHandler = null; - - PlayerRunner(@NotNull Session session, @NotNull Player.Configuration conf, @NotNull Listener listener) { - this.session = session; - this.conf = conf; - this.listener = listener; - - switch (conf.output()) { - case MIXER: - try { - output = new Output(Output.Type.MIXER, mixing, conf, null, null); - } catch (LineUnavailableException ex) { - throw new IllegalStateException("Failed opening line!", ex); - } - break; - case PIPE: - File pipe = conf.outputPipe(); - if (pipe == null || !pipe.exists() || !pipe.canWrite()) - throw new IllegalArgumentException("Invalid pipe file: " + pipe); - - try { - output = new Output(Output.Type.PIPE, mixing, conf, pipe, null); - } catch (LineUnavailableException ignored) { - throw new IllegalStateException(); // Cannot be thrown - } - break; - case STDOUT: - try { - output = new Output(Output.Type.STREAM, mixing, conf, null, System.out); - } catch (LineUnavailableException ignored) { - throw new IllegalStateException(); // Cannot be thrown - } - break; - default: - throw new IllegalArgumentException("Unknown output: " + conf.output()); - } - - output.setVolume(conf.initialVolume()); - - asyncWorker = new AsyncWorker<>("player-runner-looper", this::handleCommand); - } - - /** - * Pauses the mixer and then releases the {@link javax.sound.sampled.Line} if acquired. - * - * @return Whether the line was released. - */ - public boolean pauseAndRelease() { - pauseMixer(); - while (!paused) { - synchronized (pauseLock) { - try { - pauseLock.wait(100); - } catch (InterruptedException ignored) { - } - } - } - - return output.releaseLine(); - } - - /** - * Stops the mixer and then releases the {@link javax.sound.sampled.Line} if acquired. - */ - public boolean stopAndRelease() { - stopMixer(); - while (!paused) { - synchronized (pauseLock) { - try { - pauseLock.wait(100); - } catch (InterruptedException ignored) { - } - } - } - - return output.releaseLine(); - } - - private void sendCommand(@NotNull Command command, int id, Object... args) { - asyncWorker.submit(new CommandBundle(command, id, args)); - } - - @NotNull - TrackHandler load(@NotNull PlayableId playable, int pos) { - int id = IDS.getAndIncrement(); - TrackHandler handler = new TrackHandler(id, playable); - sendCommand(Command.Load, id, handler, pos); - return handler; - } - - @Override - public void run() { - LOGGER.trace("PlayerRunner is starting"); - - byte[] buffer = new byte[Codec.BUFFER_SIZE * 2]; - - boolean started = false; - while (!closed) { - if (paused) { - output.stop(); - started = false; - - synchronized (pauseLock) { - try { - pauseLock.wait(); - } catch (InterruptedException ex) { - throw new IllegalStateException(ex); - } - } - } else { - if (!started) started = output.start(); - - try { - int count = mixing.read(buffer); - output.write(buffer, count); - } catch (IOException | LineUnavailableException ex) { - if (closed) break; - - paused = true; - listener.mixerError(ex); - } - } - } - - try { - output.drain(); - output.close(); - } catch (IOException ignored) { - } - - LOGGER.trace("PlayerRunner is shutting down"); - } - - @Override - public void close() throws IOException { - asyncWorker.submit(new CommandBundle(Command.TerminateMixer, -1)); - - closed = true; - synchronized (pauseLock) { - pauseLock.notifyAll(); - } - - for (TrackHandler handler : loadedTracks.values()) - handler.close(); - - loadedTracks.clear(); - - firstHandler = null; - secondHandler = null; - - output.close(); - asyncWorker.close(); - executorService.shutdown(); - } - - void pauseMixer() { - sendCommand(Command.PauseMixer, -1); - } - - void playMixer() { - sendCommand(Command.PlayMixer, -1); - } - - void stopMixer() { - sendCommand(Command.StopMixer, -1); - } - - void setVolume(int volume) { - output.setVolume(volume); - } - - /** - * Handles a command from {@link PlayerRunner#asyncWorker}, MUST not be called manually. - * - * @param cmd The command - */ - private void handleCommand(@NotNull CommandBundle cmd) { - TrackHandler handler; - switch (cmd.cmd) { - case Load: - handler = (TrackHandler) cmd.args[0]; - loadedTracks.put(cmd.id, handler); - - try { - handler.load((int) cmd.args[1]); - } catch (IOException | LineHelper.MixerException | Codec.CodecException | ContentRestrictedException | MercuryClient.MercuryException | CdnManager.CdnException ex) { - listener.loadingError(handler, handler.playable, ex); - handler.close(); - } - break; - case PushToMixer: - handler = loadedTracks.get(cmd.id); - if (handler == null) break; - - if (firstHandler == null) { - firstHandler = handler; - firstHandler.setOut(mixing.firstOut()); - } else if (secondHandler == null) { - secondHandler = handler; - secondHandler.setOut(mixing.secondOut()); - } else { - throw new IllegalStateException(); - } - - executorService.execute(handler); - break; - case RemoveFromMixer: - handler = loadedTracks.get(cmd.id); - if (handler == null) break; - handler.clearOut(); - break; - case Stop: - handler = loadedTracks.get(cmd.id); - if (handler != null) handler.close(); - break; - case Seek: - handler = loadedTracks.get(cmd.id); - if (handler == null) break; - - if (!handler.isReady()) - handler.waitReady(); - - boolean shouldAbortCrossfade = false; - if (handler == firstHandler && secondHandler != null) { - secondHandler.close(); - secondHandler = null; - shouldAbortCrossfade = true; - } else if (handler == secondHandler && firstHandler != null) { - firstHandler.close(); - firstHandler = null; - shouldAbortCrossfade = true; - } - - if (handler.codec != null) { - if (shouldAbortCrossfade) handler.abortCrossfade(); - - output.flush(); - if (handler.out != null) handler.out.stream().emptyBuffer(); - handler.codec.seek((Integer) cmd.args[0]); - } - - listener.finishedSeek(handler); - break; - case PlayMixer: - paused = false; - synchronized (pauseLock) { - pauseLock.notifyAll(); - } - break; - case PauseMixer: - paused = true; - break; - case StopMixer: - paused = true; - for (TrackHandler h : new ArrayList<>(loadedTracks.values())) - h.close(); - - firstHandler = null; - secondHandler = null; - loadedTracks.clear(); - - synchronized (pauseLock) { - pauseLock.notifyAll(); - } - break; - case TerminateMixer: - return; - default: - throw new IllegalArgumentException("Unknown command: " + cmd.cmd); - } - } - - public enum Command { - PlayMixer, PauseMixer, StopMixer, TerminateMixer, - Load, PushToMixer, Stop, Seek, RemoveFromMixer - } - - public enum PushToMixerReason { - None, Next, - Prev, Fade - } - - public interface Listener { - void startedLoading(@NotNull TrackHandler handler); - - void finishedLoading(@NotNull TrackHandler handler, int pos); - - void mixerError(@NotNull Exception ex); - - void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId track, @NotNull Exception ex); - - void endOfTrack(@NotNull TrackHandler handler, @Nullable String uri, boolean fadeOut); - - void preloadNextTrack(@NotNull TrackHandler handler); - - void playbackError(@NotNull TrackHandler handler, @NotNull Exception ex); - - void playbackHalted(@NotNull TrackHandler handler, int chunk); - - void playbackResumedFromHalt(@NotNull TrackHandler handler, int chunk, long diff); - - void crossfadeNextTrack(@NotNull TrackHandler handler, @Nullable String uri); - - @NotNull - Map metadataFor(@NotNull PlayableId id); - - void finishedSeek(@NotNull TrackHandler handler); - - void abortedCrossfade(@NotNull TrackHandler handler); - } - - private static class Output implements Closeable { - private final File pipe; - private final MixingLine mixing; - private final Player.Configuration conf; - private final Type type; - private SourceDataLine line; - private OutputStream out; - private int lastVolume = -1; - - Output(@NotNull Type type, @NotNull MixingLine mixing, @NotNull Player.Configuration conf, @Nullable File pipe, @Nullable OutputStream out) throws LineUnavailableException { - this.conf = conf; - this.mixing = mixing; - this.type = type; - this.pipe = pipe; - this.out = out; - - switch (type) { - case MIXER: - acquireLine(); - break; - case PIPE: - if (pipe == null) throw new IllegalArgumentException(); - break; - case STREAM: - if (out == null) throw new IllegalArgumentException(); - break; - default: - throw new IllegalArgumentException(String.valueOf(type)); - } - } - - private static float calcLogarithmic(int val) { - return (float) (Math.log10((double) val / VOLUME_MAX) * 20f); - } - - private void acquireLine() throws LineUnavailableException { - if (line != null) return; - - line = LineHelper.getLineFor(conf, OUTPUT_FORMAT); - line.open(OUTPUT_FORMAT); - - if (lastVolume != -1) setVolume(lastVolume); - } - - void flush() { - if (line != null) line.flush(); - } - - void stop() { - if (line != null) line.stop(); - } - - boolean start() { - if (line != null) { - line.start(); - return true; - } - - return false; - } - - void write(byte[] buffer, int len) throws IOException, LineUnavailableException { - if (type == Type.MIXER) { - acquireLine(); - line.write(buffer, 0, len); - } else if (type == Type.PIPE) { - if (out == null) { - if (!pipe.exists()) { - try { - Process p = new ProcessBuilder().command("mkfifo " + pipe.getAbsolutePath()) - .redirectError(ProcessBuilder.Redirect.INHERIT).start(); - p.waitFor(); - if (p.exitValue() != 0) - LOGGER.warn(String.format("Failed creating pipe! {exit: %d}", p.exitValue())); - else - LOGGER.info("Created pipe: " + pipe); - } catch (InterruptedException ex) { - throw new IllegalStateException(ex); - } - } - - out = new FileOutputStream(pipe, true); - } - - out.write(buffer, 0, len); - } else if (type == Type.STREAM) { - out.write(buffer, 0, len); - } else { - throw new IllegalStateException(); - } - } - - void drain() { - if (line != null) line.drain(); - } - - @Override - public void close() throws IOException { - if (line != null) line.close(); - if (out != null) out.close(); - } - - @NotNull - public AudioFormat getFormat() { - if (line != null) return line.getFormat(); - else return OUTPUT_FORMAT; - } - - void setVolume(int volume) { - lastVolume = volume; - - if (line != null) { - FloatControl ctrl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); - if (ctrl != null) { - mixing.setGlobalGain(1); - ctrl.setValue(calcLogarithmic(volume)); - return; - } - } - - // Cannot set volume through line - mixing.setGlobalGain(((float) volume) / VOLUME_MAX); - } - - boolean releaseLine() { - if (line == null) return false; - - line.close(); - line = null; - return true; - } - - enum Type { - MIXER, PIPE, STREAM - } - } - - private static class CommandBundle { - private final Command cmd; - private final int id; - private final Object[] args; - - private CommandBundle(@NotNull Command cmd, int id, Object... args) { - this.cmd = cmd; - this.id = id; - this.args = args; - } - } - - public class TrackHandler implements HaltListener, Closeable, Runnable { - private final int id; - private final PlayableId playable; - private final Object writeLock = new Object(); - private final Object readyLock = new Object(); - private Metadata.Track track; - private Metadata.Episode episode; - private CrossfadeController crossfade; - private long playbackHaltedAt = 0; - private volatile boolean calledPreload = false; - private Codec codec; - private volatile boolean closed = false; - private MixingLine.MixingOutput out; - private PushToMixerReason pushReason = PushToMixerReason.None; - private volatile boolean calledCrossfade = false; - private boolean abortCrossfade = false; - - TrackHandler(int id, @NotNull PlayableId playable) { - this.id = id; - this.playable = playable; - } - - private void setOut(@NotNull MixingLine.MixingOutput out) { - this.out = out; - out.toggle(true); - - synchronized (writeLock) { - writeLock.notifyAll(); - } - } - - private void setGain(float gain) { - if (out == null) return; - out.gain(gain); - } - - private void clearOut() { - if (out == null) return; - out.toggle(false); - out.clear(); - out = null; - } - - boolean isReady() { - if (closed) throw new IllegalStateException("The handler is closed!"); - return codec != null; - } - - void waitReady() { - synchronized (readyLock) { - if (codec == null) { - try { - readyLock.wait(); - } catch (InterruptedException ex) { - throw new IllegalStateException(ex); - } - } - } - } - - private void load(int pos) throws Codec.CodecException, IOException, LineHelper.MixerException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { - listener.startedLoading(this); - - PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(conf.preferredQuality()), this); - track = stream.track; - episode = stream.episode; - - int duration; - if (playable instanceof EpisodeId && stream.episode != null) { - duration = stream.episode.getDuration(); - LOGGER.info(String.format("Loaded episode, name: '%s', gid: %s", stream.episode.getName(), Utils.bytesToHex(playable.getGid()))); - } else if (playable instanceof TrackId && stream.track != null) { - duration = stream.track.getDuration(); - LOGGER.info(String.format("Loaded track, name: '%s', artists: '%s', gid: %s", stream.track.getName(), Utils.artistsToString(stream.track.getArtistList()), Utils.bytesToHex(playable.getGid()))); - } else { - throw new IllegalArgumentException(); - } - - try { - Map metadata = listener.metadataFor(playable); - crossfade = new CrossfadeController(duration, metadata, conf); - } catch (IllegalArgumentException ex) { - LOGGER.warn("Failed retrieving metadata for " + playable); - crossfade = new CrossfadeController(duration, conf); - } - - switch (stream.in.codec()) { - case VORBIS: - codec = new VorbisCodec(output.getFormat(), stream.in, stream.normalizationData, conf, duration); - break; - case MP3: - try { - codec = new Mp3Codec(output.getFormat(), stream.in, stream.normalizationData, conf, duration); - } catch (BitstreamException ex) { - throw new IOException(ex); - } - break; - default: - throw new IllegalArgumentException("Unknown codec: " + stream.in.codec()); - } - - LOGGER.trace(String.format("Loaded codec (%s), fileId: %s, format: %s", stream.in.codec(), stream.in.describe(), codec.getAudioFormat())); - - if (pos == 0 && crossfade.fadeInEnabled()) pos = crossfade.fadeInStartTime(); - codec.seek(pos); - - synchronized (readyLock) { - readyLock.notifyAll(); - } - - listener.finishedLoading(this, pos); - } - - @Override - public void streamReadHalted(int chunk, long time) { - playbackHaltedAt = time; - listener.playbackHalted(this, chunk); - } - - @Override - public void streamReadResumed(int chunk, long time) { - listener.playbackResumedFromHalt(this, chunk, time - playbackHaltedAt); - } - - @Nullable - public Metadata.Track track() { - return track; - } - - @Nullable - public Metadata.Episode episode() { - return episode; - } - - void stop() { - sendCommand(Command.Stop, id); - } - - @Override - public void close() { - if (closed) return; - - loadedTracks.remove(id); - if (firstHandler == this) firstHandler = null; - else if (secondHandler == this) secondHandler = null; - - closed = true; - - synchronized (writeLock) { - writeLock.notifyAll(); - } - - try { - clearOut(); - if (codec != null) codec.close(); - codec = null; - } catch (IOException ignored) { - } - } - - boolean isPlayable(@NotNull PlayableId id) { - return !closed && playable.toSpotifyUri().equals(id.toSpotifyUri()); - } - - void seek(int pos) { - sendCommand(Command.Seek, id, pos); - } - - void pushToMixer(@NotNull PushToMixerReason reason) { - pushReason = reason; - sendCommand(Command.PushToMixer, id); - } - - void removeFromMixer() { - sendCommand(Command.RemoveFromMixer, id); - } - - int time() throws Codec.CannotGetTimeException { - return codec == null ? 0 : Math.max(0, codec.time()); - } - - private void shouldPreload() { - if (calledPreload || codec == null) return; - - if (!conf.preloadEnabled() && !crossfade.fadeOutEnabled()) { // Force preload if crossfade is enabled - calledPreload = true; - return; - } - - try { - if (codec.time() + TimeUnit.SECONDS.toMillis(15) >= crossfade.fadeOutStartTime()) { - listener.preloadNextTrack(this); - calledPreload = true; - } - } catch (Codec.CannotGetTimeException ex) { - calledPreload = true; - } - } - - private boolean updateCrossfade() { - int pos; - try { - pos = codec.time(); - } catch (Codec.CannotGetTimeException ex) { - calledCrossfade = true; - return false; - } - - if (abortCrossfade && calledCrossfade) { - abortCrossfade = false; - listener.abortedCrossfade(this); - return false; - } - - if (!calledCrossfade && crossfade.shouldStartNextTrack(pos)) { - listener.crossfadeNextTrack(this, crossfade.fadeOutUri()); - calledCrossfade = true; - } else if (crossfade.shouldStop(pos)) { - return true; - } else { - setGain(crossfade.getGain(pos)); - } - - return false; - } - - void abortCrossfade() { - abortCrossfade = true; - } - - @Override - public void run() { - LOGGER.trace("PlayerRunner.TrackHandler is starting"); - - waitReady(); - - int seekTo = -1; - if (pushReason == PushToMixerReason.Fade) { - seekTo = crossfade.fadeInStartTime(); - } - - if (seekTo != -1) codec.seek(seekTo); - - while (!closed) { - if (out == null) { - synchronized (writeLock) { - try { - writeLock.wait(); - } catch (InterruptedException ex) { - throw new IllegalStateException(ex); - } - } - } - - if (closed) return; - if (out == null) break; - - shouldPreload(); - if (updateCrossfade()) { - listener.endOfTrack(this, crossfade.fadeOutUri(), true); - break; - } - - try { - if (codec.readSome(out.stream()) == -1) { - listener.endOfTrack(this, crossfade.fadeOutUri(), false); - break; - } - } catch (IOException | Codec.CodecException ex) { - if (closed) return; - - listener.playbackError(this, ex); - break; - } - } - - close(); - - LOGGER.trace("PlayerRunner.TrackHandler is shutting down"); - } - - boolean isInMixer() { - return firstHandler == this || secondHandler == this; - } - } -} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java index d4d86875..c2758907 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -79,6 +79,7 @@ public class StateWrapper implements DeviceStateHandler.Listener, DealerClient.M @NotNull private static PlayerState.Builder initState(@NotNull PlayerState.Builder builder) { return builder.setPlaybackSpeed(1.0) + .clearSessionId().clearPlaybackId() .setSuppressions(Suppressions.newBuilder().build()) .setContextRestrictions(Restrictions.newBuilder().build()) .setOptions(ContextPlayerOptions.newBuilder() @@ -90,6 +91,21 @@ private static PlayerState.Builder initState(@NotNull PlayerState.Builder builde .setIsPlaying(false); } + @NotNull + public static String generatePlaybackId(@NotNull Random random) { + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + bytes[0] = 1; + return Utils.bytesToHex(bytes).toLowerCase(); + } + + @NotNull + private static String generateSessionId(@NotNull Random random) { + byte[] bytes = new byte[16]; + random.nextBytes(bytes); + return Base64.getEncoder().withoutPadding().encodeToString(bytes); + } + private boolean shouldPlay(@NotNull ContextTrack track) { if (!PlayableId.isSupported(track.getUri()) || !PlayableId.shouldPlay(track)) return false; @@ -104,10 +120,6 @@ boolean isActive() { return device.isActive(); } - void setBuffering(boolean buffering) { - setState(state.getIsPlaying(), state.getIsPaused(), buffering); - } - synchronized void setState(boolean playing, boolean paused, boolean buffering) { if (paused && !playing) throw new IllegalStateException(); else if (buffering && !playing) throw new IllegalStateException(); @@ -123,6 +135,10 @@ boolean isPaused() { return state.getIsPlaying() && state.getIsPaused(); } + void setBuffering(boolean buffering) { + setState(state.getIsPlaying(), state.getIsPaused(), buffering); + } + private boolean isShufflingContext() { return state.getOptions().getShufflingContext(); } @@ -156,11 +172,21 @@ void setRepeatingTrack(boolean value) { state.getOptionsBuilder().setRepeatingTrack(value && context.restrictions.can(Action.REPEAT_TRACK)); } + @NotNull + public DeviceStateHandler device() { + return device; + } + @Nullable - String getContextUri() { + public String getContextUri() { return state.getContextUri(); } + @Nullable + public String getContextUrl() { + return state.getContextUrl(); + } + private void loadTransforming() { if (tracksKeeper == null) throw new IllegalStateException(); @@ -192,7 +218,8 @@ private void loadTransforming() { } } - private void setContext(@NotNull String uri) { + @NotNull + private String setContext(@NotNull String uri) { this.context = AbsSpotifyContext.from(uri); this.state.setContextUri(uri); @@ -210,9 +237,12 @@ private void setContext(@NotNull String uri) { this.tracksKeeper = new TracksKeeper(); this.device.setIsActive(true); + + return renewSessionId(); } - private void setContext(@NotNull Context ctx) { + @NotNull + private String setContext(@NotNull Context ctx) { String uri = ctx.getUri(); this.context = AbsSpotifyContext.from(uri); this.state.setContextUri(uri); @@ -232,6 +262,8 @@ private void setContext(@NotNull Context ctx) { this.tracksKeeper = new TracksKeeper(); this.device.setIsActive(true); + + return renewSessionId(); } private void updateRestrictions() { @@ -295,7 +327,12 @@ void setVolume(int val) { device.setVolume(val); } - synchronized void enrichWithMetadata(@NotNull Metadata.Track track) { + void enrichWithMetadata(@NotNull TrackOrEpisode metadata) { + if (metadata.isTrack()) enrichWithMetadata(metadata.track); + else if (metadata.isEpisode()) enrichWithMetadata(metadata.episode); + } + + private synchronized void enrichWithMetadata(@NotNull Metadata.Track track) { if (state.getTrack() == null) throw new IllegalStateException(); if (!state.getTrack().getUri().equals(PlayableId.from(track).toSpotifyUri())) { LOGGER.warn(String.format("Failed updating metadata: tracks do not match. {current: %s, expected: %s}", state.getTrack().getUri(), PlayableId.from(track).toSpotifyUri())); @@ -356,7 +393,7 @@ synchronized void enrichWithMetadata(@NotNull Metadata.Track track) { state.setTrack(builder.build()); } - synchronized void enrichWithMetadata(@NotNull Metadata.Episode episode) { + private synchronized void enrichWithMetadata(@NotNull Metadata.Episode episode) { if (state.getTrack() == null) throw new IllegalStateException(); if (!state.getTrack().getUri().equals(PlayableId.from(episode).toSpotifyUri())) { LOGGER.warn(String.format("Failed updating metadata: episodes do not match. {current: %s, expected: %s}", state.getTrack().getUri(), PlayableId.from(episode).toSpotifyUri())); @@ -397,35 +434,40 @@ synchronized void setPosition(long pos) { state.clearPosition(); } - void loadContextWithTracks(@NotNull String uri, @NotNull List tracks) throws MercuryClient.MercuryException, IOException, AbsSpotifyContext.UnsupportedContextException { + @NotNull + String loadContextWithTracks(@NotNull String uri, @NotNull List tracks) throws MercuryClient.MercuryException, IOException, AbsSpotifyContext.UnsupportedContextException { state.setPlayOrigin(PlayOrigin.newBuilder().build()); state.setOptions(ContextPlayerOptions.newBuilder().build()); - setContext(uri); + String sessionId = setContext(uri); pages.putFirstPage(tracks); tracksKeeper.initializeStart(); setPosition(0); loadTransforming(); + return sessionId; } - void loadContext(@NotNull String uri) throws MercuryClient.MercuryException, IOException, AbsSpotifyContext.UnsupportedContextException { + @NotNull + String loadContext(@NotNull String uri) throws MercuryClient.MercuryException, IOException, AbsSpotifyContext.UnsupportedContextException { state.setPlayOrigin(PlayOrigin.newBuilder().build()); state.setOptions(ContextPlayerOptions.newBuilder().build()); - setContext(uri); + String sessionId = setContext(uri); tracksKeeper.initializeStart(); setPosition(0); loadTransforming(); + return sessionId; } - void transfer(@NotNull TransferStateOuterClass.TransferState cmd) throws AbsSpotifyContext.UnsupportedContextException, IOException, MercuryClient.MercuryException { + @NotNull + String transfer(@NotNull TransferStateOuterClass.TransferState cmd) throws AbsSpotifyContext.UnsupportedContextException, IOException, MercuryClient.MercuryException { SessionOuterClass.Session ps = cmd.getCurrentSession(); state.setPlayOrigin(ProtoUtils.convertPlayOrigin(ps.getPlayOrigin())); state.setOptions(ProtoUtils.convertPlayerOptions(cmd.getOptions())); - setContext(ps.getContext()); + String sessionId = setContext(ps.getContext()); PlaybackOuterClass.Playback pb = cmd.getPlayback(); tracksKeeper.initializeFrom(tracks -> ProtoUtils.indexOfTrackByUid(tracks, ps.getCurrentUid()), pb.getCurrentTrack(), cmd.getQueue()); @@ -435,12 +477,14 @@ void transfer(@NotNull TransferStateOuterClass.TransferState cmd) throws AbsSpot else state.setTimestamp(pb.getTimestamp()); loadTransforming(); + return sessionId; } - void load(@NotNull JsonObject obj) throws AbsSpotifyContext.UnsupportedContextException, IOException, MercuryClient.MercuryException { + @NotNull + String load(@NotNull JsonObject obj) throws AbsSpotifyContext.UnsupportedContextException, IOException, MercuryClient.MercuryException { state.setPlayOrigin(ProtoUtils.jsonToPlayOrigin(PlayCommandHelper.getPlayOrigin(obj))); state.setOptions(ProtoUtils.jsonToPlayerOptions(PlayCommandHelper.getPlayerOptionsOverride(obj), state.getOptions())); - setContext(ProtoUtils.jsonToContext(PlayCommandHelper.getContext(obj))); + String sessionId = setContext(ProtoUtils.jsonToContext(PlayCommandHelper.getContext(obj))); String trackUid = PlayCommandHelper.getSkipToUid(obj); String trackUri = PlayCommandHelper.getSkipToUri(obj); @@ -464,6 +508,7 @@ void load(@NotNull JsonObject obj) throws AbsSpotifyContext.UnsupportedContextEx else setPosition(0); loadTransforming(); + return sessionId; } synchronized void updateContext(@NotNull JsonObject obj) { @@ -483,18 +528,7 @@ void skipTo(@NotNull ContextTrack track) { } @Nullable - PlayableId nextPlayableDoNotSet() { - try { - PlayableIdWithIndex id = tracksKeeper.nextPlayableDoNotSet(); - return id == null ? null : id.id; - } catch (IOException | MercuryClient.MercuryException ex) { - LOGGER.error("Failed fetching next playable.", ex); - return null; - } - } - - @Nullable - PlayableId getCurrentPlayable() { + public PlayableId getCurrentPlayable() { return tracksKeeper == null ? null : PlayableId.from(tracksKeeper.getCurrentTrack()); } @@ -517,6 +551,17 @@ NextPlayable nextPlayable(@NotNull Player.Configuration conf) { } } + @Nullable + PlayableId nextPlayableDoNotSet() { + try { + PlayableIdWithIndex id = tracksKeeper.nextPlayableDoNotSet(); + return id == null ? null : id.id; + } catch (IOException | MercuryClient.MercuryException ex) { + LOGGER.error("Failed fetching next playable.", ex); + return null; + } + } + @NotNull PreviousPlayable previousPlayable() { if (tracksKeeper == null) return PreviousPlayable.MISSING_TRACKS; @@ -688,6 +733,13 @@ private synchronized void performCollectionUpdate(@NotNull List uris, bo tracksKeeper.updateMetadataFor(uri, "collection.in_collection", String.valueOf(inCollection)); } + public int getContextSize() { + String trackCount = getContextMetadata("track_count"); + if (trackCount != null) return Integer.parseInt(trackCount); + else if (tracksKeeper != null) return tracksKeeper.tracks.size(); + else return 0; + } + @Nullable public String getContextMetadata(@NotNull String key) { return state.getContextMetadataOrDefault(key, null); @@ -698,6 +750,27 @@ public void setContextMetadata(@NotNull String key, @Nullable String value) { else state.putContextMetadata(key, value); } + @NotNull + private String renewSessionId() { + String sessionId = generateSessionId(session.random()); + state.setSessionId(sessionId); + return sessionId; + } + + @NotNull + public String getSessionId() { + return state.getSessionId(); + } + + public void setPlaybackId(@NotNull String playbackId) { + state.setPlaybackId(playbackId); + } + + @NotNull + public PlayOrigin getPlayOrigin() { + return state.getPlayOrigin(); + } + @Override public void close() { session.dealer().removeMessageListener(this); @@ -1010,7 +1083,7 @@ synchronized void skipTo(@NotNull ContextTrack track) { @Nullable synchronized PlayableIdWithIndex nextPlayableDoNotSet() throws IOException, MercuryClient.MercuryException { if (isRepeatingTrack()) - return null; + return new PlayableIdWithIndex(PlayableId.from(tracks.get(getCurrentTrackIndex())), getCurrentTrackIndex()); if (!queue.isEmpty()) return new PlayableIdWithIndex(PlayableId.from(queue.peek()), -1); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java b/core/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java new file mode 100644 index 00000000..a4042ff3 --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java @@ -0,0 +1,83 @@ +package xyz.gianlu.librespot.player; + +import com.spotify.metadata.Metadata; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.Utils; +import xyz.gianlu.librespot.mercury.model.PlayableId; + +/** + * @author devgianlu + */ +public final class TrackOrEpisode { + public final PlayableId id; + public final Metadata.Track track; + public final Metadata.Episode episode; + + @Contract("null, null -> fail") + public TrackOrEpisode(@Nullable Metadata.Track track, @Nullable Metadata.Episode episode) { + if (track == null && episode == null) throw new IllegalArgumentException(); + + this.track = track; + this.episode = episode; + + if (track != null) id = PlayableId.from(track); + else id = PlayableId.from(episode); + } + + public boolean isTrack() { + return track != null; + } + + public boolean isEpisode() { + return episode != null; + } + + /** + * @return The track/episode duration + */ + public int duration() { + return track != null ? track.getDuration() : episode.getDuration(); + } + + /** + * @return The track album cover or episode cover + */ + @Nullable + public Metadata.ImageGroup getCoverImage() { + if (track != null) { + if (track.hasAlbum() && track.getAlbum().hasCoverGroup()) + return track.getAlbum().getCoverGroup(); + } else { + if (episode.hasCoverImage()) + return episode.getCoverImage(); + } + + return null; + } + + /** + * @return The track/episode name + */ + @NotNull + public String getName() { + return track != null ? track.getName() : episode.getName(); + } + + /** + * @return The track album name or episode show name + */ + @NotNull + public String getAlbumName() { + return track != null ? track.getAlbum().getName() : episode.getShow().getName(); + } + + /** + * @return The track artists or show publisher + */ + @NotNull + public String getArtist() { + return track != null ? Utils.artistsToString(track.getArtistList()) : episode.getShow().getPublisher(); + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java b/core/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java index 1f782929..a7984af5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java @@ -3,10 +3,9 @@ import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.AbsChunkedInputStream; -import xyz.gianlu.librespot.player.GeneralAudioStream; -import xyz.gianlu.librespot.player.NormalizationData; import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; +import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; import javax.sound.sampled.AudioFormat; import java.io.Closeable; @@ -38,7 +37,7 @@ public abstract class Codec implements Closeable { this.normalizationFactor = 1; } - public final int readSome(@NotNull OutputStream out) throws IOException, CodecException { + public final int writeSomeTo(@NotNull OutputStream out) throws IOException, CodecException { if (converter == null) return readInternal(out); int written = readInternal(converter); @@ -101,6 +100,14 @@ public final int duration() { return duration; } + public int size() { + return audioIn.size(); + } + + public int decodedLength() { + return audioIn.decodedLength(); + } + public static class CannotGetTimeException extends Exception { CannotGetTimeException() { } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java b/core/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java index a1dfd798..32a9140d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java @@ -3,9 +3,8 @@ import javazoom.jl.decoder.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.GeneralAudioStream; -import xyz.gianlu.librespot.player.NormalizationData; import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; import javax.sound.sampled.AudioFormat; import java.io.IOException; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/NormalizationData.java b/core/src/main/java/xyz/gianlu/librespot/player/codecs/NormalizationData.java similarity index 95% rename from core/src/main/java/xyz/gianlu/librespot/player/NormalizationData.java rename to core/src/main/java/xyz/gianlu/librespot/player/codecs/NormalizationData.java index dbc002bf..536bb40c 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/NormalizationData.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/codecs/NormalizationData.java @@ -1,8 +1,9 @@ -package xyz.gianlu.librespot.player; +package xyz.gianlu.librespot.player.codecs; import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.player.Player; import java.io.DataInputStream; import java.io.IOException; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java b/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java index 9b13a57d..c00efcd2 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java @@ -10,9 +10,8 @@ import com.jcraft.jorbis.Info; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.GeneralAudioStream; -import xyz.gianlu.librespot.player.NormalizationData; import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; import xyz.gianlu.librespot.player.mixing.LineHelper; import javax.sound.sampled.AudioFormat; @@ -36,9 +35,9 @@ public class VorbisCodec extends Codec { private byte[] buffer; private int count; private int index; - private byte[] convertedBuffer; - private float[][][] pcmInfo; - private int[] pcmIndex; + private final byte[] convertedBuffer; + private final float[][][] pcmInfo; + private final int[] pcmIndex; private long pcm_offset; public VorbisCodec(@NotNull AudioFormat dstFormat, @NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, Player.@NotNull Configuration conf, int duration) throws IOException, CodecException, LineHelper.MixerException { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java b/core/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java index e1190d6e..6bf4660a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java @@ -6,112 +6,122 @@ import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.core.EventService.PlaybackMetrics.Reason; import xyz.gianlu.librespot.player.Player; +import java.util.HashMap; import java.util.Map; public class CrossfadeController { private static final Logger LOGGER = Logger.getLogger(CrossfadeController.class); + private final String playbackId; private final int trackDuration; + private final Map fadeOutMap = new HashMap<>(8); + private final Map fadeInMap = new HashMap<>(8); private final int defaultFadeDuration; - private final int fadeInDuration; - private final int fadeInStartTime; - private final int fadeOutDuration; - private final int fadeOutStartTime; - private final String fadeOutUri; - private final FadeInterval startInterval; - private final FadeInterval endInterval; + private FadeInterval fadeIn = null; + private FadeInterval fadeOut = null; private FadeInterval activeInterval = null; private float lastGain = 1; + private int fadeOverlap = 0; - public CrossfadeController(int duration, @NotNull Player.Configuration conf) { + public CrossfadeController(@NotNull String playbackId, int duration, @NotNull Map metadata, @NotNull Player.Configuration conf) { + this.playbackId = playbackId; trackDuration = duration; defaultFadeDuration = conf.crossfadeDuration(); + // Didn't ever find an use for "audio.fade_out_uri" - fadeInDuration = -1; - fadeInStartTime = -1; + populateFadeIn(metadata); + populateFadeOut(metadata); - fadeOutUri = null; - fadeOutDuration = -1; - fadeOutStartTime = -1; - - if (defaultFadeDuration > 0) - startInterval = new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator()); - else - startInterval = null; + LOGGER.debug(String.format("Loaded crossfade intervals {id: %s, in: %s, out: %s}", playbackId, fadeInMap, fadeOutMap)); + } - if (defaultFadeDuration > 0) - endInterval = new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator()); - else - endInterval = null; + @NotNull + private static JsonArray getFadeCurve(@NotNull JsonArray curves) { + JsonObject curve = curves.get(0).getAsJsonObject(); + if (curve.get("start_point").getAsFloat() != 0 || curve.get("end_point").getAsFloat() != 1) + throw new UnsupportedOperationException(); - LOGGER.debug(String.format("Loaded default intervals. {start: %s, end: %s}", startInterval, endInterval)); + return curve.getAsJsonArray("fade_curve"); } - public CrossfadeController(int duration, @NotNull Map metadata, @NotNull Player.Configuration conf) { - trackDuration = duration; - defaultFadeDuration = conf.crossfadeDuration(); - - fadeInDuration = Integer.parseInt(metadata.getOrDefault("audio.fade_in_duration", "-1")); - fadeInStartTime = Integer.parseInt(metadata.getOrDefault("audio.fade_in_start_time", "-1")); + private void populateFadeIn(@NotNull Map metadata) { + int fadeInDuration = Integer.parseInt(metadata.getOrDefault("audio.fade_in_duration", "-1")); + int fadeInStartTime = Integer.parseInt(metadata.getOrDefault("audio.fade_in_start_time", "-1")); JsonArray fadeInCurves = JsonParser.parseString(metadata.getOrDefault("audio.fade_in_curves", "[]")).getAsJsonArray(); if (fadeInCurves.size() > 1) throw new UnsupportedOperationException(fadeInCurves.toString()); - fadeOutUri = metadata.get("audio.fade_out_uri"); - fadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.fade_out_duration", "-1")); - fadeOutStartTime = Integer.parseInt(metadata.getOrDefault("audio.fade_out_start_time", "-1")); - JsonArray fadeOutCurves = JsonParser.parseString(metadata.getOrDefault("audio.fade_out_curves", "[]")).getAsJsonArray(); - if (fadeOutCurves.size() > 1) throw new UnsupportedOperationException(fadeOutCurves.toString()); - - if (fadeInDuration == 0) - startInterval = null; - else if (fadeInCurves.size() > 0) - startInterval = new FadeInterval(fadeInStartTime, fadeInDuration, LookupInterpolator.fromJson(getFadeCurve(fadeInCurves))); + if (fadeInDuration != 0 && fadeInCurves.size() > 0) + fadeInMap.put(Reason.TRACK_DONE, new FadeInterval(fadeInStartTime, fadeInDuration, LookupInterpolator.fromJson(getFadeCurve(fadeInCurves)))); else if (defaultFadeDuration > 0) - startInterval = new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator()); - else - startInterval = null; - - if (fadeOutDuration == 0) - endInterval = null; - else if (fadeOutCurves.size() > 0) - endInterval = new FadeInterval(fadeOutStartTime, fadeOutDuration, LookupInterpolator.fromJson(getFadeCurve(fadeOutCurves))); - else if (defaultFadeDuration > 0) - endInterval = new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator()); - else - endInterval = null; + fadeInMap.put(Reason.TRACK_DONE, new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator())); - LOGGER.debug(String.format("Loaded intervals. {start: %s, end: %s}", startInterval, endInterval)); - } - @NotNull - private static JsonArray getFadeCurve(@NotNull JsonArray curves) { - JsonObject curve = curves.get(0).getAsJsonObject(); - if (curve.get("start_point").getAsFloat() != 0 || curve.get("end_point").getAsFloat() != 1) - throw new UnsupportedOperationException(); + int fwdFadeInStartTime = Integer.parseInt(metadata.getOrDefault("audio.fwdbtn.fade_in_start_time", "-1")); + int fwdFadeInDuration = Integer.parseInt(metadata.getOrDefault("audio.fwdbtn.fade_in_duration", "-1")); + if (fwdFadeInDuration > 0) + fadeInMap.put(Reason.FORWARD_BTN, new FadeInterval(fwdFadeInStartTime, fwdFadeInDuration, new LinearIncreasingInterpolator())); - return curve.getAsJsonArray("fade_curve"); + int backFadeInStartTime = Integer.parseInt(metadata.getOrDefault("audio.backbtn.fade_in_start_time", "-1")); + int backFadeInDuration = Integer.parseInt(metadata.getOrDefault("audio.backbtn.fade_in_duration", "-1")); + if (backFadeInDuration > 0) + fadeInMap.put(Reason.BACK_BTN, new FadeInterval(backFadeInStartTime, backFadeInDuration, new LinearIncreasingInterpolator())); } - public boolean shouldStartNextTrack(int pos) { - return fadeOutEnabled() && endInterval != null && pos >= endInterval.start; - } + private void populateFadeOut(@NotNull Map metadata) { + int fadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.fade_out_duration", "-1")); + int fadeOutStartTime = Integer.parseInt(metadata.getOrDefault("audio.fade_out_start_time", "-1")); + JsonArray fadeOutCurves = JsonParser.parseString(metadata.getOrDefault("audio.fade_out_curves", "[]")).getAsJsonArray(); + if (fadeOutCurves.size() > 1) throw new UnsupportedOperationException(fadeOutCurves.toString()); + + if (fadeOutDuration != 0 && fadeOutCurves.size() > 0) + fadeOutMap.put(Reason.TRACK_DONE, new FadeInterval(fadeOutStartTime, fadeOutDuration, LookupInterpolator.fromJson(getFadeCurve(fadeOutCurves)))); + else if (defaultFadeDuration > 0) + fadeOutMap.put(Reason.TRACK_DONE, new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator())); + - public boolean shouldStop(int pos) { - return endInterval != null && pos >= endInterval.end(); + int backFadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.backbtn.fade_out_duration", "-1")); + if (backFadeOutDuration > 0) + fadeOutMap.put(Reason.BACK_BTN, new PartialFadeInterval(backFadeOutDuration, new LinearDecreasingInterpolator())); + + int fwdFadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.fwdbtn.fade_out_duration", "-1")); + if (fwdFadeOutDuration > 0) + fadeOutMap.put(Reason.FORWARD_BTN, new PartialFadeInterval(fwdFadeOutDuration, new LinearDecreasingInterpolator())); } + /** + * Get the gain at this specified position, switching out intervals if needed. + * + * @param pos The time in milliseconds + * @return The gain value from 0 to 1 + */ public float getGain(int pos) { + if (activeInterval == null && fadeIn == null && fadeOut == null) + return lastGain; + if (activeInterval != null && activeInterval.end() <= pos) { lastGain = activeInterval.interpolator.last(); + + if (activeInterval == fadeIn) { + fadeIn = null; + LOGGER.debug(String.format("Cleared fade in. {id: %s}", playbackId)); + } else if (activeInterval == fadeOut) { + fadeOut = null; + LOGGER.debug(String.format("Cleared fade out. {id: %s}", playbackId)); + } + activeInterval = null; } if (activeInterval == null) { - if (startInterval != null && pos >= startInterval.start && startInterval.end() >= pos) - activeInterval = startInterval; - else if (endInterval != null && pos >= endInterval.start && endInterval.end() >= pos) - activeInterval = endInterval; + if (fadeIn != null && pos >= fadeIn.start && fadeIn.end() >= pos) { + activeInterval = fadeIn; + fadeOverlap += fadeIn.duration; + } else if (fadeOut != null && pos >= fadeOut.start && fadeOut.end() >= pos) { + activeInterval = fadeOut; + fadeOverlap += fadeOut.duration; + } } if (activeInterval == null) return lastGain; @@ -119,30 +129,107 @@ else if (endInterval != null && pos >= endInterval.start && endInterval.end() >= return lastGain = activeInterval.interpolate(pos); } - public int fadeInStartTime() { - if (fadeInStartTime != -1) return fadeInStartTime; - else return 0; + /** + * Select the next fade in interval. This field will be cleared once the interval has started and then left. + * + * @param reason The reason behind this change, used to get the correct interval + * @return The interval that has just been selected + */ + @Nullable + public FadeInterval selectFadeIn(@NotNull Reason reason) { + fadeIn = fadeInMap.get(reason); + activeInterval = null; + LOGGER.debug(String.format("Changed fade in. {curr: %s, why: %s, id: %s}", fadeIn, reason, playbackId)); + return fadeIn; } - public int fadeOutStartTime() { - if (fadeOutStartTime != -1) return fadeOutStartTime; - else return trackDuration - defaultFadeDuration; + /** + * Select the next fade out interval. This field will be cleared once the interval has started and then left. + * + * @param reason The reason behind this change, used to get the correct interval + * @return The interval that has just been selected + */ + @Nullable + public FadeInterval selectFadeOut(@NotNull Reason reason) { + fadeOut = fadeOutMap.get(reason); + activeInterval = null; + LOGGER.debug(String.format("Changed fade out. {curr: %s, why: %s, id: %s}", fadeOut, reason, playbackId)); + return fadeOut; } - public boolean fadeInEnabled() { - return fadeInDuration != -1 || defaultFadeDuration > 0; + /** + * @return The first (scheduled) fade out start time. + */ + public int fadeOutStartTimeMin() { + int fadeOutStartTime = -1; + for (FadeInterval interval : fadeOutMap.values()) { + if (interval instanceof PartialFadeInterval) continue; + + if (fadeOutStartTime == -1 || fadeOutStartTime > interval.start) + fadeOutStartTime = interval.start; + } + + if (fadeOutStartTime == -1) return trackDuration; + else return fadeOutStartTime; } - public boolean fadeOutEnabled() { - return fadeOutDuration != -1 || defaultFadeDuration > 0; + /** + * @return Whether there is any possibility of a fade out. + */ + public boolean hasAnyFadeOut() { + return !fadeOutMap.isEmpty(); } - @Nullable - public String fadeOutUri() { - return fadeOutUri; + /** + * @return The amount of fade overlap accumulated during playback. + */ + public int fadeOverlap() { + return fadeOverlap; } - private static class FadeInterval { + /** + * An interval without a start. Used when crossfading due to an user interaction. + */ + public static class PartialFadeInterval extends FadeInterval { + private int partialStart = -1; + + PartialFadeInterval(int duration, @NotNull GainInterpolator interpolator) { + super(-1, duration, interpolator); + } + + @Override + public int start() { + if (partialStart == -1) throw new IllegalStateException(); + return partialStart; + } + + public int end(int now) { + partialStart = now; + return end(); + } + + @Override + public int end() { + if (partialStart == -1) throw new IllegalStateException(); + return partialStart + duration; + } + + @Override + float interpolate(int trackPos) { + if (partialStart == -1) throw new IllegalStateException(); + return super.interpolate(trackPos - 1 - partialStart); + } + + @Override + public String toString() { + return "PartialFadeInterval{duration=" + duration + ", interpolator=" + interpolator + '}'; + } + } + + /** + * An interval representing when the fade should start, end, how much should last and how should behave. + */ + public static class FadeInterval { final int start; final int duration; final GainInterpolator interpolator; @@ -153,10 +240,18 @@ private static class FadeInterval { this.interpolator = interpolator; } - int end() { + public int end() { return start + duration; } + public int duration() { + return duration; + } + + public int start() { + return start; + } + float interpolate(int trackPos) { float pos = ((float) trackPos - start) / duration; pos = Math.min(pos, 1); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java b/core/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java index fb5a0db5..9936905e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java @@ -3,6 +3,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Range; import java.util.Arrays; @@ -35,9 +36,17 @@ static LookupInterpolator fromJson(@NotNull JsonArray curve) { } @Override - public float interpolate(float ix) { - if (ix > tx[tx.length - 1]) return ty[tx.length - 1]; - else if (ix < tx[0]) return ty[0]; + public String toString() { + return "LookupInterpolator{" + + "tx=" + Arrays.toString(tx) + + ", ty=" + Arrays.toString(ty) + + '}'; + } + + @Override + public float interpolate(@Range(from = 0, to = 1) float ix) { + if (ix >= tx[tx.length - 1]) return ty[tx.length - 1]; + else if (ix <= tx[0]) return ty[0]; for (int i = 0; i < tx.length - 1; i++) { if (ix >= tx[i] && ix <= tx[i + 1]) { @@ -47,7 +56,7 @@ public float interpolate(float ix) { float o_delta = ty[i + 1] - ty[i]; // Spread between the two adjacent table output values if (o_delta == 0) return o_low; - else return o_low + ((ix - i_low) * (long) o_delta) / i_delta; + else return o_low + ((ix - i_low) * o_delta) / i_delta; } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/AbsChunkedInputStream.java similarity index 96% rename from core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java rename to core/src/main/java/xyz/gianlu/librespot/player/feeders/AbsChunkedInputStream.java index 90157873..f836242e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/AbsChunkedInputStream.java @@ -1,6 +1,7 @@ -package xyz.gianlu.librespot.player; +package xyz.gianlu.librespot.player.feeders; import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.player.Player; import java.io.IOException; import java.io.InputStream; @@ -22,6 +23,7 @@ public abstract class AbsChunkedInputStream extends InputStream implements HaltL private int pos = 0; private int mark = 0; private volatile boolean closed = false; + private int decodedLength = 0; protected AbsChunkedInputStream(@NotNull Player.Configuration conf) { this.retries = new int[chunks()]; @@ -34,7 +36,7 @@ public final boolean isClosed() { protected abstract byte[][] buffer(); - protected abstract int size(); + public abstract int size(); @Override public void close() { @@ -219,6 +221,7 @@ public final synchronized int read() throws IOException { public final void notifyChunkAvailable(int index) { availableChunks()[index] = true; + decodedLength += buffer()[index].length; synchronized (waitLock) { if (index == waitForChunk && !closed) { @@ -242,6 +245,10 @@ public final void notifyChunkError(int index, @NotNull ChunkException ex) { } } + public int decodedLength() { + return decodedLength; + } + public static class ChunkException extends IOException { public ChunkException(@NotNull Throwable cause) { super(cause); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/GeneralAudioStream.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralAudioStream.java similarity index 86% rename from core/src/main/java/xyz/gianlu/librespot/player/GeneralAudioStream.java rename to core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralAudioStream.java index 6aa1962a..7490c612 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/GeneralAudioStream.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralAudioStream.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player; +package xyz.gianlu.librespot.player.feeders; import org.jetbrains.annotations.NotNull; import xyz.gianlu.librespot.player.codecs.SuperAudioFormat; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/GeneralWritableStream.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralWritableStream.java similarity index 80% rename from core/src/main/java/xyz/gianlu/librespot/player/GeneralWritableStream.java rename to core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralWritableStream.java index 91e09ada..6c45f99b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/GeneralWritableStream.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralWritableStream.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player; +package xyz.gianlu.librespot.player.feeders; import java.io.IOException; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/HaltListener.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/HaltListener.java similarity index 78% rename from core/src/main/java/xyz/gianlu/librespot/player/HaltListener.java rename to core/src/main/java/xyz/gianlu/librespot/player/feeders/HaltListener.java index 24fa3db1..018b0d88 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/HaltListener.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/HaltListener.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player; +package xyz.gianlu.librespot.player.feeders; /** * @author Gianlu diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/PlayableContentFeeder.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/PlayableContentFeeder.java index 4407870a..625c183d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/PlayableContentFeeder.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/PlayableContentFeeder.java @@ -7,6 +7,7 @@ import okhttp3.Response; import okhttp3.ResponseBody; import org.apache.log4j.Logger; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.common.Utils; @@ -16,11 +17,9 @@ import xyz.gianlu.librespot.mercury.model.PlayableId; import xyz.gianlu.librespot.mercury.model.TrackId; import xyz.gianlu.librespot.player.ContentRestrictedException; -import xyz.gianlu.librespot.player.GeneralAudioStream; -import xyz.gianlu.librespot.player.HaltListener; -import xyz.gianlu.librespot.player.NormalizationData; import xyz.gianlu.librespot.player.codecs.AudioQuality; import xyz.gianlu.librespot.player.codecs.AudioQualityPreference; +import xyz.gianlu.librespot.player.codecs.NormalizationData; import xyz.gianlu.librespot.player.feeders.cdn.CdnFeedHelper; import xyz.gianlu.librespot.player.feeders.cdn.CdnManager; import xyz.gianlu.librespot.player.feeders.storage.AudioFileFetch; @@ -33,6 +32,8 @@ */ public final class PlayableContentFeeder { private static final Logger LOGGER = Logger.getLogger(PlayableContentFeeder.class); + private static final String STORAGE_RESOLVE_INTERACTIVE = "/storage-resolve/files/audio/interactive/%s"; + private static final String STORAGE_RESOLVE_INTERACTIVE_PREFETCH = "/storage-resolve/files/audio/interactive_prefetch/%s"; protected final Session session; public PlayableContentFeeder(@NotNull Session session) { @@ -56,15 +57,18 @@ private static Metadata.Track pickAlternativeIfNecessary(@NotNull Metadata.Track } @NotNull - public final LoadedStream load(@NotNull PlayableId id, @NotNull AudioQualityPreference audioQualityPreference, @Nullable HaltListener haltListener) throws CdnManager.CdnException, ContentRestrictedException, MercuryClient.MercuryException, IOException { - if (id instanceof TrackId) return loadTrack((TrackId) id, audioQualityPreference, haltListener); - else if (id instanceof EpisodeId) return loadEpisode((EpisodeId) id, audioQualityPreference, haltListener); - else throw new IllegalArgumentException("Unknown PlayableId: " + id); + public final LoadedStream load(@NotNull PlayableId id, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws CdnManager.CdnException, ContentRestrictedException, MercuryClient.MercuryException, IOException { + if (id instanceof TrackId) + return loadTrack((TrackId) id, audioQualityPreference, preload, haltListener); + else if (id instanceof EpisodeId) + return loadEpisode((EpisodeId) id, audioQualityPreference, preload, haltListener); + else + throw new IllegalArgumentException("Unknown content: " + id); } @NotNull - private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fileId) throws IOException, MercuryClient.MercuryException { - try (Response resp = session.api().send("GET", String.format("/storage-resolve/files/audio/interactive/%s", Utils.bytesToHex(fileId)), null, null)) { + private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fileId, boolean preload) throws IOException, MercuryClient.MercuryException { + try (Response resp = session.api().send("GET", String.format(preload ? STORAGE_RESOLVE_INTERACTIVE_PREFETCH : STORAGE_RESOLVE_INTERACTIVE, Utils.bytesToHex(fileId)), null, null)) { if (resp.code() != 200) throw new IOException(resp.code() + ": " + resp.message()); ResponseBody body = resp.body(); @@ -74,47 +78,51 @@ private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fil } } - private @NotNull LoadedStream loadTrack(@NotNull TrackId id, @NotNull AudioQualityPreference audioQualityPreference, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, ContentRestrictedException, CdnManager.CdnException { + private @NotNull LoadedStream loadTrack(@NotNull TrackId id, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, ContentRestrictedException, CdnManager.CdnException { Metadata.Track original = session.api().getMetadata4Track(id); Metadata.Track track = pickAlternativeIfNecessary(original); if (track == null) { String country = session.countryCode(); if (country != null) ContentRestrictedException.checkRestrictions(country, original.getRestrictionList()); - LOGGER.fatal("Couldn't find playable track: " + Utils.bytesToHex(id.getGid())); + LOGGER.fatal("Couldn't find playable track: " + id.toSpotifyUri()); throw new FeederException(); } - return loadTrack(track, audioQualityPreference, haltListener); + return loadTrack(track, audioQualityPreference, preload, haltListener); } @NotNull - private LoadedStream loadCdnStream(@NotNull Metadata.AudioFile file, @Nullable Metadata.Track track, @Nullable Metadata.Episode episode, @NotNull String urlStr, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { + @Contract("_, null, null, _, _, _ -> fail") + private LoadedStream loadCdnStream(@NotNull Metadata.AudioFile file, @Nullable Metadata.Track track, @Nullable Metadata.Episode episode, @NotNull String urlStr, boolean preload, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { + if (track == null && episode == null) + throw new IllegalStateException(); + HttpUrl url = HttpUrl.get(urlStr); - if (track != null) return CdnFeedHelper.loadTrack(session, track, file, url, haltListener); - else if (episode != null) return CdnFeedHelper.loadEpisode(session, episode, file, url, haltListener); - else throw new IllegalStateException(); + if (track != null) return CdnFeedHelper.loadTrack(session, track, file, url, preload, haltListener); + else return CdnFeedHelper.loadEpisode(session, episode, file, url, haltListener); } @NotNull - private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Metadata.Track track, @Nullable Metadata.Episode episode, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { - StorageResolveResponse resp = resolveStorageInteractive(file.getFileId()); + @Contract("_, null, null, _, _ -> fail") + private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Metadata.Track track, @Nullable Metadata.Episode episode, boolean preload, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { + if (track == null && episode == null) + throw new IllegalStateException(); + + session.eventService().fetchedFileId(track != null ? PlayableId.from(track) : PlayableId.from(episode), file); + + StorageResolveResponse resp = resolveStorageInteractive(file.getFileId(), preload); switch (resp.getResult()) { case CDN: - if (track != null) return CdnFeedHelper.loadTrack(session, track, file, resp, haltListener); - else if (episode != null) return CdnFeedHelper.loadEpisode(session, episode, file, resp, haltListener); - else throw new IllegalStateException(); + if (track != null) return CdnFeedHelper.loadTrack(session, track, file, resp, preload, haltListener); + else return CdnFeedHelper.loadEpisode(session, episode, file, resp, haltListener); case STORAGE: try { - if (track != null) - return StorageFeedHelper.loadTrack(session, track, file, haltListener); - else if (episode != null) - return StorageFeedHelper.loadEpisode(session, episode, file, haltListener); - else - throw new IllegalStateException(); + if (track != null) return StorageFeedHelper.loadTrack(session, track, file, preload, haltListener); + else return StorageFeedHelper.loadEpisode(session, episode, file, preload, haltListener); } catch (AudioFileFetch.StorageNotAvailable ex) { LOGGER.info("Storage is not available. Going CDN: " + ex.cdnUrl); - return loadCdnStream(file, track, episode, ex.cdnUrl, haltListener); + return loadCdnStream(file, track, episode, ex.cdnUrl, preload, haltListener); } case RESTRICTED: throw new IllegalStateException("Content is restricted!"); @@ -126,18 +134,18 @@ else if (episode != null) } @NotNull - private LoadedStream loadTrack(@NotNull Metadata.Track track, @NotNull AudioQualityPreference audioQualityPreference, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException, MercuryClient.MercuryException { + private LoadedStream loadTrack(@NotNull Metadata.Track track, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException, MercuryClient.MercuryException { Metadata.AudioFile file = audioQualityPreference.getFile(track.getFileList()); if (file == null) { LOGGER.fatal(String.format("Couldn't find any suitable audio file, available: %s", AudioQuality.listFormats(track.getFileList()))); throw new FeederException(); } - return loadStream(file, track, null, haltListener); + return loadStream(file, track, null, preload, haltListener); } @NotNull - private LoadedStream loadEpisode(@NotNull EpisodeId id, @NotNull AudioQualityPreference audioQualityPreference, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { + private LoadedStream loadEpisode(@NotNull EpisodeId id, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { Metadata.Episode episode = session.api().getMetadata4Episode(id); if (episode.hasExternalUrl()) { @@ -149,7 +157,7 @@ private LoadedStream loadEpisode(@NotNull EpisodeId id, @NotNull AudioQualityPre throw new FeederException(); } - return loadStream(file, null, episode, haltListener); + return loadStream(file, null, episode, preload, haltListener); } } @@ -158,22 +166,40 @@ public static class LoadedStream { public final Metadata.Track track; public final GeneralAudioStream in; public final NormalizationData normalizationData; + public final Metrics metrics; - public LoadedStream(@NotNull Metadata.Track track, @NotNull GeneralAudioStream in, @Nullable NormalizationData normalizationData) { + public LoadedStream(@NotNull Metadata.Track track, @NotNull GeneralAudioStream in, @Nullable NormalizationData normalizationData, @NotNull Metrics metrics) { this.track = track; this.in = in; this.normalizationData = normalizationData; + this.metrics = metrics; this.episode = null; } - public LoadedStream(@NotNull Metadata.Episode episode, @NotNull GeneralAudioStream in, @Nullable NormalizationData normalizationData) { + public LoadedStream(@NotNull Metadata.Episode episode, @NotNull GeneralAudioStream in, @Nullable NormalizationData normalizationData, @NotNull Metrics metrics) { this.episode = episode; this.in = in; this.normalizationData = normalizationData; + this.metrics = metrics; this.track = null; } } + public static class Metrics { + public final String fileId; + public final boolean preloadedAudioKey; + public final int audioKeyTime; + + public Metrics(@Nullable ByteString fileId, boolean preloadedAudioKey, int audioKeyTime) { + this.fileId = fileId == null ? null : Utils.bytesToHex(fileId).toLowerCase(); + this.preloadedAudioKey = preloadedAudioKey; + this.audioKeyTime = audioKeyTime; + + if (preloadedAudioKey && audioKeyTime != -1) + throw new IllegalStateException(); + } + } + public static class FeederException extends IOException { FeederException() { } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/StreamId.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/StreamId.java similarity index 95% rename from core/src/main/java/xyz/gianlu/librespot/player/StreamId.java rename to core/src/main/java/xyz/gianlu/librespot/player/feeders/StreamId.java index f89385cf..9cfcccfd 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StreamId.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/StreamId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player; +package xyz.gianlu.librespot.player.feeders; import com.google.protobuf.ByteString; import com.spotify.metadata.Metadata; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java index 7900d1bb..4205aed9 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java @@ -10,8 +10,9 @@ import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.player.HaltListener; -import xyz.gianlu.librespot.player.NormalizationData; +import xyz.gianlu.librespot.player.codecs.NormalizationData; +import xyz.gianlu.librespot.player.feeders.HaltListener; +import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder.LoadedStream; import java.io.IOException; @@ -31,17 +32,22 @@ private static HttpUrl getUrl(@NotNull Session session, @NotNull StorageResolveR return HttpUrl.get(resp.getCdnurl(session.random().nextInt(resp.getCdnurlCount()))); } - public static @NotNull LoadedStream loadTrack(@NotNull Session session, Metadata.@NotNull Track track, Metadata.@NotNull AudioFile file, @NotNull HttpUrl url, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { + public static @NotNull LoadedStream loadTrack(@NotNull Session session, Metadata.@NotNull Track track, Metadata.@NotNull AudioFile file, + @NotNull HttpUrl url, boolean preload, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { + long start = System.currentTimeMillis(); byte[] key = session.audioKey().getAudioKey(track.getGid(), file.getFileId()); + int audioKeyTime = (int) (System.currentTimeMillis() - start); + CdnManager.Streamer streamer = session.cdn().streamFile(file, key, url, haltListener); InputStream in = streamer.stream(); NormalizationData normalizationData = NormalizationData.read(in); if (in.skip(0xa7) != 0xa7) throw new IOException("Couldn't skip 0xa7 bytes!"); - return new LoadedStream(track, streamer, normalizationData); + return new LoadedStream(track, streamer, normalizationData, new PlayableContentFeeder.Metrics(file.getFileId(), preload, preload ? -1 : audioKeyTime)); } - public static @NotNull LoadedStream loadTrack(@NotNull Session session, Metadata.@NotNull Track track, Metadata.@NotNull AudioFile file, @NotNull StorageResolveResponse storage, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { - return loadTrack(session, track, file, getUrl(session, storage), haltListener); + public static @NotNull LoadedStream loadTrack(@NotNull Session session, Metadata.@NotNull Track track, Metadata.@NotNull AudioFile file, + @NotNull StorageResolveResponse storage, boolean preload, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { + return loadTrack(session, track, file, getUrl(session, storage), preload, haltListener); } public static @NotNull LoadedStream loadEpisodeExternal(@NotNull Session session, Metadata.@NotNull Episode episode, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { @@ -55,17 +61,20 @@ private static HttpUrl getUrl(@NotNull Session session, @NotNull StorageResolveR LOGGER.debug(String.format("Fetched external url for %s: %s", Utils.bytesToHex(episode.getGid()), url)); CdnManager.Streamer streamer = session.cdn().streamExternalEpisode(episode, url, haltListener); - return new LoadedStream(episode, streamer, null); + return new LoadedStream(episode, streamer, null, new PlayableContentFeeder.Metrics(null, false, -1)); } } public static @NotNull LoadedStream loadEpisode(@NotNull Session session, Metadata.@NotNull Episode episode, @NotNull Metadata.AudioFile file, @NotNull HttpUrl url, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { + long start = System.currentTimeMillis(); byte[] key = session.audioKey().getAudioKey(episode.getGid(), file.getFileId()); + int audioKeyTime = (int) (System.currentTimeMillis() - start); + CdnManager.Streamer streamer = session.cdn().streamFile(file, key, url, haltListener); InputStream in = streamer.stream(); NormalizationData normalizationData = NormalizationData.read(in); if (in.skip(0xa7) != 0xa7) throw new IOException("Couldn't skip 0xa7 bytes!"); - return new LoadedStream(episode, streamer, normalizationData); + return new LoadedStream(episode, streamer, normalizationData, new PlayableContentFeeder.Metrics(file.getFileId(), false, audioKeyTime)); } public static @NotNull LoadedStream loadEpisode(@NotNull Session session, Metadata.@NotNull Episode episode, @NotNull Metadata.AudioFile file, @NotNull StorageResolveResponse storage, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java index a5dee60a..6880976b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java @@ -12,11 +12,12 @@ import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.player.*; +import xyz.gianlu.librespot.player.Player; import xyz.gianlu.librespot.player.codecs.SuperAudioFormat; import xyz.gianlu.librespot.player.decrypt.AesAudioDecrypt; import xyz.gianlu.librespot.player.decrypt.AudioDecrypt; import xyz.gianlu.librespot.player.decrypt.NoopAudioDecrypt; +import xyz.gianlu.librespot.player.feeders.*; import xyz.gianlu.librespot.player.feeders.storage.AudioFileFetch; import java.io.IOException; @@ -121,11 +122,6 @@ private class CdnUrl { this.setUrl(url); } - @Nullable - String host() { - return url == null ? null : url.host(); - } - @NotNull HttpUrl url() throws CdnException { if (expiration == -1) return url; @@ -210,7 +206,22 @@ private Streamer(@NotNull StreamId streamId, @NotNull SuperAudioFormat format, @ boolean fromCache; byte[] firstChunk; byte[] sizeHeader; - if (cacheHandler == null || (sizeHeader = cacheHandler.getHeader(AudioFileFetch.HEADER_SIZE)) == null) { + + if (cacheHandler != null && (sizeHeader = cacheHandler.getHeader(AudioFileFetch.HEADER_SIZE)) != null) { + size = ByteBuffer.wrap(sizeHeader).getInt() * 4; + chunks = (size + CHUNK_SIZE - 1) / CHUNK_SIZE; + + try { + firstChunk = cacheHandler.readChunk(0); + fromCache = true; + } catch (IOException ex) { + LOGGER.error("Failed getting first chunk from cache.", ex); + + InternalResponse resp = request(0, CHUNK_SIZE - 1); + firstChunk = resp.buffer; + fromCache = false; + } + } else { InternalResponse resp = request(0, CHUNK_SIZE - 1); String contentRange = resp.headers.get("Content-Range"); if (contentRange == null) @@ -220,17 +231,11 @@ private Streamer(@NotNull StreamId streamId, @NotNull SuperAudioFormat format, @ size = Integer.parseInt(split[1]); chunks = (int) Math.ceil((float) size / (float) CHUNK_SIZE); - firstChunk = resp.buffer; - fromCache = false; - if (cacheHandler != null) cacheHandler.setHeader(AudioFileFetch.HEADER_SIZE, ByteBuffer.allocate(4).putInt(size / 4).array()); - } else { - size = ByteBuffer.wrap(sizeHeader).getInt() * 4; - chunks = (size + CHUNK_SIZE - 1) / CHUNK_SIZE; - firstChunk = cacheHandler.readChunk(0); - fromCache = true; + firstChunk = resp.buffer; + fromCache = false; } available = new boolean[chunks]; @@ -255,7 +260,7 @@ public void writeChunk(@NotNull byte[] chunk, int chunkIndex, boolean cached) th } } - LOGGER.trace(String.format("Chunk %d/%d completed, cdn: %s, cached: %b, stream: %s", chunkIndex, chunks, cdnUrl.host(), cached, describe())); + LOGGER.trace(String.format("Chunk %d/%d completed, cached: %b, stream: %s", chunkIndex, chunks, cached, describe())); audioDecrypt.decryptChunk(chunkIndex, chunk, buffer[chunkIndex]); internalStream.notifyChunkAvailable(chunkIndex); @@ -320,6 +325,10 @@ public synchronized InternalResponse request(int rangeStart, int rangeEnd) throw } } + public int size() { + return size; + } + private class InternalStream extends AbsChunkedInputStream { private InternalStream(Player.@NotNull Configuration conf) { @@ -338,7 +347,7 @@ protected byte[][] buffer() { } @Override - protected int size() { + public int size() { return size; } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFile.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFile.java index f85d35dd..e720e45d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFile.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFile.java @@ -1,6 +1,6 @@ package xyz.gianlu.librespot.player.feeders.storage; -import xyz.gianlu.librespot.player.GeneralWritableStream; +import xyz.gianlu.librespot.player.feeders.GeneralWritableStream; import java.io.Closeable; import java.io.IOException; @@ -11,7 +11,7 @@ public interface AudioFile extends Closeable, GeneralWritableStream { void writeChunk(byte[] chunk, int chunkIndex, boolean cached) throws IOException; - void writeHeader(byte id, byte[] bytes, boolean cached) throws IOException; + void writeHeader(int id, byte[] bytes, boolean cached) throws IOException; void streamError(int chunkIndex, short code); } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileFetch.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileFetch.java index 3ca97fcb..aeca5cbe 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileFetch.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileFetch.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.cache.CacheManager; import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.player.AbsChunkedInputStream; +import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; import java.io.IOException; import java.nio.ByteBuffer; @@ -36,7 +36,7 @@ public void writeChunk(byte[] chunk, int chunkIndex, boolean cached) { } @Override - public synchronized void writeHeader(byte id, byte[] bytes, boolean cached) throws IOException { + public synchronized void writeHeader(int id, byte[] bytes, boolean cached) throws IOException { if (closed) return; if (!cached && cache != null) { @@ -44,7 +44,7 @@ public synchronized void writeHeader(byte id, byte[] bytes, boolean cached) thro cache.setHeader(id, bytes); } catch (IOException ex) { if (id == HEADER_SIZE) throw new IOException(ex); - else LOGGER.warn(String.format("Failed writing header to cache! {id: %s}", Utils.byteToHex(id))); + else LOGGER.warn(String.format("Failed writing header to cache! {id: %s}", Utils.byteToHex((byte) id))); } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java index 4f36a6ed..0b5cb696 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java @@ -10,13 +10,13 @@ import xyz.gianlu.librespot.common.NameThreadFactory; import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.player.AbsChunkedInputStream; -import xyz.gianlu.librespot.player.GeneralAudioStream; -import xyz.gianlu.librespot.player.HaltListener; import xyz.gianlu.librespot.player.Player; import xyz.gianlu.librespot.player.codecs.SuperAudioFormat; import xyz.gianlu.librespot.player.decrypt.AesAudioDecrypt; import xyz.gianlu.librespot.player.decrypt.AudioDecrypt; +import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; +import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; +import xyz.gianlu.librespot.player.feeders.HaltListener; import java.io.Closeable; import java.io.IOException; @@ -138,7 +138,7 @@ public void writeChunk(byte[] buffer, int chunkIndex, boolean cached) throws IOE } @Override - public void writeHeader(byte id, byte[] bytes, boolean cached) { + public void writeHeader(int id, byte[] bytes, boolean cached) { // Not interested } @@ -176,10 +176,8 @@ private class ChunksBuffer implements Closeable { void writeChunk(@NotNull byte[] chunk, int chunkIndex) throws IOException { if (internalStream.isClosed()) return; - if (chunk.length != buffer[chunkIndex].length) { - System.out.println(Utils.bytesToHex(chunk)); + if (chunk.length != buffer[chunkIndex].length) throw new IllegalArgumentException(String.format("Buffer size mismatch, required: %d, received: %d, index: %d", buffer[chunkIndex].length, chunk.length, chunkIndex)); - } audioDecrypt.decryptChunk(chunkIndex, chunk, buffer[chunkIndex]); internalStream.notifyChunkAvailable(chunkIndex); @@ -207,7 +205,7 @@ protected byte[][] buffer() { } @Override - protected int size() { + public int size() { return size; } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java index 4a11922c..0c134dae 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java @@ -5,8 +5,8 @@ import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.crypto.Packet; -import xyz.gianlu.librespot.player.HaltListener; -import xyz.gianlu.librespot.player.NormalizationData; +import xyz.gianlu.librespot.player.codecs.NormalizationData; +import xyz.gianlu.librespot.player.feeders.HaltListener; import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; import java.io.IOException; @@ -20,8 +20,11 @@ public final class StorageFeedHelper { private StorageFeedHelper() { } - public static @NotNull PlayableContentFeeder.LoadedStream loadTrack(@NotNull Session session, @NotNull Metadata.Track track, @NotNull Metadata.AudioFile file, @Nullable HaltListener haltListener) throws IOException { + public static @NotNull PlayableContentFeeder.LoadedStream loadTrack(@NotNull Session session, @NotNull Metadata.Track track, @NotNull Metadata.AudioFile file, boolean preload, @Nullable HaltListener haltListener) throws IOException { + long start = System.currentTimeMillis(); byte[] key = session.audioKey().getAudioKey(track.getGid(), file.getFileId()); + int audioKeyTime = (int) (System.currentTimeMillis() - start); + AudioFileStreaming stream = new AudioFileStreaming(session, file, key, haltListener); stream.open(); @@ -31,11 +34,14 @@ private StorageFeedHelper() { NormalizationData normalizationData = NormalizationData.read(in); if (in.skip(0xa7) != 0xa7) throw new IOException("Couldn't skip 0xa7 bytes!"); - return new PlayableContentFeeder.LoadedStream(track, stream, normalizationData); + return new PlayableContentFeeder.LoadedStream(track, stream, normalizationData, new PlayableContentFeeder.Metrics(file.getFileId(), preload, preload ? -1 : audioKeyTime)); } - public static @NotNull PlayableContentFeeder.LoadedStream loadEpisode(@NotNull Session session, Metadata.@NotNull Episode episode, Metadata.@NotNull AudioFile file, @Nullable HaltListener haltListener) throws IOException { + public static @NotNull PlayableContentFeeder.LoadedStream loadEpisode(@NotNull Session session, Metadata.@NotNull Episode episode, Metadata.@NotNull AudioFile file, boolean preload, @Nullable HaltListener haltListener) throws IOException { + long start = System.currentTimeMillis(); byte[] key = session.audioKey().getAudioKey(episode.getGid(), file.getFileId()); + int audioKeyTime = (int) (System.currentTimeMillis() - start); + AudioFileStreaming stream = new AudioFileStreaming(session, file, key, haltListener); stream.open(); @@ -43,6 +49,6 @@ private StorageFeedHelper() { NormalizationData normalizationData = NormalizationData.read(in); if (in.skip(0xa7) != 0xa7) throw new IOException("Couldn't skip 0xa7 bytes!"); - return new PlayableContentFeeder.LoadedStream(episode, stream, normalizationData); + return new PlayableContentFeeder.LoadedStream(episode, stream, normalizationData, new PlayableContentFeeder.Metrics(file.getFileId(), preload, preload ? -1 : audioKeyTime)); } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java b/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java new file mode 100644 index 00000000..b11dd1a7 --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java @@ -0,0 +1,304 @@ +package xyz.gianlu.librespot.player.mixing; + +import org.apache.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.codecs.Codec; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.FloatControl; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.SourceDataLine; +import java.io.*; + +/** + * @author devgianlu + */ +public final class AudioSink implements Runnable, Closeable { + public static final AudioFormat OUTPUT_FORMAT = new AudioFormat(44100, 16, 2, true, false); + private static final Logger LOGGER = Logger.getLogger(AudioSink.class); + private final Object pauseLock = new Object(); + private final Output output; + private final MixingLine mixing = new MixingLine(OUTPUT_FORMAT); + private final Thread thread; + private final Listener listener; + private volatile boolean closed = false; + private volatile boolean paused = true; + + /** + * Creates a new sink from the current {@param conf} with the standard {@link AudioSink#OUTPUT_FORMAT} format. Also sets the initial volume. + */ + public AudioSink(@NotNull Player.Configuration conf, @NotNull Listener listener) { + this.listener = listener; + switch (conf.output()) { + case MIXER: + output = new Output(Output.Type.MIXER, mixing, conf, null, null); + break; + case PIPE: + File pipe = conf.outputPipe(); + if (pipe == null || !pipe.exists() || !pipe.canWrite()) + throw new IllegalArgumentException("Invalid pipe file: " + pipe); + + output = new Output(Output.Type.PIPE, mixing, conf, pipe, null); + break; + case STDOUT: + output = new Output(Output.Type.STREAM, mixing, conf, null, System.out); + break; + default: + throw new IllegalArgumentException("Unknown output: " + conf.output()); + } + + output.setVolume(conf.initialVolume()); + + thread = new Thread(this, "player-audio-sink"); + thread.start(); + } + + /** + * @return The {@link AudioFormat} that this sink accepts + */ + @NotNull + public AudioFormat getFormat() { + return output.getFormat(); + } + + public void clearOutputs() { + mixing.firstOut().clear(); + mixing.secondOut().clear(); + } + + /** + * @return A free output stream or {@code null} if both are in use. + */ + @Nullable + public MixingLine.MixingOutput someOutput() { + return mixing.someOut(); + } + + /** + * Resumes the sink. + */ + public void resume() { + paused = false; + synchronized (pauseLock) { + pauseLock.notifyAll(); + } + } + + /** + * Pauses the sink and then releases the {@link javax.sound.sampled.Line} if specified by {@param release}. + * + * @return Whether the line was released. + */ + public boolean pause(boolean release) { + paused = true; + + if (release) return output.releaseLine(); + else return false; + } + + /** + * Flushes the sink. + */ + public void flush() { + output.flush(); + } + + /** + * Sets the volume accordingly. + * + * @param volume The volume value from 0 to {@link Player#VOLUME_MAX}, inclusive. + */ + public void setVolume(int volume) { + if (volume < 0 || volume > Player.VOLUME_MAX) + throw new IllegalArgumentException("Invalid volume: " + volume); + + output.setVolume(volume); + } + + @Override + public void close() { + closed = true; + thread.interrupt(); + + clearOutputs(); + } + + @Override + public void run() { + byte[] buffer = new byte[Codec.BUFFER_SIZE * 2]; + + boolean started = false; + while (!closed) { + if (paused) { + output.stop(); + started = false; + + synchronized (pauseLock) { + try { + pauseLock.wait(); + } catch (InterruptedException ex) { + break; + } + } + } else { + if (!started) + started = output.start(); + + try { + int count = mixing.read(buffer); + output.write(buffer, count); + } catch (IOException | LineUnavailableException ex) { + if (closed) break; + + pause(true); + listener.sinkError(ex); + } + } + } + + try { + output.drain(); + output.close(); + } catch (IOException ignored) { + } + } + + public interface Listener { + void sinkError(@NotNull Exception ex); + } + + private static class Output implements Closeable { + private final File pipe; + private final MixingLine mixing; + private final Player.Configuration conf; + private final Type type; + private SourceDataLine line; + private OutputStream out; + private int lastVolume = -1; + + Output(@NotNull Type type, @NotNull MixingLine mixing, @NotNull Player.Configuration conf, @Nullable File pipe, @Nullable OutputStream out) { + this.conf = conf; + this.mixing = mixing; + this.type = type; + this.pipe = pipe; + this.out = out; + + if (type == Type.PIPE && pipe == null) + throw new IllegalArgumentException("Pipe cannot be null!"); + + if (type == Type.STREAM && out == null) + throw new IllegalArgumentException("Output stream cannot be null!"); + } + + private static float calcLogarithmic(int val) { + return (float) (Math.log10((double) val / Player.VOLUME_MAX) * 20f); + } + + private void acquireLine() throws LineUnavailableException { + if (line != null) return; + + line = LineHelper.getLineFor(conf, OUTPUT_FORMAT); + line.open(OUTPUT_FORMAT); + + if (lastVolume != -1) setVolume(lastVolume); + } + + void flush() { + if (line != null) line.flush(); + } + + void stop() { + if (line != null) line.stop(); + } + + boolean start() { + if (line != null) { + line.start(); + return true; + } + + return false; + } + + void write(byte[] buffer, int len) throws IOException, LineUnavailableException { + if (type == Type.MIXER) { + acquireLine(); + line.write(buffer, 0, len); + } else if (type == Type.PIPE) { + if (out == null) { + if (!pipe.exists()) { + try { + Process p = new ProcessBuilder().command("mkfifo " + pipe.getAbsolutePath()) + .redirectError(ProcessBuilder.Redirect.INHERIT).start(); + p.waitFor(); + if (p.exitValue() != 0) + LOGGER.warn(String.format("Failed creating pipe! {exit: %d}", p.exitValue())); + else + LOGGER.info("Created pipe: " + pipe); + } catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + } + + out = new FileOutputStream(pipe, true); + } + + out.write(buffer, 0, len); + } else if (type == Type.STREAM) { + out.write(buffer, 0, len); + } else { + throw new IllegalStateException(); + } + } + + void drain() { + if (line != null) line.drain(); + } + + @Override + public void close() throws IOException { + if (line != null) { + line.close(); + line = null; + } + + if (out != null) out.close(); + } + + @NotNull + public AudioFormat getFormat() { + if (line != null) return line.getFormat(); + else return OUTPUT_FORMAT; + } + + void setVolume(int volume) { + lastVolume = volume; + + if (line != null) { + FloatControl ctrl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN); + if (ctrl != null) { + mixing.setGlobalGain(1); + ctrl.setValue(calcLogarithmic(volume)); + return; + } + } + + // Cannot set volume through line + mixing.setGlobalGain(((float) volume) / Player.VOLUME_MAX); + } + + boolean releaseLine() { + if (line == null) return false; + + line.close(); + line = null; + return true; + } + + enum Type { + MIXER, PIPE, STREAM + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java b/core/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java index 9c1f7f18..59fba6e9 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java @@ -5,7 +5,6 @@ import xyz.gianlu.librespot.common.Utils; import java.io.Closeable; -import java.io.IOException; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; @@ -39,8 +38,8 @@ protected void awaitData(int count) throws InterruptedException { awaitData.await(100, TimeUnit.MILLISECONDS); } - public void write(byte[] b, int off, int len) throws IOException { - if (closed) throw new IOException("Buffer is closed!"); + public void write(byte[] b, int off, int len) { + if (closed) throw new IllegalStateException("Buffer is closed!"); lock.lock(); @@ -55,16 +54,15 @@ public void write(byte[] b, int off, int len) throws IOException { } awaitData.signal(); - } catch (InterruptedException ex) { - throw new IOException(ex); + } catch (InterruptedException ignored) { } finally { lock.unlock(); } } @TestOnly - public void write(byte value) throws IOException { - if (closed) throw new IOException("Buffer is closed!"); + public void write(byte value) { + if (closed) throw new IllegalStateException("Buffer is closed!"); lock.lock(); @@ -77,14 +75,13 @@ public void write(byte value) throws IOException { tail = 0; awaitData.signal(); - } catch (InterruptedException ex) { - throw new IOException(ex); + } catch (InterruptedException ignored) { } finally { lock.unlock(); } } - public int read(byte[] b, int off, int len) throws IOException { + public int read(byte[] b, int off, int len) { if (closed) return -1; lock.lock(); @@ -101,8 +98,9 @@ public int read(byte[] b, int off, int len) throws IOException { awaitSpace.signal(); return dest - off; - } catch (InterruptedException ex) { - throw new IOException(ex); + } catch (InterruptedException ignored) { + if (closed) return -1; + else return 0; } finally { lock.unlock(); } @@ -122,7 +120,7 @@ protected int readInternal() { * @return a byte from the buffer. */ @TestOnly - public int read() throws IOException { + public int read() { if (closed) return -1; lock.lock(); @@ -134,8 +132,8 @@ public int read() throws IOException { int value = readInternal(); awaitSpace.signal(); return value; - } catch (InterruptedException ex) { - throw new IOException(ex); + } catch (InterruptedException ignored) { + return -1; } finally { lock.unlock(); } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java b/core/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java index 41bd1546..f4852f44 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java @@ -1,7 +1,5 @@ package xyz.gianlu.librespot.player.mixing; -import java.io.IOException; - /** * @author Gianlu */ @@ -10,7 +8,7 @@ class GainAwareCircularBuffer extends CircularBuffer { super(bufferSize); } - void readGain(byte[] b, int off, int len, float gain) throws IOException { + void readGain(byte[] b, int off, int len, float gain) { if (closed) return; lock.lock(); @@ -33,14 +31,13 @@ void readGain(byte[] b, int off, int len, float gain) throws IOException { } awaitSpace.signal(); - } catch (InterruptedException ex) { - throw new IOException(ex); + } catch (InterruptedException ignored) { } finally { lock.unlock(); } } - void readMergeGain(byte[] b, int off, int len, float gg, float fg, float sg) throws IOException { + void readMergeGain(byte[] b, int off, int len, float gg, float fg, float sg) { if (closed) return; lock.lock(); @@ -69,8 +66,7 @@ void readMergeGain(byte[] b, int off, int len, float gg, float fg, float sg) thr } awaitSpace.signal(); - } catch (InterruptedException ex) { - throw new IOException(ex); + } catch (InterruptedException ignored) { } finally { lock.unlock(); } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java b/core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java index fe21a407..f8c4adb0 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java @@ -2,17 +2,17 @@ import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.player.codecs.Codec; import javax.sound.sampled.AudioFormat; -import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; /** * @author Gianlu */ -public class MixingLine extends InputStream { +public final class MixingLine extends InputStream { private static final Logger LOGGER = Logger.getLogger(MixingLine.class); private final AudioFormat format; private GainAwareCircularBuffer fcb; @@ -38,7 +38,7 @@ public int read() { } @Override - public synchronized int read(@NotNull byte[] b, int off, int len) throws IOException { + public synchronized int read(@NotNull byte[] b, int off, int len) { if (fe && fcb != null && se && scb != null) { int willRead = Math.min(fcb.available(), scb.available()); willRead = Math.min(willRead, len); @@ -49,13 +49,20 @@ public synchronized int read(@NotNull byte[] b, int off, int len) throws IOExcep return willRead; } else if (fe && fcb != null) { fcb.readGain(b, off, len, gg * fg); + return len; } else if (se && scb != null) { scb.readGain(b, off, len, gg * sg); + return len; } else { - for (int i = off; i < len - off; i++) b[i] = 0; + return 0; } + } - return len; + @Nullable + public MixingOutput someOut() { + if (fout == null) return firstOut(); + else if (sout == null) return secondOut(); + else return null; } @NotNull @@ -87,7 +94,7 @@ public interface MixingOutput { void gain(float gain); - void write(byte[] buffer, int off, int len) throws IOException; + void write(byte[] buffer, int off, int len); void clear(); @@ -108,7 +115,7 @@ public final void write(int b) { } @Override - public final void write(@NotNull byte[] b, int off, int len) throws IOException { + public final void write(@NotNull byte[] b, int off, int len) { buffer.write(b, off, len); } @@ -125,21 +132,21 @@ public class FirstOutputStream extends LowLevelStream implements MixingOutput { @Override public void toggle(boolean enabled) { if (enabled == fe) return; - if (enabled && fout != this) throw new IllegalArgumentException(); + if (enabled && fout != null && fout != this) throw new IllegalArgumentException(); fe = enabled; LOGGER.trace("Toggle first channel: " + enabled); } @Override public void gain(float gain) { - if (fout != this) throw new IllegalArgumentException(); + if (fout != null && fout != this) throw new IllegalArgumentException(); fg = gain; } @Override @SuppressWarnings("DuplicatedCode") public void clear() { - if (fout != this) throw new IllegalArgumentException(); + if (fout != null && fout != this) throw new IllegalArgumentException(); fg = 1; fe = false; @@ -168,21 +175,21 @@ public class SecondOutputStream extends LowLevelStream implements MixingOutput { @Override public void toggle(boolean enabled) { if (enabled == se) return; - if (enabled && sout != this) throw new IllegalArgumentException(); + if (enabled && sout != null && sout != this) throw new IllegalArgumentException(); se = enabled; LOGGER.trace("Toggle second channel: " + enabled); } @Override public void gain(float gain) { - if (sout != this) throw new IllegalArgumentException(); + if (sout != null && sout != this) throw new IllegalArgumentException(); sg = gain; } @Override @SuppressWarnings("DuplicatedCode") public void clear() { - if (sout != this) throw new IllegalArgumentException(); + if (sout != null && sout != this) throw new IllegalArgumentException(); sg = 1; se = false; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java new file mode 100644 index 00000000..95186baa --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java @@ -0,0 +1,45 @@ +package xyz.gianlu.librespot.player.playback; + +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.player.codecs.Codec; +import xyz.gianlu.librespot.player.codecs.Mp3Codec; +import xyz.gianlu.librespot.player.codecs.VorbisCodec; +import xyz.gianlu.librespot.player.crossfade.CrossfadeController; +import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; + +import javax.sound.sampled.AudioFormat; + +/** + * @author devgianlu + */ +public final class PlayerMetrics { + public final PlayableContentFeeder.Metrics contentMetrics; + public int decodedLength = 0; + public int size = 0; + public int bitrate = 0; + public int duration = 0; + public String encoding = null; + public int fadeOverlap = 0; + public String transition = "none"; + + PlayerMetrics(@Nullable PlayableContentFeeder.Metrics contentMetrics, @Nullable CrossfadeController crossfade, @Nullable Codec codec) { + this.contentMetrics = contentMetrics; + + if (codec != null) { + size = codec.size(); + duration = codec.duration(); + decodedLength = codec.decodedLength(); + + AudioFormat format = codec.getAudioFormat(); + bitrate = (int) (format.getFrameRate() * format.getFrameSize()); + + if (codec instanceof VorbisCodec) encoding = "vorbis"; + else if (codec instanceof Mp3Codec) encoding = "mp3"; + } + + if (crossfade != null) { + transition = "crossfade"; + fadeOverlap = crossfade.fadeOverlap(); + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java new file mode 100644 index 00000000..01066f83 --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java @@ -0,0 +1,196 @@ +package xyz.gianlu.librespot.player.playback; + +import org.apache.log4j.Logger; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.NameThreadFactory; + +import java.io.Closeable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Handles the queue of entries. Responsible for next/prev operations and executing each entry on the executor. + * + * @author devgianlu + */ +final class PlayerQueue implements Closeable { + private static final Logger LOGGER = Logger.getLogger(PlayerQueue.class); + private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory((r) -> "player-queue-" + r.hashCode())); + private PlayerQueueEntry head = null; + + PlayerQueue() { + } + + /** + * @return The next element after the head + */ + @Nullable + @Contract(pure = true) + synchronized PlayerQueueEntry next() { + if (head == null || head.next == null) return null; + else return head.next; + } + + /** + * @return The head of the queue + */ + @Nullable + @Contract(pure = true) + synchronized PlayerQueueEntry head() { + return head; + } + + /** + * @return The next element after the head + */ + @Nullable + @Contract(pure = true) + synchronized PlayerQueueEntry prev() { + if (head == null || head.prev == null) return null; + else return head.prev; + } + + /** + * Adds an entry to the queue and executes it. + * + * @param entry The entry to add + */ + synchronized void add(@NotNull PlayerQueueEntry entry) { + if (head == null) head = entry; + else head.setNext(entry); + executorService.execute(entry); + + LOGGER.trace(String.format("%s added to queue.", entry)); + } + + /** + * Swap two entries, closing the old one in any case. + * + * @param oldEntry The old entry + * @param newEntry The new entry + */ + synchronized void swap(@NotNull PlayerQueueEntry oldEntry, @NotNull PlayerQueueEntry newEntry) { + if (head == null) return; + + boolean swapped; + if (head == oldEntry) { + head = newEntry; + head.next = oldEntry.next; + head.prev = oldEntry.prev; + swapped = true; + } else { + swapped = head.swap(oldEntry, newEntry); + } + + oldEntry.close(); + if (swapped) { + executorService.execute(newEntry); + LOGGER.trace(String.format("%s swapped with %s.", oldEntry, newEntry)); + } + } + + /** + * Removes the specified entry from the queue and closes it. + * + * @param entry The entry to remove + */ + synchronized void remove(@NotNull PlayerQueueEntry entry) { + if (head == null) return; + + boolean removed; + if (head == entry) { + PlayerQueueEntry tmp = head; + head = tmp.next; + tmp.close(); + removed = true; + } else { + removed = head.remove(entry); + } + + if (removed) LOGGER.trace(String.format("%s removed from queue.", entry)); + } + + /** + * Tries to advance in the queue. + * + * @return If the operation was successful. If {@code true} the head will surely be non-null, always {@code null} otherwise. + */ + synchronized boolean advance() { + if (head == null || head.next == null) + return false; + + PlayerQueueEntry tmp = head.next; + head.next = null; + head.prev = null; + if (!head.closeIfUseless()) tmp.prev = head; + head = tmp; + return true; + } + + /** + * Clear the queue by closing every entry and shutdown the executor service. + */ + @Override + public void close() { + if (head != null) head.clear(); + executorService.shutdown(); + + LOGGER.trace("Queue has been cleared."); + } + + abstract static class Entry { + PlayerQueueEntry next = null; + PlayerQueueEntry prev = null; + + void setNext(@NotNull PlayerQueueEntry entry) { + if (next == null) { + next = entry; + entry.prev = (PlayerQueueEntry) this; + } else { + next.setNext(entry); + } + } + + boolean remove(@NotNull PlayerQueueEntry entry) { + if (next == null) return false; + if (next == entry) { + PlayerQueueEntry tmp = next; + next = tmp.next; + tmp.close(); + return true; + } else { + return next.remove(entry); + } + } + + boolean swap(@NotNull PlayerQueueEntry oldEntry, @NotNull PlayerQueueEntry newEntry) { + if (next == null) return false; + if (next == oldEntry) { + next = newEntry; + next.prev = oldEntry.prev; + next.next = oldEntry.next; + return true; + } else { + return next.swap(oldEntry, newEntry); + } + } + + void clear() { + if (prev != null) { + prev.clear(); + prev.close(); + prev = null; + } + + if (next != null) { + next.clear(); + next.close(); + next = null; + } + + ((PlayerQueueEntry) this).close(); + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java new file mode 100644 index 00000000..170f8290 --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java @@ -0,0 +1,442 @@ +package xyz.gianlu.librespot.player.playback; + +import javazoom.jl.decoder.BitstreamException; +import org.apache.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.Utils; +import xyz.gianlu.librespot.core.EventService.PlaybackMetrics; +import xyz.gianlu.librespot.core.Session; +import xyz.gianlu.librespot.mercury.MercuryClient; +import xyz.gianlu.librespot.mercury.model.EpisodeId; +import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.mercury.model.TrackId; +import xyz.gianlu.librespot.player.ContentRestrictedException; +import xyz.gianlu.librespot.player.StateWrapper; +import xyz.gianlu.librespot.player.TrackOrEpisode; +import xyz.gianlu.librespot.player.codecs.Codec; +import xyz.gianlu.librespot.player.codecs.Mp3Codec; +import xyz.gianlu.librespot.player.codecs.VorbisCodec; +import xyz.gianlu.librespot.player.codecs.VorbisOnlyAudioQuality; +import xyz.gianlu.librespot.player.crossfade.CrossfadeController; +import xyz.gianlu.librespot.player.feeders.HaltListener; +import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; +import xyz.gianlu.librespot.player.feeders.cdn.CdnManager; +import xyz.gianlu.librespot.player.mixing.MixingLine; + +import javax.sound.sampled.AudioFormat; +import java.io.Closeable; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.Comparator; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; + +/** + * An object representing one single content/track/episode associated with its playback ID. This is responsible for IO operations, + * decoding, metrics, crossfade and instant notifications. + * + * @author devgianlu + */ +class PlayerQueueEntry extends PlayerQueue.Entry implements Closeable, Runnable, HaltListener { + static final int INSTANT_PRELOAD = 1; + static final int INSTANT_START_NEXT = 2; + static final int INSTANT_END = 3; + private static final Logger LOGGER = Logger.getLogger(PlayerQueueEntry.class); + final PlayableId playable; + final String playbackId; + private final boolean preloaded; + private final Listener listener; + private final Object playbackLock = new Object(); + private final TreeMap notifyInstants = new TreeMap<>(Comparator.comparingInt(o -> o)); + private final Session session; + private final AudioFormat format; + CrossfadeController crossfade; + PlaybackMetrics.Reason endReason = PlaybackMetrics.Reason.END_PLAY; + private Codec codec; + private TrackOrEpisode metadata; + private volatile boolean closed = false; + private volatile MixingLine.MixingOutput output; + private long playbackHaltedAt = 0; + private volatile int seekTime = -1; + private boolean retried = false; + private PlayableContentFeeder.Metrics contentMetrics; + + PlayerQueueEntry(@NotNull Session session, @NotNull AudioFormat format, @NotNull PlayableId playable, boolean preloaded, @NotNull Listener listener) { + this.session = session; + this.format = format; + this.playbackId = StateWrapper.generatePlaybackId(session.random()); + this.playable = playable; + this.preloaded = preloaded; + this.listener = listener; + + LOGGER.trace(String.format("Created new %s.", this)); + } + + @NotNull + PlayerQueueEntry retrySelf(boolean preloaded) { + if (retried) throw new IllegalStateException(); + + PlayerQueueEntry retry = new PlayerQueueEntry(session, format, playable, preloaded, listener); + retry.retried = true; + return retry; + } + + /** + * Loads the content described by this entry. + * + * @throws ContentRestrictedException If the content cannot be retrieved because of restrictions (this condition won't change with a retry). + */ + private void load(boolean preload) throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { + PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(session.conf().preferredQuality()), preload, this); + metadata = new TrackOrEpisode(stream.track, stream.episode); + contentMetrics = stream.metrics; + + if (playable instanceof EpisodeId && stream.episode != null) { + LOGGER.info(String.format("Loaded episode. {name: '%s', uri: %s, id: %s}", stream.episode.getName(), playable.toSpotifyUri(), playbackId)); + } else if (playable instanceof TrackId && stream.track != null) { + LOGGER.info(String.format("Loaded track. {name: '%s', artists: '%s', uri: %s, id: %s}", stream.track.getName(), + Utils.artistsToString(stream.track.getArtistList()), playable.toSpotifyUri(), playbackId)); + } + + crossfade = new CrossfadeController(playbackId, metadata.duration(), listener.metadataFor(playable), session.conf()); + if (crossfade.hasAnyFadeOut() || session.conf().preloadEnabled()) + notifyInstant(INSTANT_PRELOAD, (int) (crossfade.fadeOutStartTimeMin() - TimeUnit.SECONDS.toMillis(20))); + + switch (stream.in.codec()) { + case VORBIS: + codec = new VorbisCodec(format, stream.in, stream.normalizationData, session.conf(), metadata.duration()); + break; + case MP3: + try { + codec = new Mp3Codec(format, stream.in, stream.normalizationData, session.conf(), metadata.duration()); + } catch (BitstreamException ex) { + throw new IOException(ex); + } + break; + default: + throw new UnsupportedEncodingException(stream.in.codec().toString()); + } + + LOGGER.trace(String.format("Loaded %s codec. {of: %s, format: %s, playbackId: %s}", stream.in.codec(), stream.in.describe(), codec.getAudioFormat(), playbackId)); + } + + /** + * Gets the metadata associated with this entry. + * + * @return A {@link TrackOrEpisode} object or {@code null} if not loaded yet + */ + @Nullable + public TrackOrEpisode metadata() { + return metadata; + } + + /** + * Returns the metrics for this entry. + * + * @return A {@link PlayerMetrics} object + */ + @NotNull + PlayerMetrics metrics() { + return new PlayerMetrics(contentMetrics, crossfade, codec); + } + + /** + * Returns the current position. + * + * @return The current position of the player or {@code -1} if not ready. + * @throws Codec.CannotGetTimeException If the time is unavailable for the codec being used. + */ + int getTime() throws Codec.CannotGetTimeException { + return codec == null ? -1 : codec.time(); + } + + /** + * Returns the current position. + * + * @return The current position of the player or {@code -1} if not available. + * @see PlayerQueueEntry#getTime() + */ + int getTimeNoThrow() { + try { + return getTime(); + } catch (Codec.CannotGetTimeException e) { + return -1; + } + } + + /** + * Seeks to the specified position. + * + * @param pos The time in milliseconds + */ + void seek(int pos) { + seekTime = pos; + if (output != null) output.stream().emptyBuffer(); + } + + /** + * Sets the output to {@param output}. As soon as this method returns the entry will start playing. + * + * @throws IllegalStateException If the output is already set. Will also clear {@param output}. + */ + void setOutput(@NotNull MixingLine.MixingOutput output) { + if (closed || this.output != null) { + output.clear(); + throw new IllegalStateException("Cannot set output for " + this); + } + + synchronized (playbackLock) { + this.output = output; + playbackLock.notifyAll(); + } + + this.output.toggle(true); + } + + /** + * Removes the output. As soon as this method is called the entry will stop playing. + */ + private void clearOutput() { + if (output != null) { + MixingLine.MixingOutput tmp = output; + output = null; + + tmp.toggle(false); + tmp.clear(); + + LOGGER.debug(String.format("%s has been removed from output.", this)); + } + + synchronized (playbackLock) { + playbackLock.notifyAll(); + } + } + + /** + * Instructs to notify when this time instant is reached. + * + * @param callbackId The callback ID + * @param when The time in milliseconds + */ + void notifyInstant(int callbackId, int when) { + if (codec != null) { + try { + int time = codec.time(); + if (time >= when) { + listener.instantReached(this, callbackId, time); + return; + } + } catch (Codec.CannotGetTimeException ex) { + return; + } + } + + notifyInstants.put(when, callbackId); + } + + @Override + public void run() { + listener.startedLoading(this); + + try { + load(preloaded); + } catch (IOException | ContentRestrictedException | CdnManager.CdnException | MercuryClient.MercuryException | Codec.CodecException ex) { + close(); + listener.loadingError(this, ex, retried); + LOGGER.trace(String.format("%s terminated at loading.", this), ex); + return; + } + + if (seekTime != -1) { + codec.seek(seekTime); + seekTime = -1; + } + + listener.finishedLoading(this, metadata); + + boolean canGetTime = true; + while (!closed) { + if (output == null) { + synchronized (playbackLock) { + try { + playbackLock.wait(); + } catch (InterruptedException ex) { + break; + } + } + + if (output == null) continue; + } + + if (closed) break; + + if (seekTime != -1) { + codec.seek(seekTime); + seekTime = -1; + } + + if (canGetTime) { + try { + int time = codec.time(); + if (!notifyInstants.isEmpty()) checkInstants(time); + if (output == null) + continue; + + output.gain(crossfade.getGain(time)); + } catch (Codec.CannotGetTimeException ex) { + canGetTime = false; + } + } + + try { + if (codec.writeSomeTo(output.stream()) == -1) { + try { + int time = codec.time(); + LOGGER.debug(String.format("Player time offset is %d. {id: %s}", metadata.duration() - time, playbackId)); + } catch (Codec.CannotGetTimeException ignored) { + } + + close(); + break; + } + } catch (IOException | Codec.CodecException ex) { + if (!closed) { + close(); + listener.playbackError(this, ex); + return; + } + + break; + } + } + + listener.playbackEnded(this); + LOGGER.trace(String.format("%s terminated.", this)); + } + + private void checkInstants(int time) { + int key = notifyInstants.firstKey(); + if (time >= key) { + int callbackId = notifyInstants.remove(key); + listener.instantReached(this, callbackId, time); + if (!notifyInstants.isEmpty()) checkInstants(time); + } + } + + /** + * Close this entry if it's not attached to an output. + * + * @return Whether it has been closed + */ + boolean closeIfUseless() { + if (output == null) { + close(); + return true; + } + + return false; + } + + @Override + public void close() { + closed = true; + clearOutput(); + } + + @Override + public void streamReadHalted(int chunk, long time) { + playbackHaltedAt = time; + listener.playbackHalted(this, chunk); + } + + @Override + public void streamReadResumed(int chunk, long time) { + if (playbackHaltedAt == 0) return; + + int duration = (int) (time - playbackHaltedAt); + listener.playbackResumed(this, chunk, duration); + } + + @Override + public String toString() { + return "PlayerQueueEntry{" + playbackId + "}"; + } + + interface Listener { + /** + * An error occurred during playback. + * + * @param entry The {@link PlayerQueueEntry} that called this + * @param ex The exception thrown + */ + void playbackError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex); + + /** + * The playback of the current entry ended. + * + * @param entry The {@link PlayerQueueEntry} that called this + */ + void playbackEnded(@NotNull PlayerQueueEntry entry); + + /** + * The playback halted while trying to receive a chunk. + * + * @param entry The {@link PlayerQueueEntry} that called this + * @param chunk The chunk that is being retrieved + */ + void playbackHalted(@NotNull PlayerQueueEntry entry, int chunk); + + /** + * The playback resumed from halt. + * + * @param entry The {@link PlayerQueueEntry} that called this + * @param chunk The chunk that was being retrieved + * @param diff The time taken to retrieve the chunk + */ + void playbackResumed(@NotNull PlayerQueueEntry entry, int chunk, int diff); + + /** + * Notify that a previously request instant has been reached. This is called from the runner, be careful. + * + * @param entry The {@link PlayerQueueEntry} that called this + * @param callbackId The callback ID for the instant + * @param exactTime The exact time the instant was reached + */ + void instantReached(@NotNull PlayerQueueEntry entry, int callbackId, int exactTime); + + /** + * The track started loading. + * + * @param entry The {@link PlayerQueueEntry} that called this + */ + void startedLoading(@NotNull PlayerQueueEntry entry); + + /** + * The track failed loading. + * + * @param entry The {@link PlayerQueueEntry} that called this + * @param ex The exception thrown + * @param retried Whether this is the second time an error occurs + */ + void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, boolean retried); + + /** + * The track finished loading. + * + * @param entry The {@link PlayerQueueEntry} that called this + * @param metadata The {@link TrackOrEpisode} object + */ + void finishedLoading(@NotNull PlayerQueueEntry entry, @NotNull TrackOrEpisode metadata); + + /** + * Get the metadata for this content. + * + * @param playable The content + * @return A map containing all the metadata related + */ + @NotNull + Map metadataFor(@NotNull PlayableId playable); + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java new file mode 100644 index 00000000..1e42a3db --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java @@ -0,0 +1,418 @@ +package xyz.gianlu.librespot.player.playback; + +import org.apache.log4j.Logger; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.NameThreadFactory; +import xyz.gianlu.librespot.core.EventService.PlaybackMetrics.Reason; +import xyz.gianlu.librespot.core.Session; +import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.player.ContentRestrictedException; +import xyz.gianlu.librespot.player.TrackOrEpisode; +import xyz.gianlu.librespot.player.codecs.Codec; +import xyz.gianlu.librespot.player.crossfade.CrossfadeController; +import xyz.gianlu.librespot.player.mixing.AudioSink; +import xyz.gianlu.librespot.player.mixing.MixingLine; + +import java.io.Closeable; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Handles a session which is a container for entries (each with its own playback ID). This is responsible for higher level prev/next operations (using {@link PlayerQueue}, + * receiving and creating instants, dispatching events to the player and operating the sink. + * + * @author devgianlu + */ +public class PlayerSession implements Closeable, PlayerQueueEntry.Listener { + private static final Logger LOGGER = Logger.getLogger(PlayerSession.class); + private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory((r) -> "player-session-" + r.hashCode())); + private final Session session; + private final AudioSink sink; + private final String sessionId; + private final Listener listener; + private final PlayerQueue queue; + private int lastPlayPos = 0; + private Reason lastPlayReason = null; + private volatile boolean closed = false; + + public PlayerSession(@NotNull Session session, @NotNull AudioSink sink, @NotNull String sessionId, @NotNull Listener listener) { + this.session = session; + this.sink = sink; + this.sessionId = sessionId; + this.listener = listener; + this.queue = new PlayerQueue(); + LOGGER.info(String.format("Created new session. {id: %s}", sessionId)); + + sink.clearOutputs(); + add(listener.currentPlayable(), false); + } + + /** + * Creates and adds a new entry to the queue. + * + * @param playable The content for the new entry + */ + private void add(@NotNull PlayableId playable, boolean preloaded) { + queue.add(new PlayerQueueEntry(session, sink.getFormat(), playable, preloaded, this)); + } + + /** + * Adds the next content to the queue. + */ + private void addNext() { + PlayableId playable = listener.nextPlayableDoNotSet(); + if (playable != null) add(playable, true); + } + + /** + * Tries to advance to the given content. This is a destructive operation as it will close every entry that passes by. + * Also checks if the next entry has the same content, in that case it advances (repeating track fix). + * + * @param id The target content + * @return Whether the operation was successful + */ + private boolean advanceTo(@NotNull PlayableId id) { + do { + PlayerQueueEntry entry = queue.head(); + if (entry == null) return false; + if (entry.playable.equals(id)) { + PlayerQueueEntry next = queue.next(); + if (next == null || !next.playable.equals(id)) + return true; + } + } while (queue.advance()); + return false; + } + + /** + * Gets the next content and tries to advance, notifying if successful. + */ + private void advance(@NotNull Reason reason) { + if (closed) return; + + PlayableId next = listener.nextPlayable(); + if (next == null) + return; + + EntryWithPos entry = playInternal(next, 0, reason); + listener.trackChanged(entry.entry.playbackId, entry.entry.metadata(), entry.pos, reason); + } + + @Override + public void instantReached(@NotNull PlayerQueueEntry entry, int callbackId, int exactTime) { + switch (callbackId) { + case PlayerQueueEntry.INSTANT_PRELOAD: + if (entry == queue.head()) executorService.execute(this::addNext); + break; + case PlayerQueueEntry.INSTANT_START_NEXT: + executorService.execute(() -> advance(Reason.TRACK_DONE)); + break; + case PlayerQueueEntry.INSTANT_END: + entry.close(); + break; + default: + throw new IllegalArgumentException("Unknown callback: " + callbackId); + } + } + + @Override + public void playbackEnded(@NotNull PlayerQueueEntry entry) { + listener.trackPlayed(entry.playbackId, entry.endReason, entry.metrics(), entry.getTimeNoThrow()); + + if (entry == queue.head()) + advance(Reason.TRACK_DONE); + } + + @Override + public void startedLoading(@NotNull PlayerQueueEntry entry) { + LOGGER.trace(String.format("%s started loading.", entry)); + if (entry == queue.head()) listener.startedLoading(); + } + + @Override + public void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, boolean retried) { + if (entry == queue.head()) { + if (ex instanceof ContentRestrictedException) { + advance(Reason.TRACK_ERROR); + } else if (!retried) { + PlayerQueueEntry newEntry = entry.retrySelf(false); + executorService.execute(() -> { + queue.swap(entry, newEntry); + playInternal(newEntry.playable, lastPlayPos, lastPlayReason == null ? Reason.TRACK_ERROR : lastPlayReason); + }); + return; + } + + listener.loadingError(ex); + } else if (entry == queue.next()) { + if (!(ex instanceof ContentRestrictedException) && !retried) { + PlayerQueueEntry newEntry = entry.retrySelf(true); + executorService.execute(() -> queue.swap(entry, newEntry)); + return; + } + } + + queue.remove(entry); + } + + @Override + public void finishedLoading(@NotNull PlayerQueueEntry entry, @NotNull TrackOrEpisode metadata) { + LOGGER.trace(String.format("%s finished loading.", entry)); + if (entry == queue.head()) listener.finishedLoading(metadata); + + CrossfadeController.FadeInterval fadeOut; + if (entry.crossfade != null && (fadeOut = entry.crossfade.selectFadeOut(Reason.TRACK_DONE)) != null) + entry.notifyInstant(PlayerQueueEntry.INSTANT_START_NEXT, fadeOut.start()); + } + + @Override + public @NotNull Map metadataFor(@NotNull PlayableId playable) { + return listener.metadataFor(playable); + } + + @Override + public void playbackError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex) { + if (entry == queue.head()) listener.playbackError(ex); + queue.remove(entry); + } + + @Override + public void playbackHalted(@NotNull PlayerQueueEntry entry, int chunk) { + if (entry == queue.head()) listener.playbackHalted(chunk); + } + + @Override + public void playbackResumed(@NotNull PlayerQueueEntry entry, int chunk, int diff) { + if (entry == queue.head()) listener.playbackResumedFromHalt(chunk, diff); + } + + + // ================================ // + // =========== Playback =========== // + // ================================ // + + /** + * Start playing this content by any possible mean. Also sets up crossfade for the previous entry and the current head. + * + * @param playable The content to be played + * @param pos The time in milliseconds + * @param reason The reason why the playback started + */ + @Contract("_, _, _ -> new") + private @NotNull EntryWithPos playInternal(@NotNull PlayableId playable, int pos, @NotNull Reason reason) { + lastPlayPos = pos; + lastPlayReason = reason; + + if (!advanceTo(playable)) { + add(playable, false); + queue.advance(); + } + + PlayerQueueEntry head = queue.head(); + if (head == null) + throw new IllegalStateException(); + + if (head.prev != null) { + head.prev.endReason = reason; + CrossfadeController.FadeInterval fadeOut; + if (head.prev.crossfade == null || (fadeOut = head.prev.crossfade.selectFadeOut(reason)) == null) { + head.prev.close(); + } else { + if (fadeOut instanceof CrossfadeController.PartialFadeInterval) { + try { + int time = head.prev.getTime(); + head.prev.notifyInstant(PlayerQueueEntry.INSTANT_END, ((CrossfadeController.PartialFadeInterval) fadeOut).end(time)); + } catch (Codec.CannotGetTimeException ex) { + head.prev.close(); + } + } else { + head.prev.notifyInstant(PlayerQueueEntry.INSTANT_END, fadeOut.end()); + } + } + } + + MixingLine.MixingOutput out = sink.someOutput(); + if (out == null) + throw new IllegalStateException("No output is available for " + head); + + CrossfadeController.FadeInterval fadeIn; + if (head.crossfade != null && (fadeIn = head.crossfade.selectFadeIn(reason)) != null) { + head.seek(pos = fadeIn.start()); + } else { + head.seek(pos); + } + + head.setOutput(out); + LOGGER.debug(String.format("%s has been added to the output. {sessionId: %s, pos: %d, reason: %s}", head, sessionId, pos, reason)); + return new PlayerSession.EntryWithPos(head, pos); + } + + /** + * Start playing this content by any possible mean. Also sets up crossfade for the previous entry and the current head. + * + * @param playable The content to be played + * @param pos The time in milliseconds + * @param reason The reason why the playback started + * @return The playback ID associated with the head + */ + @NotNull + public String play(@NotNull PlayableId playable, int pos, @NotNull Reason reason) { + return playInternal(playable, pos, reason).entry.playbackId; + } + + /** + * Seek to the specified position on the queue head. + * + * @param pos The time in milliseconds + */ + public void seekCurrent(int pos) { + if (queue.head() == null) return; + + if (queue.prev() != null) queue.remove(queue.prev()); + if (queue.next() != null) queue.remove(queue.next()); + + queue.head().seek(pos); + sink.flush(); + } + + + // ================================ // + // =========== Getters ============ // + // ================================ // + + /** + * @return The {@link PlayerMetrics} for the current entry or {@code null} if not available. + */ + @Nullable + public PlayerMetrics currentMetrics() { + if (queue.head() == null) return null; + else return queue.head().metrics(); + } + + /** + * @return The metadata for the current head or {@code null} if not available. + */ + @Nullable + public TrackOrEpisode currentMetadata() { + if (queue.head() == null) return null; + else return queue.head().metadata(); + } + + /** + * @return The time for the current head or {@code -1} if not available. + * @throws Codec.CannotGetTimeException If the head is available, but time cannot be retrieved + */ + public long currentTime() throws Codec.CannotGetTimeException { + if (queue.head() == null) return -1; + else return queue.head().getTime(); + } + + @Nullable + public String currentPlaybackId() { + if (queue.head() == null) return null; + else return queue.head().playbackId; + } + + /** + * Close the session by clearing the queue which will close all entries. + */ + @Override + public void close() { + closed = true; + queue.close(); + } + + public interface Listener { + @NotNull + PlayableId currentPlayable(); + + @Nullable + PlayableId nextPlayable(); + + @Nullable + PlayableId nextPlayableDoNotSet(); + + /** + * Get the metadata for this content. + * + * @param playable The content + * @return A map containing all the metadata related + */ + @NotNull + Map metadataFor(@NotNull PlayableId playable); + + /** + * The current track playback halted while trying to receive a chunk. + * + * @param chunk The chunk that is being retrieved + */ + void playbackHalted(int chunk); + + /** + * The current track playback resumed from halt. + * + * @param chunk The chunk that was being retrieved + * @param diff The time taken to retrieve the chunk + */ + void playbackResumedFromHalt(int chunk, long diff); + + /** + * The current track started loading. + */ + void startedLoading(); + + /** + * The current track failed loading. + * + * @param ex The exception thrown + */ + void loadingError(@NotNull Exception ex); + + /** + * The current track finished loading. + * + * @param metadata The {@link TrackOrEpisode} object + */ + void finishedLoading(@NotNull TrackOrEpisode metadata); + + /** + * An error occurred during playback of the current track. + * + * @param ex The exception thrown + */ + void playbackError(@NotNull Exception ex); + + /** + * The current track changed. Not called if {@link PlayerSession#playInternal(PlayableId, int, Reason)} is called directly. + * + * @param playbackId The new playback ID + * @param metadata The metadata for the new track + * @param pos The position at which playback started + * @param startedReason The reason why the current track changed + */ + void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode metadata, int pos, @NotNull Reason startedReason); + + /** + * The current entry has finished playing. + * + * @param playbackId The playback ID of this entry + * @param endReason The reason why this track ended + * @param playerMetrics The {@link PlayerMetrics} for this entry + * @param endedAt The time this entry ended + */ + void trackPlayed(@NotNull String playbackId, @NotNull Reason endReason, @NotNull PlayerMetrics playerMetrics, int endedAt); + } + + private static class EntryWithPos { + final PlayerQueueEntry entry; + final int pos; + + EntryWithPos(@NotNull PlayerQueueEntry entry, int pos) { + this.entry = entry; + this.pos = pos; + } + } +} diff --git a/core/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java b/core/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java index 0d04b89c..5aef43f2 100644 --- a/core/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java +++ b/core/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java @@ -4,8 +4,6 @@ import org.junit.jupiter.api.Test; import xyz.gianlu.librespot.player.mixing.CircularBuffer; -import java.io.IOException; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,16 +12,16 @@ */ class CircularBufferTest { - private static void write(@NotNull CircularBuffer b, int count) throws IOException { + private static void write(@NotNull CircularBuffer b, int count) { for (int i = 0; i < count; i++) b.write((byte) i); } - private static void read(@NotNull CircularBuffer b, int count) throws IOException { + private static void read(@NotNull CircularBuffer b, int count) { for (int i = 0; i < count; i++) b.read(); } @Test - void test() throws IOException { + void test() { CircularBuffer b = new CircularBuffer(32); assertEquals(0, b.available()); assertEquals(32, b.free()); diff --git a/core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java b/core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java new file mode 100644 index 00000000..a66cf171 --- /dev/null +++ b/core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java @@ -0,0 +1,32 @@ +package xyz.gianlu.librespot.player.crossfade; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author devgianlu + */ +public class InterpolatorTest { + + @Test + public void testLookup() { + LookupInterpolator interpolator = LookupInterpolator.fromJson(JsonParser.parseString("[{\"x\":0.0,\"y\":1.0},{\"x\":0.0,\"y\":0.4},{\"x\":1.0,\"y\":0.0}]").getAsJsonArray()); + assertEquals(0, interpolator.interpolate(1)); + assertEquals(0.368, interpolator.interpolate(0.08f), 0.0001); + assertEquals(0.272, interpolator.interpolate(0.32f), 0.0001); + assertEquals(1, interpolator.interpolate(0)); + } + + @Test + public void testLinear() { + GainInterpolator interpolator = new LinearIncreasingInterpolator(); + for (float i = 0; i < 1; i += 0.1) + assertEquals(i, interpolator.interpolate(i)); + + interpolator = new LinearDecreasingInterpolator(); + for (float i = 0; i < 1; i += 0.1) + assertEquals(1 - i, interpolator.interpolate(i)); + } +}