From f0893f6fc092063408cdec0d9aeb4f8ff58ee94c Mon Sep 17 00:00:00 2001 From: Gianlu Date: Wed, 27 Nov 2019 15:39:58 +0100 Subject: [PATCH 01/32] Event service framework (not working) --- .../librespot/core/EventServiceHelper.java | 114 ++++++++++++++++++ .../xyz/gianlu/librespot/player/Player.java | 17 +++ .../gianlu/librespot/player/StateWrapper.java | 40 +++++- 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java b/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java new file mode 100644 index 00000000..1767319d --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java @@ -0,0 +1,114 @@ +package xyz.gianlu.librespot.core; + +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.common.Utils; +import xyz.gianlu.librespot.mercury.MercuryClient; +import xyz.gianlu.librespot.mercury.RawMercuryRequest; +import xyz.gianlu.librespot.player.StateWrapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * @author Gianlu + */ +public final class EventServiceHelper { + + private EventServiceHelper() { + } + + private static void sendEvent(@NotNull Session session, @NotNull EventBuilder builder) throws IOException { + byte[] body = builder.toArray(); + System.out.println(Utils.bytesToHex(body)); + + MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() + .setUri("hm://event-service/v1/events").setMethod("POST") + .addUserField("Accept-Language", "en").addUserField("X-Offset", "321" /* FIXME ?? */) + .addUserField("X-ClientTimeStamp", String.valueOf(TimeProvider.currentTimeMillis())) + .addPayloadPart(body) + .build()); + + System.out.println(resp.statusCode); + } + + public static void reportPlayback(@NotNull Session session, @NotNull StateWrapper state, @NotNull String uri, @NotNull PlaybackIntervals intervals) throws IOException { + EventBuilder event = new EventBuilder(); + event.chars("372"); + event.delimiter().chars('1'); + event.delimiter().chars(state.getPlaybackId()); + event.delimiter().chars(uri); + event.delimiter().chars('0'); + event.delimiter().chars(intervals.toSend()); + + sendEvent(session, event); + } + + public static void announceNewPlaybackId(@NotNull Session session, @NotNull StateWrapper state) throws IOException { + EventBuilder event = new EventBuilder(); + event.chars("558"); + event.delimiter().chars('1'); + event.delimiter().chars(state.getPlaybackId()); + event.delimiter().chars(state.getSessionId()); + event.delimiter().chars(String.valueOf(TimeProvider.currentTimeMillis())); + + sendEvent(session, event); + } + + public static void announceNewSessionId(@NotNull Session session, @NotNull StateWrapper state) throws IOException { + EventBuilder event = new EventBuilder(); + event.chars("557"); + event.delimiter().chars('3'); + event.delimiter().chars(state.getSessionId()); + + String contextUri = state.getContextUri(); + event.delimiter().chars(contextUri); + event.delimiter().chars(contextUri); + event.delimiter().chars(String.valueOf(TimeProvider.currentTimeMillis())); + event.delimiter().delimiter().chars("300"); // FIXME: Number of tracks in context + event.delimiter().chars("context://" + state.getContextUri()); // FIXME. Might not be this way + + sendEvent(session, event); + } + + private static class EventBuilder { + private final ByteArrayOutputStream body = new ByteArrayOutputStream(256); + + EventBuilder() { + } + + EventBuilder chars(char c) { + body.write(c); + return this; + } + + EventBuilder chars(@NotNull String str) throws IOException { + body.write(str.getBytes(StandardCharsets.UTF_8)); + return this; + } + + EventBuilder delimiter() { + body.write(0x09); + return this; + } + + @NotNull + byte[] toArray() throws IOException { + byte[] bodyBytes = body.toByteArray(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(bodyBytes.length + 2); + out.write(bodyBytes.length << 8); + out.write(bodyBytes.length & 0xFF); + out.write(bodyBytes); + return out.toByteArray(); + } + } + + public static class PlaybackIntervals { + + @NotNull + String toSend() { + return "[[0,40000]]"; + } + } +} 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 8f8c9825..cd8d9f2b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -11,6 +11,7 @@ import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.connectstate.DeviceStateHandler; import xyz.gianlu.librespot.connectstate.DeviceStateHandler.PlayCommandHelper; +import xyz.gianlu.librespot.core.EventServiceHelper; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.MercuryRequests; @@ -137,6 +138,13 @@ private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { loadTrack(!cmd.getPlayback().getIsPaused(), PushToMixerReason.None); events.dispatchContextChanged(); events.dispatchTrackChanged(); + + try { + EventServiceHelper.announceNewSessionId(session, state); + EventServiceHelper.announceNewPlaybackId(session, state); + } catch (IOException e) { + e.printStackTrace(); // FIXME + } } private void handleLoad(@NotNull JsonObject obj) { @@ -286,6 +294,13 @@ public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, @Override public void endOfTrack(@NotNull TrackHandler handler, @Nullable String uri, boolean fadeOut) { if (handler == trackHandler) { + try { + EventServiceHelper.reportPlayback(session, state, state.getCurrentPlayableOrThrow().toSpotifyUri(), + new EventServiceHelper.PlaybackIntervals()); + } catch (IOException e) { + e.printStackTrace(); // FIXME + } + LOGGER.trace(String.format("End of track. Proceeding with next. {fadeOut: %b}", fadeOut)); handleNext(null); @@ -401,6 +416,8 @@ private void loadTrack(boolean play, @NotNull PushToMixerReason reason) { trackHandler = null; } + state.renewPlaybackId(); + PlayableId id = state.getCurrentPlayableOrThrow(); if (crossfadeHandler != null && crossfadeHandler.isTrack(id)) { trackHandler = crossfadeHandler; 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 02cd5a74..3b91536a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -81,6 +81,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() @@ -96,6 +97,21 @@ private static boolean shouldPlay(@NotNull ContextTrack track) { return PlayableId.isSupported(track.getUri()) && PlayableId.shouldPlay(track); } + @NotNull + private 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); + } + void setState(@Nullable Boolean playing, @Nullable Boolean paused, @Nullable Boolean buffering) { setState(playing == null ? state.getIsPlaying() : playing, paused == null ? state.getIsPaused() : paused, buffering == null ? state.getIsBuffering() : buffering); @@ -150,7 +166,7 @@ void setRepeatingTrack(boolean value) { } @Nullable - String getContextUri() { + public String getContextUri() { return state.getContextUri(); } @@ -203,6 +219,8 @@ private void setContext(@NotNull String uri) { this.tracksKeeper = new TracksKeeper(); this.device.setIsActive(true); + + renewSessionId(); } private void setContext(@NotNull Context ctx) { @@ -225,6 +243,8 @@ private void setContext(@NotNull Context ctx) { this.tracksKeeper = new TracksKeeper(); this.device.setIsActive(true); + + renewSessionId(); } private void updateRestrictions() { @@ -673,6 +693,24 @@ public void setContextMetadata(@NotNull String key, @Nullable String value) { else state.putContextMetadata(key, value); } + @Nullable + public String getPlaybackId() { + return state.getPlaybackId(); + } + + @Nullable + public String getSessionId() { + return state.getSessionId(); + } + + public void renewSessionId() { + state.setSessionId(generateSessionId(session.random())); + } + + public void renewPlaybackId() { + state.setPlaybackId(generatePlaybackId(session.random())); + } + public enum PreviousPlayable { MISSING_TRACKS, OK; From c0c74b47e107f062ce805d986d9a65f0ea3f79e4 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sat, 30 Nov 2019 16:38:21 +0100 Subject: [PATCH 02/32] Fixed 400 + report language --- .../librespot/core/EventServiceHelper.java | 22 +++++++++++-------- .../xyz/gianlu/librespot/core/Session.java | 7 +++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java b/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java index 1767319d..f1e78d1d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java @@ -24,7 +24,7 @@ private static void sendEvent(@NotNull Session session, @NotNull EventBuilder bu MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() .setUri("hm://event-service/v1/events").setMethod("POST") - .addUserField("Accept-Language", "en").addUserField("X-Offset", "321" /* FIXME ?? */) + .addUserField("Accept-Language", "en") .addUserField("X-ClientTimeStamp", String.valueOf(TimeProvider.currentTimeMillis())) .addPayloadPart(body) .build()); @@ -32,6 +32,16 @@ private static void sendEvent(@NotNull Session session, @NotNull EventBuilder bu System.out.println(resp.statusCode); } + public static void reportLang(@NotNull Session session, @NotNull String lang) throws IOException { + EventBuilder event = new EventBuilder(); + event.chars("812"); + event.delimiter().chars('1'); + event.delimiter().chars(lang); + event.delimiter(); + + sendEvent(session, event); + } + public static void reportPlayback(@NotNull Session session, @NotNull StateWrapper state, @NotNull String uri, @NotNull PlaybackIntervals intervals) throws IOException { EventBuilder event = new EventBuilder(); event.chars("372"); @@ -93,14 +103,8 @@ EventBuilder delimiter() { } @NotNull - byte[] toArray() throws IOException { - byte[] bodyBytes = body.toByteArray(); - - ByteArrayOutputStream out = new ByteArrayOutputStream(bodyBytes.length + 2); - out.write(bodyBytes.length << 8); - out.write(bodyBytes.length & 0xFF); - out.write(bodyBytes); - return out.toByteArray(); + byte[] toArray() { + return body.toByteArray(); } } 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 f83ada1e..65888970 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -263,9 +263,9 @@ 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); authLock.set(false); @@ -276,6 +276,7 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I player.initState(); TimeProvider.init(this); + EventServiceHelper.reportLang(this, conf().preferredLocale()); LOGGER.info(String.format("Authenticated as %s!", apWelcome.getCanonicalUsername())); } @@ -317,7 +318,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) { From af005643e068751d9d73298bce591385cb219715 Mon Sep 17 00:00:00 2001 From: devgianlu Date: Sun, 12 Jan 2020 14:02:45 +0100 Subject: [PATCH 03/32] Better logging + minor changes --- .../librespot/core/EventServiceHelper.java | 22 +++++++++++++------ .../gianlu/librespot/player/StateWrapper.java | 7 ++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java b/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java index f1e78d1d..fab91d3a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java @@ -1,7 +1,6 @@ package xyz.gianlu.librespot.core; import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.RawMercuryRequest; import xyz.gianlu.librespot.player.StateWrapper; @@ -20,8 +19,6 @@ private EventServiceHelper() { private static void sendEvent(@NotNull Session session, @NotNull EventBuilder builder) throws IOException { byte[] body = builder.toArray(); - System.out.println(Utils.bytesToHex(body)); - MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() .setUri("hm://event-service/v1/events").setMethod("POST") .addUserField("Accept-Language", "en") @@ -29,7 +26,7 @@ private static void sendEvent(@NotNull Session session, @NotNull EventBuilder bu .addPayloadPart(body) .build()); - System.out.println(resp.statusCode); + System.out.println(EventBuilder.toString(body) + " => " + resp.statusCode); } public static void reportLang(@NotNull Session session, @NotNull String lang) throws IOException { @@ -75,8 +72,8 @@ public static void announceNewSessionId(@NotNull Session session, @NotNull State event.delimiter().chars(contextUri); event.delimiter().chars(contextUri); event.delimiter().chars(String.valueOf(TimeProvider.currentTimeMillis())); - event.delimiter().delimiter().chars("300"); // FIXME: Number of tracks in context - event.delimiter().chars("context://" + state.getContextUri()); // FIXME. Might not be this way + event.delimiter().delimiter().chars(String.valueOf(state.getContextSize())); + event.delimiter().chars("context://" + state.getContextUri()); // FIXME: Might not be this way sendEvent(session, event); } @@ -87,6 +84,17 @@ private static class EventBuilder { EventBuilder() { } + @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(); + } + EventBuilder chars(char c) { body.write(c); return this; @@ -112,7 +120,7 @@ public static class PlaybackIntervals { @NotNull String toSend() { - return "[[0,40000]]"; + return "[[0,40000]]"; // FIXME } } } 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 e995c06c..1c2fce3a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -677,6 +677,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); From ce1596b71f66e73aad558657204ae39e5104ba81 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 13 Apr 2020 14:53:47 +0200 Subject: [PATCH 04/32] Working! (with a lot of improvements to make) --- .../gianlu/librespot/core/EventService.java | 198 ++++++++++++++++++ .../librespot/core/EventServiceHelper.java | 126 ----------- .../xyz/gianlu/librespot/core/Session.java | 16 +- .../xyz/gianlu/librespot/player/Player.java | 9 +- .../player/feeders/PlayableContentFeeder.java | 25 ++- .../player/feeders/cdn/CdnFeedHelper.java | 3 + .../player/feeders/cdn/CdnManager.java | 4 + 7 files changed, 239 insertions(+), 142 deletions(-) create mode 100644 core/src/main/java/xyz/gianlu/librespot/core/EventService.java delete mode 100644 core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java 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..ea99473c --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -0,0 +1,198 @@ +package xyz.gianlu.librespot.core; + +import com.spotify.metadata.Metadata; +import okhttp3.HttpUrl; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.Utils; +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 java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * @author Gianlu + */ +public final class EventService implements Closeable { + private final Session session; + + EventService(@NotNull Session session) { + this.session = session; + } + + private void sendEvent(@NotNull EventBuilder builder) throws IOException { // TODO: Async + byte[] body = builder.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()); + + System.out.println(EventBuilder.toString(body) + " => " + resp.statusCode); // FIXME + } + + public void reportLang(@NotNull String lang) throws IOException { + EventBuilder event = new EventBuilder(Type.LANGUAGE); + event.append(lang); + sendEvent(event); + } + + public void trackTransition(@NotNull StateWrapper state, PlayableId id) throws IOException { + EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); + event.append("1"); // FIXME: Incremental + event.append(session.deviceId()); + event.append(state.getPlaybackId()); + event.append("00000000000000000000000000000000"); + event.append("library-collection").append("trackdone"); + event.append("library-collection").append("trackdone"); + event.append("3172204").append("3172204"); // FIXME + event.append("167548").append("167548").append("167548"); // FIXME + event.append('8' /* FIXME */).append('0').append('0').append('0').append('0').append('0'); + event.append("12" /* FIXME */).append("-1").append("context").append("-1").append('0').append("1"); + event.append('0').append("72" /* FIXME */).append('0'); + event.append("167548").append("167548"); // FIXME + event.append('0').append("160000"); + event.append(state.getContextUri()).append("vorbis" /* FIXME */); + event.append(id.hexId()).append(""); + event.append('0').append(String.valueOf(TimeProvider.currentTimeMillis())).append('0'); + event.append("context").append("spotify:app:collection-songs" /* FIXME */).append("1.1.26" /* FIXME */); + event.append("com.spotify").append("none").append("none").append("local").append("na").append("none"); + sendEvent(event); + } + + public void trackPlayed(@NotNull StateWrapper state, @NotNull PlayableId uri, @NotNull PlaybackIntervals intervals) throws IOException { + trackTransition(state, uri); + + EventBuilder event = new EventBuilder(Type.TRACK_PLAYED); + event.append(state.getPlaybackId()).append(uri.toSpotifyUri()); + event.append('0').append(intervals.toSend()); + sendEvent(event); + } + + public void newPlaybackId(@NotNull StateWrapper state) throws IOException { + EventBuilder event = new EventBuilder(Type.NEW_PLAYBACK_ID); + event.append(state.getPlaybackId()).append(state.getSessionId()).append(String.valueOf(TimeProvider.currentTimeMillis())); + sendEvent(event); + } + + public void newSessionId(@NotNull StateWrapper state) throws IOException { + EventBuilder event = new EventBuilder(Type.NEW_SESSION_ID); + event.append(state.getSessionId()); + String contextUri = state.getContextUri(); + event.append(contextUri); + event.append(contextUri); + event.append(String.valueOf(TimeProvider.currentTimeMillis())); + event.append("").append(String.valueOf(state.getContextSize())); + event.append("context://" + state.getContextUri()); // FIXME + sendEvent(event); + } + + public void fetchedFileId(Metadata.AudioFile file, PlayableId id) throws IOException { + 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'); // FIXME + sendEvent(event); + } + + public void cdnRequest(Metadata.AudioFile file, int fileLength, HttpUrl url) throws IOException { // FIXME + EventBuilder event = new EventBuilder(Type.CDN_REQUEST); + event.append(Utils.bytesToHex(file.getFileId()).toLowerCase()); + event.append("00000000000000000000000000000000"); + event.append('0').append('0').append('0').append('0'); + event.append(String.valueOf(fileLength)); + event.append('0').append('0'); + event.append(String.valueOf(fileLength)); + event.append("music"); + event.append("-1").append("-1").append("-1").append("-1.000000").append("-1").append("-1.000000"); + event.append("181").append("181").append("181").append("181.000000").append("181"); + event.append("227").append("227").append("227").append("227.000000").append("227"); + event.append("3427443.636364").append("2914058.000000"); + event.append(url.scheme()); + event.append(url.host()); + event.append("unknown"); + event.append('0').append('0').append('0').append('0'); + event.append(String.valueOf(fileLength)); + event.append("interactive"); + event.append("1345").append("160000").append("1").append('0'); + sendEvent(event); + } + + @Override + public void close() throws IOException { + // TODO + } + + private enum Type { + LANGUAGE("812", "1"), CDN_REQUEST("10", "20"), FETCHED_FILE_ID("274", "3"), + NEW_SESSION_ID("557", "3"), NEW_PLAYBACK_ID("558", "1"), TRACK_PLAYED("372", "1"), + TRACK_TRANSITION("12", "37"); + + 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) { + append(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(); + } + + EventBuilder append(char c) { + body.write(0x09); + body.write(c); + return this; + } + + EventBuilder append(@Nullable String str) { + if (str == null) str = ""; + + try { + body.write(0x09); + body.write(str.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + + return this; + } + + @NotNull + byte[] toArray() { + return body.toByteArray(); + } + } + + public static class PlaybackIntervals { + + @NotNull + String toSend() { + return "[[0,40000]]"; // FIXME + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java b/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java deleted file mode 100644 index fab91d3a..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventServiceHelper.java +++ /dev/null @@ -1,126 +0,0 @@ -package xyz.gianlu.librespot.core; - -import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.RawMercuryRequest; -import xyz.gianlu.librespot.player.StateWrapper; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -/** - * @author Gianlu - */ -public final class EventServiceHelper { - - private EventServiceHelper() { - } - - private static void sendEvent(@NotNull Session session, @NotNull EventBuilder builder) throws IOException { - byte[] body = builder.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()); - - System.out.println(EventBuilder.toString(body) + " => " + resp.statusCode); - } - - public static void reportLang(@NotNull Session session, @NotNull String lang) throws IOException { - EventBuilder event = new EventBuilder(); - event.chars("812"); - event.delimiter().chars('1'); - event.delimiter().chars(lang); - event.delimiter(); - - sendEvent(session, event); - } - - public static void reportPlayback(@NotNull Session session, @NotNull StateWrapper state, @NotNull String uri, @NotNull PlaybackIntervals intervals) throws IOException { - EventBuilder event = new EventBuilder(); - event.chars("372"); - event.delimiter().chars('1'); - event.delimiter().chars(state.getPlaybackId()); - event.delimiter().chars(uri); - event.delimiter().chars('0'); - event.delimiter().chars(intervals.toSend()); - - sendEvent(session, event); - } - - public static void announceNewPlaybackId(@NotNull Session session, @NotNull StateWrapper state) throws IOException { - EventBuilder event = new EventBuilder(); - event.chars("558"); - event.delimiter().chars('1'); - event.delimiter().chars(state.getPlaybackId()); - event.delimiter().chars(state.getSessionId()); - event.delimiter().chars(String.valueOf(TimeProvider.currentTimeMillis())); - - sendEvent(session, event); - } - - public static void announceNewSessionId(@NotNull Session session, @NotNull StateWrapper state) throws IOException { - EventBuilder event = new EventBuilder(); - event.chars("557"); - event.delimiter().chars('3'); - event.delimiter().chars(state.getSessionId()); - - String contextUri = state.getContextUri(); - event.delimiter().chars(contextUri); - event.delimiter().chars(contextUri); - event.delimiter().chars(String.valueOf(TimeProvider.currentTimeMillis())); - event.delimiter().delimiter().chars(String.valueOf(state.getContextSize())); - event.delimiter().chars("context://" + state.getContextUri()); // FIXME: Might not be this way - - sendEvent(session, event); - } - - private static class EventBuilder { - private final ByteArrayOutputStream body = new ByteArrayOutputStream(256); - - EventBuilder() { - } - - @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(); - } - - EventBuilder chars(char c) { - body.write(c); - return this; - } - - EventBuilder chars(@NotNull String str) throws IOException { - body.write(str.getBytes(StandardCharsets.UTF_8)); - return this; - } - - EventBuilder delimiter() { - body.write(0x09); - return this; - } - - @NotNull - byte[] toArray() { - return body.toByteArray(); - } - } - - public static class PlaybackIntervals { - - @NotNull - String toSend() { - return "[[0,40000]]"; // FIXME - } - } -} 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 a88e2c02..d16857b5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -106,6 +106,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; @@ -306,6 +307,7 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I dealer = new DealerClient(this); player = new Player(conf(), this); search = new SearchManager(this); + eventService = new EventService(this); authLock.set(false); authLock.notifyAll(); @@ -315,7 +317,7 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I player.initState(); TimeProvider.init(this); - EventServiceHelper.reportLang(this, conf().preferredLocale()); + eventService.reportLang(conf().preferredLocale()); LOGGER.info(String.format("Authenticated as %s!", apWelcome.getCanonicalUsername())); @@ -419,6 +421,11 @@ public void close() throws IOException { channelManager = null; } + if (eventService != null) { + eventService.close(); + eventService = null; + } + if (mercuryClient != null) { mercuryClient.close(); mercuryClient = null; @@ -555,6 +562,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/player/Player.java b/core/src/main/java/xyz/gianlu/librespot/player/Player.java index 57cf2e93..41d89c7c 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -16,7 +16,7 @@ import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.connectstate.DeviceStateHandler; import xyz.gianlu.librespot.connectstate.DeviceStateHandler.PlayCommandHelper; -import xyz.gianlu.librespot.core.EventServiceHelper; +import xyz.gianlu.librespot.core.EventService; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.MercuryRequests; @@ -148,8 +148,8 @@ private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { loadTrack(!cmd.getPlayback().getIsPaused(), PushToMixerReason.None); try { - EventServiceHelper.announceNewSessionId(session, state); - EventServiceHelper.announceNewPlaybackId(session, state); + session.eventService().newSessionId(state); + session.eventService().newPlaybackId(state); } catch (IOException e) { e.printStackTrace(); // FIXME } @@ -306,8 +306,7 @@ public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, public void endOfTrack(@NotNull TrackHandler handler, @Nullable String uri, boolean fadeOut) { if (handler == trackHandler) { try { - EventServiceHelper.reportPlayback(session, state, state.getCurrentPlayableOrThrow().toSpotifyUri(), - new EventServiceHelper.PlaybackIntervals()); + session.eventService().trackPlayed(state, state.getCurrentPlayableOrThrow(), new EventService.PlaybackIntervals()); } catch (IOException e) { e.printStackTrace(); // FIXME } 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..f0c1dc3a 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; @@ -89,29 +90,33 @@ private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fil } @NotNull + @Contract("_, null, null, _, _ -> fail") 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 { + 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(); + else return CdnFeedHelper.loadEpisode(session, episode, file, url, haltListener); } @NotNull + @Contract("_, null, null, _ -> fail") private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Metadata.Track track, @Nullable Metadata.Episode episode, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { + if (track == null && episode == null) + throw new IllegalStateException(); + + session.eventService().fetchedFileId(file, track != null ? PlayableId.from(track) : PlayableId.from(episode)); + StorageResolveResponse resp = resolveStorageInteractive(file.getFileId()); 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(); + 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, haltListener); + else return StorageFeedHelper.loadEpisode(session, episode, file, haltListener); } catch (AudioFileFetch.StorageNotAvailable ex) { LOGGER.info("Storage is not available. Going CDN: " + ex.cdnUrl); return loadCdnStream(file, track, episode, ex.cdnUrl, haltListener); 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..a77ebcdc 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 @@ -34,6 +34,9 @@ private static HttpUrl getUrl(@NotNull Session session, @NotNull StorageResolveR 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 { byte[] key = session.audioKey().getAudioKey(track.getGid(), file.getFileId()); CdnManager.Streamer streamer = session.cdn().streamFile(file, key, url, haltListener); + + session.eventService().cdnRequest(file, streamer.size(), url); + InputStream in = streamer.stream(); NormalizationData normalizationData = NormalizationData.read(in); if (in.skip(0xa7) != 0xa7) throw new IOException("Couldn't skip 0xa7 bytes!"); 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 0ebd1a5a..7041342d 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 @@ -317,6 +317,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) { From 255b83a8532cf967f4578f9303f39422ebed83ca Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 13 Apr 2020 14:57:46 +0200 Subject: [PATCH 05/32] Event dispatch is now asynchronous --- .../gianlu/librespot/core/EventService.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index ea99473c..a043eef9 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -2,8 +2,10 @@ import com.spotify.metadata.Metadata; import okhttp3.HttpUrl; +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.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.RawMercuryRequest; @@ -19,13 +21,22 @@ * @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; EventService(@NotNull Session session) { this.session = session; + this.asyncWorker = new AsyncWorker<>("event-service", eventBuilder -> { + try { + sendEvent(eventBuilder); + } catch (IOException ex) { + LOGGER.error("Failed sending event: " + eventBuilder, ex); + } + }); } - private void sendEvent(@NotNull EventBuilder builder) throws IOException { // TODO: Async + private void sendEvent(@NotNull EventBuilder builder) throws IOException { byte[] body = builder.toArray(); MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() .setUri("hm://event-service/v1/events").setMethod("POST") @@ -34,7 +45,7 @@ private void sendEvent(@NotNull EventBuilder builder) throws IOException { // TO .addPayloadPart(body) .build()); - System.out.println(EventBuilder.toString(body) + " => " + resp.statusCode); // FIXME + LOGGER.debug(String.format("Event sent. {body: %s, result: %d}", EventBuilder.toString(body), resp.statusCode)); } public void reportLang(@NotNull String lang) throws IOException { @@ -126,8 +137,8 @@ public void cdnRequest(Metadata.AudioFile file, int fileLength, HttpUrl url) thr } @Override - public void close() throws IOException { - // TODO + public void close() { + asyncWorker.close(); } private enum Type { @@ -163,12 +174,14 @@ static String toString(byte[] body) { return result.toString(); } + @NotNull EventBuilder append(char c) { body.write(0x09); body.write(c); return this; } + @NotNull EventBuilder append(@Nullable String str) { if (str == null) str = ""; @@ -182,6 +195,11 @@ EventBuilder append(@Nullable String str) { return this; } + @Override + public String toString() { + return "EventBuilder{" + toString(toArray()) + '}'; + } + @NotNull byte[] toArray() { return body.toByteArray(); From 5025eb847be22198885998a164e59bd0fea98ab4 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 13 Apr 2020 15:09:17 +0200 Subject: [PATCH 06/32] More clean up --- .../gianlu/librespot/core/EventService.java | 59 ++++++++++--------- .../xyz/gianlu/librespot/player/Player.java | 14 +---- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index a043eef9..cf2d02e8 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -29,32 +29,32 @@ public final class EventService implements Closeable { this.session = session; this.asyncWorker = new AsyncWorker<>("event-service", eventBuilder -> { try { - sendEvent(eventBuilder); + 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) throws IOException { - byte[] body = builder.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)); + private void sendEvent(@NotNull EventBuilder builder) { + asyncWorker.submit(builder); } - public void reportLang(@NotNull String lang) throws IOException { + public void reportLang(@NotNull String lang) { EventBuilder event = new EventBuilder(Type.LANGUAGE); event.append(lang); sendEvent(event); } - public void trackTransition(@NotNull StateWrapper state, PlayableId id) throws IOException { + public void trackTransition(@NotNull StateWrapper state, @NotNull PlayableId id) { EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); event.append("1"); // FIXME: Incremental event.append(session.deviceId()); @@ -77,7 +77,7 @@ public void trackTransition(@NotNull StateWrapper state, PlayableId id) throws I sendEvent(event); } - public void trackPlayed(@NotNull StateWrapper state, @NotNull PlayableId uri, @NotNull PlaybackIntervals intervals) throws IOException { + public void trackPlayed(@NotNull StateWrapper state, @NotNull PlayableId uri, @NotNull PlaybackIntervals intervals) { trackTransition(state, uri); EventBuilder event = new EventBuilder(Type.TRACK_PLAYED); @@ -86,13 +86,13 @@ public void trackPlayed(@NotNull StateWrapper state, @NotNull PlayableId uri, @N sendEvent(event); } - public void newPlaybackId(@NotNull StateWrapper state) throws IOException { + public void newPlaybackId(@NotNull StateWrapper state) { EventBuilder event = new EventBuilder(Type.NEW_PLAYBACK_ID); event.append(state.getPlaybackId()).append(state.getSessionId()).append(String.valueOf(TimeProvider.currentTimeMillis())); sendEvent(event); } - public void newSessionId(@NotNull StateWrapper state) throws IOException { + public void newSessionId(@NotNull StateWrapper state) { EventBuilder event = new EventBuilder(Type.NEW_SESSION_ID); event.append(state.getSessionId()); String contextUri = state.getContextUri(); @@ -104,7 +104,7 @@ public void newSessionId(@NotNull StateWrapper state) throws IOException { sendEvent(event); } - public void fetchedFileId(Metadata.AudioFile file, PlayableId id) throws IOException { + public void fetchedFileId(@NotNull Metadata.AudioFile file, @NotNull PlayableId id) { EventBuilder event = new EventBuilder(Type.FETCHED_FILE_ID); event.append('2').append('2'); event.append(Utils.bytesToHex(file.getFileId()).toLowerCase()); @@ -113,7 +113,7 @@ public void fetchedFileId(Metadata.AudioFile file, PlayableId id) throws IOExcep sendEvent(event); } - public void cdnRequest(Metadata.AudioFile file, int fileLength, HttpUrl url) throws IOException { // FIXME + public void cdnRequest(@NotNull Metadata.AudioFile file, int fileLength, @NotNull HttpUrl url) { // FIXME EventBuilder event = new EventBuilder(Type.CDN_REQUEST); event.append(Utils.bytesToHex(file.getFileId()).toLowerCase()); event.append("00000000000000000000000000000000"); @@ -159,7 +159,7 @@ private static class EventBuilder { private final ByteArrayOutputStream body = new ByteArrayOutputStream(256); EventBuilder(@NotNull Type type) { - append(type.id); + appendNoDelimiter(type.id); append(type.unknown); } @@ -174,6 +174,16 @@ static String toString(byte[] body) { 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); @@ -183,15 +193,8 @@ EventBuilder append(char c) { @NotNull EventBuilder append(@Nullable String str) { - if (str == null) str = ""; - - try { - body.write(0x09); - body.write(str.getBytes(StandardCharsets.UTF_8)); - } catch (IOException ex) { - throw new IllegalStateException(ex); - } - + body.write(0x09); + appendNoDelimiter(str); return this; } 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 41d89c7c..d3fee9e1 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -147,12 +147,8 @@ private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { events.contextChanged(); loadTrack(!cmd.getPlayback().getIsPaused(), PushToMixerReason.None); - try { - session.eventService().newSessionId(state); - session.eventService().newPlaybackId(state); - } catch (IOException e) { - e.printStackTrace(); // FIXME - } + session.eventService().newSessionId(state); + session.eventService().newPlaybackId(state); } private void handleLoad(@NotNull JsonObject obj) { @@ -305,11 +301,7 @@ public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, @Override public void endOfTrack(@NotNull TrackHandler handler, @Nullable String uri, boolean fadeOut) { if (handler == trackHandler) { - try { - session.eventService().trackPlayed(state, state.getCurrentPlayableOrThrow(), new EventService.PlaybackIntervals()); - } catch (IOException e) { - e.printStackTrace(); // FIXME - } + session.eventService().trackPlayed(state, state.getCurrentPlayableOrThrow(), new EventService.PlaybackIntervals()); LOGGER.trace(String.format("End of track. Proceeding with next. {fadeOut: %b}", fadeOut)); handleNext(null); From bd597d653acdbfbb602f37644e5f762d1d2a56ed Mon Sep 17 00:00:00 2001 From: Gianlu Date: Tue, 14 Apr 2020 14:50:39 +0200 Subject: [PATCH 07/32] Send events properly + removed CDN_REQUEST + minor refactoring --- .../connectstate/DeviceStateHandler.java | 21 +- .../gianlu/librespot/core/EventService.java | 180 +++++++++++++----- .../xyz/gianlu/librespot/player/Player.java | 142 ++++++++++++-- .../gianlu/librespot/player/PlayerRunner.java | 17 +- .../gianlu/librespot/player/StateWrapper.java | 16 +- .../player/feeders/cdn/CdnFeedHelper.java | 3 - 6 files changed, 289 insertions(+), 90 deletions(-) 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 83590bb5..b3efa599 100644 --- a/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java +++ b/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java @@ -319,6 +319,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 +400,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 index cf2d02e8..b3c10a04 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -1,7 +1,7 @@ package xyz.gianlu.librespot.core; +import com.spotify.connectstate.Player; import com.spotify.metadata.Metadata; -import okhttp3.HttpUrl; import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -10,12 +10,15 @@ import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.RawMercuryRequest; import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.player.PlayerRunner; import xyz.gianlu.librespot.player.StateWrapper; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; /** * @author Gianlu @@ -24,6 +27,7 @@ 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; @@ -54,35 +58,41 @@ public void reportLang(@NotNull String lang) { sendEvent(event); } - public void trackTransition(@NotNull StateWrapper state, @NotNull PlayableId id) { + public void trackTransition(@NotNull StateWrapper state, @NotNull PlaybackDescriptor desc) { + Player.PlayOrigin playOrigin = state.getPlayOrigin(); + int when = desc.lastValue(); + EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); - event.append("1"); // FIXME: Incremental + event.append(String.valueOf(trackTransitionIncremental++)); event.append(session.deviceId()); - event.append(state.getPlaybackId()); - event.append("00000000000000000000000000000000"); - event.append("library-collection").append("trackdone"); - event.append("library-collection").append("trackdone"); - event.append("3172204").append("3172204"); // FIXME - event.append("167548").append("167548").append("167548"); // FIXME - event.append('8' /* FIXME */).append('0').append('0').append('0').append('0').append('0'); - event.append("12" /* FIXME */).append("-1").append("context").append("-1").append('0').append("1"); - event.append('0').append("72" /* FIXME */).append('0'); - event.append("167548").append("167548"); // FIXME + event.append(state.getPlaybackId()).append("00000000000000000000000000000000"); + event.append(playOrigin.getFeatureIdentifier()).append(desc.startedHow()); + event.append(playOrigin.getFeatureIdentifier()).append(desc.endedHow()); + event.append('0').append('0'); // FIXME + event.append(String.valueOf(when)).append(String.valueOf(when)); + event.append(String.valueOf(desc.duration)); + event.append('0').append('0').append('0').append('0').append('0'); // FIXME + event.append(String.valueOf(desc.firstValue())); + event.append('0').append("-1").append("context").append("-1").append('0').append('0').append('0').append('0').append('0'); // FIXME + event.append(String.valueOf(when)).append(String.valueOf(when)); event.append('0').append("160000"); - event.append(state.getContextUri()).append("vorbis" /* FIXME */); - event.append(id.hexId()).append(""); + event.append(state.getContextUri()).append(desc.encoding); + event.append(desc.id.hexId()).append(""); event.append('0').append(String.valueOf(TimeProvider.currentTimeMillis())).append('0'); - event.append("context").append("spotify:app:collection-songs" /* FIXME */).append("1.1.26" /* FIXME */); + event.append("context").append(playOrigin.getReferrerIdentifier()).append(playOrigin.getFeatureVersion()); event.append("com.spotify").append("none").append("none").append("local").append("na").append("none"); sendEvent(event); } - public void trackPlayed(@NotNull StateWrapper state, @NotNull PlayableId uri, @NotNull PlaybackIntervals intervals) { - trackTransition(state, uri); + public void trackPlayed(@NotNull StateWrapper state, @NotNull EventService.PlaybackDescriptor desc) { + if (desc.duration == 0 || desc.encoding == null) + return; + + trackTransition(state, desc); EventBuilder event = new EventBuilder(Type.TRACK_PLAYED); - event.append(state.getPlaybackId()).append(uri.toSpotifyUri()); - event.append('0').append(intervals.toSend()); + event.append(state.getPlaybackId()).append(desc.id.toSpotifyUri()); + event.append('0').append(desc.intervalsToSend()); sendEvent(event); } @@ -100,7 +110,7 @@ public void newSessionId(@NotNull StateWrapper state) { event.append(contextUri); event.append(String.valueOf(TimeProvider.currentTimeMillis())); event.append("").append(String.valueOf(state.getContextSize())); - event.append("context://" + state.getContextUri()); // FIXME + event.append(state.getContextUrl()); sendEvent(event); } @@ -113,38 +123,14 @@ public void fetchedFileId(@NotNull Metadata.AudioFile file, @NotNull PlayableId sendEvent(event); } - public void cdnRequest(@NotNull Metadata.AudioFile file, int fileLength, @NotNull HttpUrl url) { // FIXME - EventBuilder event = new EventBuilder(Type.CDN_REQUEST); - event.append(Utils.bytesToHex(file.getFileId()).toLowerCase()); - event.append("00000000000000000000000000000000"); - event.append('0').append('0').append('0').append('0'); - event.append(String.valueOf(fileLength)); - event.append('0').append('0'); - event.append(String.valueOf(fileLength)); - event.append("music"); - event.append("-1").append("-1").append("-1").append("-1.000000").append("-1").append("-1.000000"); - event.append("181").append("181").append("181").append("181.000000").append("181"); - event.append("227").append("227").append("227").append("227.000000").append("227"); - event.append("3427443.636364").append("2914058.000000"); - event.append(url.scheme()); - event.append(url.host()); - event.append("unknown"); - event.append('0').append('0').append('0').append('0'); - event.append(String.valueOf(fileLength)); - event.append("interactive"); - event.append("1345").append("160000").append("1").append('0'); - sendEvent(event); - } - @Override public void close() { asyncWorker.close(); } private enum Type { - LANGUAGE("812", "1"), CDN_REQUEST("10", "20"), FETCHED_FILE_ID("274", "3"), - NEW_SESSION_ID("557", "3"), NEW_PLAYBACK_ID("558", "1"), TRACK_PLAYED("372", "1"), - TRACK_TRANSITION("12", "37"); + 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"); private final String id; private final String unknown; @@ -209,11 +195,105 @@ byte[] toArray() { } } - public static class PlaybackIntervals { + public static class PlaybackDescriptor { + public final PlayableId id; + final List intervals = new ArrayList<>(10); + int duration = 0; + String encoding = null; + Interval lastInterval = null; + How startedHow = null; + How endedHow = null; + + public PlaybackDescriptor(@NotNull PlayableId id) { + this.id = id; + } @NotNull - String toSend() { - return "[[0,40000]]"; // FIXME + 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 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 How how) { + startedHow = how; + } + + public void endedHow(@NotNull How how) { + endedHow = how; + } + + @Nullable + String startedHow() { + return startedHow == null ? null : startedHow.val; + } + + @Nullable + String endedHow() { + return endedHow == null ? null : endedHow.val; + } + + public void update(@NotNull PlayerRunner.TrackHandler trackHandler) { + duration = trackHandler.duration(); + encoding = trackHandler.encoding(); + } + + public enum How { + TRACK_DONE("trackdone"), TRACK_ERROR("trackerror"), FORWARD_BTN("fwdbtn"), BACK_BTN("backbtn"), + END_PLAY("endplay"), PLAY_BTN("playbtn"), CLICK_ROW("clickrow"), LOGOUT("logout" /* TODO: Use me */), APP_LOAD("appload"); + + final String val; + + How(@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/player/Player.java b/core/src/main/java/xyz/gianlu/librespot/player/Player.java index d3fee9e1..f830d9e2 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -16,7 +16,7 @@ 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; +import xyz.gianlu.librespot.core.EventService.PlaybackDescriptor; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.MercuryRequests; @@ -55,6 +55,7 @@ public class Player implements Closeable, DeviceStateHandler.Listener, PlayerRun private TrackHandler crossfadeHandler; private TrackHandler preloadTrackHandler; private ScheduledFuture releaseLineFuture = null; + private PlaybackDescriptor playbackDescriptor = null; public Player(@NotNull Player.Configuration conf, @NotNull Session session) { this.conf = conf; @@ -105,7 +106,7 @@ public void pause() { } public void next() { - handleNext(null); + handleNext(null, TransitionInfo.skippedNext(state)); } public void previous() { @@ -126,7 +127,7 @@ public void load(@NotNull String uri, boolean play) { } events.contextChanged(); - loadTrack(play, PushToMixerReason.None); + loadTrack(play, PushToMixerReason.None, TransitionInfo.contextChange(state, true)); } private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { @@ -145,7 +146,7 @@ private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { } events.contextChanged(); - loadTrack(!cmd.getPlayback().getIsPaused(), PushToMixerReason.None); + loadTrack(!cmd.getPlayback().getIsPaused(), PushToMixerReason.None, TransitionInfo.contextChange(state, true)); session.eventService().newSessionId(state); session.eventService().newPlaybackId(state); @@ -170,7 +171,7 @@ private void handleLoad(@NotNull JsonObject obj) { Boolean paused = PlayCommandHelper.isInitiallyPaused(obj); if (paused == null) paused = true; - loadTrack(!paused, PushToMixerReason.None); + loadTrack(!paused, PushToMixerReason.None, TransitionInfo.contextChange(state, PlayCommandHelper.willSkipToSomething(obj))); } @Override @@ -183,7 +184,6 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi switch (endpoint) { case Play: - System.out.println(data.obj()); handleLoad(data.obj()); break; case Transfer: @@ -199,7 +199,7 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi handleSeek(data.valueInt()); break; case SkipNext: - handleNext(data.obj()); + handleNext(data.obj(), TransitionInfo.skippedNext(state)); break; case SkipPrev: handlePrev(); @@ -258,6 +258,9 @@ private void updateStateWithHandler() { else if ((episode = trackHandler.episode()) != null) state.enrichWithMetadata(episode); else LOGGER.warn("Couldn't update metadata!"); + if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) + playbackDescriptor.update(trackHandler); + events.metadataAvailable(); } @@ -286,7 +289,7 @@ public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, 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); + handleNext(null, TransitionInfo.nextError(state)); return; } @@ -301,10 +304,8 @@ public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, @Override public void endOfTrack(@NotNull TrackHandler handler, @Nullable String uri, boolean fadeOut) { if (handler == trackHandler) { - session.eventService().trackPlayed(state, state.getCurrentPlayableOrThrow(), new EventService.PlaybackIntervals()); - LOGGER.trace(String.format("End of track. Proceeding with next. {fadeOut: %b}", fadeOut)); - handleNext(null); + handleNext(null, TransitionInfo.next(state)); PlayableId curr; if (uri != null && (curr = state.getCurrentPlayable()) != null && !curr.toSpotifyUri().equals(uri)) @@ -341,7 +342,7 @@ public void crossfadeNextTrack(@NotNull TrackHandler handler, @Nullable String u crossfadeHandler.waitReady(); LOGGER.info("Crossfading to next track."); - crossfadeHandler.pushToMixer(PushToMixerReason.Fade); + crossfadeHandler.pushToMixer(PushToMixerReason.FadeNext); } } @@ -406,6 +407,11 @@ public void playbackResumedFromHalt(@NotNull TrackHandler handler, int chunk, lo } private void handleSeek(int pos) { + if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { + playbackDescriptor.endInterval(state.getPosition()); + playbackDescriptor.startInterval(pos); + } + state.setPosition(pos); if (trackHandler != null) trackHandler.seek(pos); events.seeked(pos); @@ -417,7 +423,14 @@ private void panicState() { state.updated(); } - private void loadTrack(boolean play, @NotNull PushToMixerReason reason) { + private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull TransitionInfo trans) { + if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { + playbackDescriptor.endedHow(trans.endedHow); + playbackDescriptor.endInterval(trans.endedWhen); + session.eventService().trackPlayed(state, playbackDescriptor); + playbackDescriptor = null; + } + if (trackHandler != null) { trackHandler.stop(); trackHandler = null; @@ -426,6 +439,7 @@ private void loadTrack(boolean play, @NotNull PushToMixerReason reason) { state.renewPlaybackId(); PlayableId id = state.getCurrentPlayableOrThrow(); + playbackDescriptor = new PlaybackDescriptor(id); if (crossfadeHandler != null && crossfadeHandler.isPlayable(id)) { trackHandler = crossfadeHandler; if (preloadTrackHandler == crossfadeHandler) preloadTrackHandler = null; @@ -486,6 +500,9 @@ private void loadTrack(boolean play, @NotNull PushToMixerReason reason) { releaseLineFuture.cancel(true); releaseLineFuture = null; } + + playbackDescriptor.startedHow(trans.startedHow); + playbackDescriptor.startInterval(state.getPosition()); } private void handleResume() { @@ -544,13 +561,13 @@ private void addToQueue(@NotNull JsonObject obj) { state.updated(); } - private void handleNext(@Nullable JsonObject obj) { + private void handleNext(@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, PushToMixerReason.Next, TransitionInfo.skipTo(state)); return; } @@ -561,8 +578,10 @@ 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, PushToMixerReason.Next, trans); } else { LOGGER.fatal("Failed loading next song: " + next); panicState(); @@ -587,7 +606,7 @@ private void loadAutoplay() { state.setContextMetadata("context_description", contextDesc); events.contextChanged(); - loadTrack(true, PushToMixerReason.None); + loadTrack(true, PushToMixerReason.None, TransitionInfo.contextChange(state, false)); LOGGER.debug(String.format("Loading context for autoplay, uri: %s", newContext)); } else if (resp.statusCode == 204) { @@ -596,7 +615,7 @@ private void loadAutoplay() { state.setContextMetadata("context_description", contextDesc); events.contextChanged(); - loadTrack(true, PushToMixerReason.None); + loadTrack(true, PushToMixerReason.None, TransitionInfo.contextChange(state, false)); LOGGER.debug(String.format("Loading context for autoplay (using radio-apollo), uri: %s", state.getContextUri())); } else { @@ -620,7 +639,7 @@ private void handlePrev() { StateWrapper.PreviousPlayable prev = state.previousPlayable(); if (prev.isOk()) { state.setPosition(0); - loadTrack(true, PushToMixerReason.Prev); + loadTrack(true, PushToMixerReason.Prev, TransitionInfo.skippedPrev(state)); } else { LOGGER.fatal("Failed loading previous song: " + prev); panicState(); @@ -782,6 +801,91 @@ 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 PlaybackDescriptor.How startedHow; + + /** + * How the previous track ended + */ + final PlaybackDescriptor.How endedHow; + + /** + * When the previous track ended + */ + int endedWhen = -1; + + private TransitionInfo(@NotNull PlaybackDescriptor.How endedHow, @NotNull PlaybackDescriptor.How startedHow) { + this.startedHow = startedHow; + this.endedHow = endedHow; + } + + /** + * Context changed. + */ + @NotNull + static TransitionInfo contextChange(@NotNull StateWrapper state, boolean withSkip) { + TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.END_PLAY, withSkip ? PlaybackDescriptor.How.CLICK_ROW : PlaybackDescriptor.How.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(PlaybackDescriptor.How.END_PLAY, PlaybackDescriptor.How.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(PlaybackDescriptor.How.BACK_BTN, PlaybackDescriptor.How.BACK_BTN); + if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); + return trans; + } + + /** + * Proceeding to the next track without user intervention. + */ + @NotNull + static TransitionInfo next(@NotNull StateWrapper state) { + TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.TRACK_DONE, PlaybackDescriptor.How.TRACK_DONE); + 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(PlaybackDescriptor.How.FORWARD_BTN, PlaybackDescriptor.How.FORWARD_BTN); + if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); + return trans; + } + + /** + * Skipping to next track due to an error. + */ + @NotNull + static TransitionInfo nextError(@NotNull StateWrapper state) { + TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.TRACK_ERROR, PlaybackDescriptor.How.TRACK_ERROR); + 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"; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java index 55b43eec..4c80a2a8 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java @@ -332,8 +332,7 @@ public enum Command { } public enum PushToMixerReason { - None, Next, - Prev, Fade + None, Next, Prev, FadeNext } public interface Listener { @@ -749,9 +748,8 @@ public void run() { waitReady(); int seekTo = -1; - if (pushReason == PushToMixerReason.Fade) { + if (pushReason == PushToMixerReason.FadeNext) seekTo = crossfade.fadeInStartTime(); - } if (seekTo != -1) codec.seek(seekTo); @@ -794,5 +792,16 @@ public void run() { boolean isInMixer() { return firstHandler == this || secondHandler == this; } + + public int duration() { + return codec == null ? 0 : codec.duration(); + } + + @Nullable + public String encoding() { + if (codec instanceof VorbisCodec) return "vorbis"; + else if (codec instanceof Mp3Codec) return "mp3"; // TODO + else return null; + } } } 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 352660da..b5a746a6 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -173,6 +173,11 @@ public String getContextUri() { return state.getContextUri(); } + @Nullable + public String getContextUrl() { + return state.getContextUrl(); + } + private void loadTransforming() { if (tracksKeeper == null) throw new IllegalStateException(); @@ -510,7 +515,7 @@ PlayableId nextPlayableDoNotSet() { } @Nullable - PlayableId getCurrentPlayable() { + public PlayableId getCurrentPlayable() { return tracksKeeper == null ? null : PlayableId.from(tracksKeeper.getCurrentTrack()); } @@ -721,6 +726,11 @@ public void setContextMetadata(@NotNull String key, @Nullable String value) { else state.putContextMetadata(key, value); } + @NotNull + public PlayOrigin getPlayOrigin() { + return state.getPlayOrigin(); + } + @Nullable public String getPlaybackId() { return state.getPlaybackId(); @@ -731,11 +741,11 @@ public String getSessionId() { return state.getSessionId(); } - public void renewSessionId() { + private void renewSessionId() { state.setSessionId(generateSessionId(session.random())); } - public void renewPlaybackId() { + void renewPlaybackId() { state.setPlaybackId(generatePlaybackId(session.random())); } 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 a77ebcdc..7900d1bb 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 @@ -34,9 +34,6 @@ private static HttpUrl getUrl(@NotNull Session session, @NotNull StorageResolveR 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 { byte[] key = session.audioKey().getAudioKey(track.getGid(), file.getFileId()); CdnManager.Streamer streamer = session.cdn().streamFile(file, key, url, haltListener); - - session.eventService().cdnRequest(file, streamer.size(), url); - InputStream in = streamer.stream(); NormalizationData normalizationData = NormalizationData.read(in); if (in.skip(0xa7) != 0xa7) throw new IOException("Couldn't skip 0xa7 bytes!"); From e8c50bc34c216d8b131112de47859c8d97f5a26c Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sat, 18 Apr 2020 14:05:32 +0200 Subject: [PATCH 08/32] Compress all HTTP requests with GZip --- .../xyz/gianlu/librespot/core/Session.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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 d16857b5..35ae02cc 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -10,6 +10,9 @@ import com.spotify.explicit.ExplicitContentPubsub.UserAttributesUpdate; import okhttp3.Authenticator; import okhttp3.*; +import okio.BufferedSink; +import okio.GzipSink; +import okio.Okio; import org.apache.log4j.Logger; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; @@ -143,6 +146,36 @@ public Request authenticate(Route route, @NotNull Response response) { } } + builder.addInterceptor(chain -> { + Request original = chain.request(); + RequestBody body; + if ((body = original.body()) == null || original.header("Content-Encoding") != null) + return chain.proceed(original); + + Request compressedRequest = original.newBuilder() + .header("Content-Encoding", "gzip") + .method(original.method(), new RequestBody() { + @Override + public MediaType contentType() { + return body.contentType(); + } + + @Override + public long contentLength() { + return -1; + } + + @Override + public void writeTo(@NotNull BufferedSink sink) throws IOException { + try (BufferedSink gzipSink = Okio.buffer(new GzipSink(sink))) { + body.writeTo(gzipSink); + } + } + }).build(); + + return chain.proceed(compressedRequest); + }); + return builder.build(); } From d1f2858be6a8bb79558ee10f19ce6049256f74c4 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sat, 18 Apr 2020 16:18:09 +0200 Subject: [PATCH 09/32] Fixed fields for bitrate and file size --- .../java/xyz/gianlu/librespot/core/EventService.java | 8 ++++++-- .../librespot/player/AbsChunkedInputStream.java | 2 +- .../xyz/gianlu/librespot/player/PlayerRunner.java | 12 +++++++++++- .../xyz/gianlu/librespot/player/codecs/Codec.java | 4 ++++ .../librespot/player/feeders/cdn/CdnManager.java | 2 +- .../player/feeders/storage/AudioFileStreaming.java | 2 +- 6 files changed, 24 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index b3c10a04..e891ad8b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -68,14 +68,14 @@ public void trackTransition(@NotNull StateWrapper state, @NotNull PlaybackDescri event.append(state.getPlaybackId()).append("00000000000000000000000000000000"); event.append(playOrigin.getFeatureIdentifier()).append(desc.startedHow()); event.append(playOrigin.getFeatureIdentifier()).append(desc.endedHow()); - event.append('0').append('0'); // FIXME + event.append(String.valueOf(desc.size) /* TODO: Could be less than that */).append(String.valueOf(desc.size)); event.append(String.valueOf(when)).append(String.valueOf(when)); event.append(String.valueOf(desc.duration)); event.append('0').append('0').append('0').append('0').append('0'); // FIXME event.append(String.valueOf(desc.firstValue())); event.append('0').append("-1").append("context").append("-1").append('0').append('0').append('0').append('0').append('0'); // FIXME event.append(String.valueOf(when)).append(String.valueOf(when)); - event.append('0').append("160000"); + event.append('0').append(String.valueOf(desc.bitrate)); event.append(state.getContextUri()).append(desc.encoding); event.append(desc.id.hexId()).append(""); event.append('0').append(String.valueOf(TimeProvider.currentTimeMillis())).append('0'); @@ -198,6 +198,8 @@ byte[] toArray() { public static class PlaybackDescriptor { public final PlayableId id; final List intervals = new ArrayList<>(10); + int size; + int bitrate; int duration = 0; String encoding = null; Interval lastInterval = null; @@ -274,6 +276,8 @@ String endedHow() { public void update(@NotNull PlayerRunner.TrackHandler trackHandler) { duration = trackHandler.duration(); encoding = trackHandler.encoding(); + bitrate = trackHandler.bitrate(); + size = trackHandler.size(); } public enum How { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java b/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java index 90157873..ef7505b0 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java @@ -34,7 +34,7 @@ public final boolean isClosed() { protected abstract byte[][] buffer(); - protected abstract int size(); + public abstract int size(); @Override public void close() { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java index 4c80a2a8..71271ae6 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java @@ -800,8 +800,18 @@ public int duration() { @Nullable public String encoding() { if (codec instanceof VorbisCodec) return "vorbis"; - else if (codec instanceof Mp3Codec) return "mp3"; // TODO + else if (codec instanceof Mp3Codec) return "mp3"; else return null; } + + public int bitrate() { + AudioFormat format = codec == null ? null : codec.getAudioFormat(); + if (format == null) return 0; + else return (int) (format.getSampleRate() * format.getSampleSizeInBits()); + } + + public int size() { + return codec == null ? 0 : codec.size(); + } } } 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..6e430577 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 @@ -101,6 +101,10 @@ public final int duration() { return duration; } + public int size() { + return audioIn.size(); + } + public static class CannotGetTimeException extends Exception { CannotGetTimeException() { } 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 7041342d..b774ea07 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 @@ -339,7 +339,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/AudioFileStreaming.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java index 4f36a6ed..b1f063e2 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 @@ -207,7 +207,7 @@ protected byte[][] buffer() { } @Override - protected int size() { + public int size() { return size; } From c1492fed2ac05168c2a4f951cfe519e6bc2e1f43 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sat, 18 Apr 2020 18:15:33 +0200 Subject: [PATCH 10/32] Send track played when shutting down or panicking --- .../gianlu/librespot/core/EventService.java | 2 +- .../xyz/gianlu/librespot/core/Session.java | 10 ++--- .../xyz/gianlu/librespot/player/Player.java | 45 ++++++++++++------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index e891ad8b..12da1b70 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -282,7 +282,7 @@ public void update(@NotNull PlayerRunner.TrackHandler trackHandler) { public enum How { TRACK_DONE("trackdone"), TRACK_ERROR("trackerror"), FORWARD_BTN("fwdbtn"), BACK_BTN("backbtn"), - END_PLAY("endplay"), PLAY_BTN("playbtn"), CLICK_ROW("clickrow"), LOGOUT("logout" /* TODO: Use me */), APP_LOAD("appload"); + END_PLAY("endplay"), PLAY_BTN("playbtn"), CLICK_ROW("clickrow"), LOGOUT("logout"), APP_LOAD("appload"); final String val; 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 35ae02cc..c956c4fe 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -429,11 +429,6 @@ private void authenticatePartial(@NotNull Authentication.LoginCredentials creden @Override public void close() throws IOException { - if (receiver != null) { - receiver.stop(); - receiver = null; - } - if (player != null) { player.close(); player = null; @@ -464,6 +459,11 @@ public void close() throws IOException { mercuryClient = null; } + if (receiver != null) { + receiver.stop(); + receiver = null; + } + executorService.shutdown(); conn.socket.close(); 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 f830d9e2..3106e766 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -16,6 +16,7 @@ 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; import xyz.gianlu.librespot.core.EventService.PlaybackDescriptor; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; @@ -118,11 +119,11 @@ public void load(@NotNull String uri, boolean play) { state.loadContext(uri); } catch (IOException | MercuryClient.MercuryException ex) { LOGGER.fatal("Failed loading context!", ex); - panicState(); + panicState(null); return; } catch (AbsSpotifyContext.UnsupportedContextException ex) { LOGGER.fatal("Cannot play local tracks!", ex); - panicState(); + panicState(null); return; } @@ -137,11 +138,11 @@ private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { state.transfer(cmd); } catch (IOException | MercuryClient.MercuryException ex) { LOGGER.fatal("Failed loading context!", ex); - panicState(); + panicState(null); return; } catch (AbsSpotifyContext.UnsupportedContextException ex) { LOGGER.fatal("Cannot play local tracks!", ex); - panicState(); + panicState(null); return; } @@ -159,11 +160,11 @@ private void handleLoad(@NotNull JsonObject obj) { state.load(obj); } catch (IOException | MercuryClient.MercuryException ex) { LOGGER.fatal("Failed loading context!", ex); - panicState(); + panicState(null); return; } catch (AbsSpotifyContext.UnsupportedContextException ex) { LOGGER.fatal("Cannot play local tracks!", ex); - panicState(); + panicState(null); return; } @@ -281,7 +282,7 @@ public void finishedLoading(@NotNull TrackHandler handler, int pos) { @Override public void mixerError(@NotNull Exception ex) { LOGGER.fatal("Mixer error!", ex); - panicState(); + panicState(PlaybackDescriptor.How.TRACK_ERROR); } @Override @@ -294,7 +295,7 @@ public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, } LOGGER.fatal(String.format("Failed loading track, gid: %s", Utils.bytesToHex(id.getGid())), ex); - panicState(); + panicState(PlaybackDescriptor.How.TRACK_ERROR); } else if (handler == preloadTrackHandler) { LOGGER.warn("Preloaded track loading failed!", ex); preloadTrackHandler = null; @@ -374,7 +375,7 @@ public void playbackError(@NotNull TrackHandler handler, @NotNull Exception ex) else LOGGER.fatal("Playback error!", ex); - panicState(); + panicState(PlaybackDescriptor.How.TRACK_ERROR); } else if (handler == preloadTrackHandler) { LOGGER.warn("Preloaded track loading failed!", ex); preloadTrackHandler = null; @@ -417,10 +418,17 @@ private void handleSeek(int pos) { events.seeked(pos); } - private void panicState() { + private void panicState(@Nullable EventService.PlaybackDescriptor.How how) { runner.stopMixer(); state.setState(false, false, false); state.updated(); + + if (how != null && playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { + playbackDescriptor.endedHow(how); + playbackDescriptor.endInterval(state.getPosition()); + session.eventService().trackPlayed(state, playbackDescriptor); + playbackDescriptor = null; + } } private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull TransitionInfo trans) { @@ -584,7 +592,7 @@ private void handleNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) loadTrack(next == NextPlayable.OK_PLAY || next == NextPlayable.OK_REPEAT, PushToMixerReason.Next, trans); } else { LOGGER.fatal("Failed loading next song: " + next); - panicState(); + panicState(PlaybackDescriptor.How.END_PLAY); } } @@ -592,7 +600,7 @@ private void loadAutoplay() { String context = state.getContextUri(); if (context == null) { LOGGER.fatal("Cannot load autoplay with null context!"); - panicState(); + panicState(null); return; } @@ -627,10 +635,10 @@ 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); } } @@ -642,7 +650,7 @@ private void handlePrev() { loadTrack(true, PushToMixerReason.Prev, TransitionInfo.skippedPrev(state)); } else { LOGGER.fatal("Failed loading previous song: " + prev); - panicState(); + panicState(null); } } else { state.setPosition(0); @@ -653,6 +661,13 @@ private void handlePrev() { @Override public void close() throws IOException { + if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { + playbackDescriptor.endedHow(PlaybackDescriptor.How.LOGOUT); + playbackDescriptor.endInterval(state.getPosition()); + session.eventService().trackPlayed(state, playbackDescriptor); + playbackDescriptor = null; + } + if (trackHandler != null) { trackHandler.close(); trackHandler = null; From db5a89a4308d1c887faa929483edc2727dcd46ca Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sun, 19 Apr 2020 20:58:34 +0200 Subject: [PATCH 11/32] Better handling of play origin + count decoded size --- .../gianlu/librespot/core/EventService.java | 16 +++++++++---- .../librespot/mercury/MercuryClient.java | 24 +++++++++---------- .../player/AbsChunkedInputStream.java | 6 +++++ .../xyz/gianlu/librespot/player/Player.java | 8 +++---- .../gianlu/librespot/player/PlayerRunner.java | 4 ++++ .../gianlu/librespot/player/codecs/Codec.java | 4 ++++ 6 files changed, 41 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index 12da1b70..4a5616a6 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -66,9 +66,9 @@ public void trackTransition(@NotNull StateWrapper state, @NotNull PlaybackDescri event.append(String.valueOf(trackTransitionIncremental++)); event.append(session.deviceId()); event.append(state.getPlaybackId()).append("00000000000000000000000000000000"); - event.append(playOrigin.getFeatureIdentifier()).append(desc.startedHow()); - event.append(playOrigin.getFeatureIdentifier()).append(desc.endedHow()); - event.append(String.valueOf(desc.size) /* TODO: Could be less than that */).append(String.valueOf(desc.size)); + event.append(desc.startedOrigin).append(desc.startedHow()); + event.append(desc.endedOrigin).append(desc.endedHow()); + event.append(String.valueOf(desc.decodedLength)).append(String.valueOf(desc.size)); event.append(String.valueOf(when)).append(String.valueOf(when)); event.append(String.valueOf(desc.duration)); event.append('0').append('0').append('0').append('0').append('0'); // FIXME @@ -198,13 +198,16 @@ byte[] toArray() { public static class PlaybackDescriptor { public final PlayableId id; final List intervals = new ArrayList<>(10); + int decodedLength; int size; int bitrate; int duration = 0; String encoding = null; Interval lastInterval = null; How startedHow = null; + String startedOrigin = null; How endedHow = null; + String endedOrigin = null; public PlaybackDescriptor(@NotNull PlayableId id) { this.id = id; @@ -255,12 +258,14 @@ public void endInterval(int end) { lastInterval = null; } - public void startedHow(@NotNull How how) { + public void startedHow(@NotNull How how, @Nullable String origin) { startedHow = how; + startedOrigin = origin == null ? "unknown" : origin; } - public void endedHow(@NotNull How how) { + public void endedHow(@NotNull How how, @Nullable String origin) { endedHow = how; + endedOrigin = origin == null ? "unknown" : origin; } @Nullable @@ -278,6 +283,7 @@ public void update(@NotNull PlayerRunner.TrackHandler trackHandler) { encoding = trackHandler.encoding(); bitrate = trackHandler.bitrate(); size = trackHandler.size(); + decodedLength = trackHandler.decodedLength(); } public enum How { 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/player/AbsChunkedInputStream.java b/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java index ef7505b0..5b3055fb 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/AbsChunkedInputStream.java @@ -22,6 +22,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()]; @@ -219,6 +220,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 +244,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/Player.java b/core/src/main/java/xyz/gianlu/librespot/player/Player.java index 3106e766..a81dee87 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -424,7 +424,7 @@ private void panicState(@Nullable EventService.PlaybackDescriptor.How how) { state.updated(); if (how != null && playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { - playbackDescriptor.endedHow(how); + playbackDescriptor.endedHow(how, null); playbackDescriptor.endInterval(state.getPosition()); session.eventService().trackPlayed(state, playbackDescriptor); playbackDescriptor = null; @@ -433,7 +433,7 @@ private void panicState(@Nullable EventService.PlaybackDescriptor.How how) { private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull TransitionInfo trans) { if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { - playbackDescriptor.endedHow(trans.endedHow); + playbackDescriptor.endedHow(trans.endedHow, state.getPlayOrigin().getFeatureIdentifier()); playbackDescriptor.endInterval(trans.endedWhen); session.eventService().trackPlayed(state, playbackDescriptor); playbackDescriptor = null; @@ -509,7 +509,7 @@ private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull releaseLineFuture = null; } - playbackDescriptor.startedHow(trans.startedHow); + playbackDescriptor.startedHow(trans.startedHow, state.getPlayOrigin().getFeatureIdentifier()); playbackDescriptor.startInterval(state.getPosition()); } @@ -662,7 +662,7 @@ private void handlePrev() { @Override public void close() throws IOException { if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { - playbackDescriptor.endedHow(PlaybackDescriptor.How.LOGOUT); + playbackDescriptor.endedHow(PlaybackDescriptor.How.LOGOUT, null); playbackDescriptor.endInterval(state.getPosition()); session.eventService().trackPlayed(state, playbackDescriptor); playbackDescriptor = null; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java index 71271ae6..8710383c 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java @@ -813,5 +813,9 @@ public int bitrate() { public int size() { return codec == null ? 0 : codec.size(); } + + public int decodedLength() { + return codec == null ? 0 : codec.decodedLength(); + } } } 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 6e430577..d385d3da 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 @@ -105,6 +105,10 @@ public int size() { return audioIn.size(); } + public int decodedLength() { + return audioIn.decodedLength(); + } + public static class CannotGetTimeException extends Exception { CannotGetTimeException() { } From 887286a0e32d904549b9c243e52e6a09608c5204 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 20 Apr 2020 14:21:39 +0200 Subject: [PATCH 12/32] Minor refactoring --- .../gianlu/librespot/core/EventService.java | 85 +++++++++---------- .../xyz/gianlu/librespot/player/Player.java | 82 +++++++++--------- .../gianlu/librespot/player/PlayerRunner.java | 48 ++++++----- 3 files changed, 105 insertions(+), 110 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index 4a5616a6..400f8f0f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -58,41 +58,42 @@ public void reportLang(@NotNull String lang) { sendEvent(event); } - public void trackTransition(@NotNull StateWrapper state, @NotNull PlaybackDescriptor desc) { + public void trackTransition(@NotNull StateWrapper state, @NotNull EventService.PlaybackMetrics metrics) { Player.PlayOrigin playOrigin = state.getPlayOrigin(); - int when = desc.lastValue(); + int when = metrics.lastValue(); EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); event.append(String.valueOf(trackTransitionIncremental++)); event.append(session.deviceId()); event.append(state.getPlaybackId()).append("00000000000000000000000000000000"); - event.append(desc.startedOrigin).append(desc.startedHow()); - event.append(desc.endedOrigin).append(desc.endedHow()); - event.append(String.valueOf(desc.decodedLength)).append(String.valueOf(desc.size)); + 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(desc.duration)); - event.append('0').append('0').append('0').append('0').append('0'); // FIXME - event.append(String.valueOf(desc.firstValue())); - event.append('0').append("-1").append("context").append("-1").append('0').append('0').append('0').append('0').append('0'); // FIXME + event.append(String.valueOf(metrics.player.duration)); + event.append('0' /* TODO: Encrypt latency */).append('0' /* TODO: Total fade */).append('0' /* FIXME */).append('0'); + event.append(metrics.firstValue() == 0 ? '0' : '1').append(String.valueOf(metrics.firstValue())); + event.append('0' /* TODO: Play latency */).append("-1" /* FIXME */).append("context"); + event.append("-1" /* TODO: Audio key sync time */).append('0').append('0' /* TODO: Prefetched audio key */).append('0').append('0' /* FIXME */).append('0'); event.append(String.valueOf(when)).append(String.valueOf(when)); - event.append('0').append(String.valueOf(desc.bitrate)); - event.append(state.getContextUri()).append(desc.encoding); - event.append(desc.id.hexId()).append(""); + event.append('0').append(String.valueOf(metrics.player.bitrate)); + event.append(state.getContextUri()).append(metrics.player.encoding); + event.append(metrics.id.hexId()).append(""); event.append('0').append(String.valueOf(TimeProvider.currentTimeMillis())).append('0'); event.append("context").append(playOrigin.getReferrerIdentifier()).append(playOrigin.getFeatureVersion()); - event.append("com.spotify").append("none").append("none").append("local").append("na").append("none"); + event.append("com.spotify").append("none" /* TODO: Transition */).append("none").append("local").append("na").append("none"); sendEvent(event); } - public void trackPlayed(@NotNull StateWrapper state, @NotNull EventService.PlaybackDescriptor desc) { - if (desc.duration == 0 || desc.encoding == null) + public void trackPlayed(@NotNull StateWrapper state, @NotNull EventService.PlaybackMetrics metrics) { + if (metrics.player == null) return; - trackTransition(state, desc); + trackTransition(state, metrics); EventBuilder event = new EventBuilder(Type.TRACK_PLAYED); - event.append(state.getPlaybackId()).append(desc.id.toSpotifyUri()); - event.append('0').append(desc.intervalsToSend()); + event.append(state.getPlaybackId()).append(metrics.id.toSpotifyUri()); + event.append('0').append(metrics.intervalsToSend()); sendEvent(event); } @@ -195,21 +196,17 @@ byte[] toArray() { } } - public static class PlaybackDescriptor { + public static class PlaybackMetrics { public final PlayableId id; final List intervals = new ArrayList<>(10); - int decodedLength; - int size; - int bitrate; - int duration = 0; - String encoding = null; + PlayerRunner.PlayerMetrics player = null; Interval lastInterval = null; - How startedHow = null; - String startedOrigin = null; - How endedHow = null; - String endedOrigin = null; + Reason reasonStart = null; + String sourceStart = null; + Reason reasonEnd = null; + String sourceEnd = null; - public PlaybackDescriptor(@NotNull PlayableId id) { + public PlaybackMetrics(@NotNull PlayableId id) { this.id = id; } @@ -238,7 +235,7 @@ int firstValue() { } int lastValue() { - if (intervals.isEmpty()) return duration; + if (intervals.isEmpty()) return player == null ? 0 : player.duration; else return intervals.get(intervals.size() - 1).end; } @@ -258,41 +255,37 @@ public void endInterval(int end) { lastInterval = null; } - public void startedHow(@NotNull How how, @Nullable String origin) { - startedHow = how; - startedOrigin = origin == null ? "unknown" : origin; + public void startedHow(@NotNull EventService.PlaybackMetrics.Reason reason, @Nullable String origin) { + reasonStart = reason; + sourceStart = origin == null ? "unknown" : origin; } - public void endedHow(@NotNull How how, @Nullable String origin) { - endedHow = how; - endedOrigin = 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 startedHow == null ? null : startedHow.val; + return reasonStart == null ? null : reasonStart.val; } @Nullable String endedHow() { - return endedHow == null ? null : endedHow.val; + return reasonEnd == null ? null : reasonEnd.val; } - public void update(@NotNull PlayerRunner.TrackHandler trackHandler) { - duration = trackHandler.duration(); - encoding = trackHandler.encoding(); - bitrate = trackHandler.bitrate(); - size = trackHandler.size(); - decodedLength = trackHandler.decodedLength(); + public void update(@NotNull PlayerRunner.PlayerMetrics playerMetrics) { + player = playerMetrics; } - public enum How { + 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"); final String val; - How(@NotNull String val) { + Reason(@NotNull String val) { this.val = val; } } 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 a81dee87..20abb554 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -17,7 +17,7 @@ import xyz.gianlu.librespot.connectstate.DeviceStateHandler; import xyz.gianlu.librespot.connectstate.DeviceStateHandler.PlayCommandHelper; import xyz.gianlu.librespot.core.EventService; -import xyz.gianlu.librespot.core.EventService.PlaybackDescriptor; +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; @@ -56,7 +56,7 @@ public class Player implements Closeable, DeviceStateHandler.Listener, PlayerRun private TrackHandler crossfadeHandler; private TrackHandler preloadTrackHandler; private ScheduledFuture releaseLineFuture = null; - private PlaybackDescriptor playbackDescriptor = null; + private PlaybackMetrics playbackMetrics = null; public Player(@NotNull Player.Configuration conf, @NotNull Session session) { this.conf = conf; @@ -259,8 +259,8 @@ private void updateStateWithHandler() { else if ((episode = trackHandler.episode()) != null) state.enrichWithMetadata(episode); else LOGGER.warn("Couldn't update metadata!"); - if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) - playbackDescriptor.update(trackHandler); + if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) + playbackMetrics.update(trackHandler.metrics()); events.metadataAvailable(); } @@ -282,7 +282,7 @@ public void finishedLoading(@NotNull TrackHandler handler, int pos) { @Override public void mixerError(@NotNull Exception ex) { LOGGER.fatal("Mixer error!", ex); - panicState(PlaybackDescriptor.How.TRACK_ERROR); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); } @Override @@ -295,7 +295,7 @@ public void loadingError(@NotNull TrackHandler handler, @NotNull PlayableId id, } LOGGER.fatal(String.format("Failed loading track, gid: %s", Utils.bytesToHex(id.getGid())), ex); - panicState(PlaybackDescriptor.How.TRACK_ERROR); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); } else if (handler == preloadTrackHandler) { LOGGER.warn("Preloaded track loading failed!", ex); preloadTrackHandler = null; @@ -375,7 +375,7 @@ public void playbackError(@NotNull TrackHandler handler, @NotNull Exception ex) else LOGGER.fatal("Playback error!", ex); - panicState(PlaybackDescriptor.How.TRACK_ERROR); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); } else if (handler == preloadTrackHandler) { LOGGER.warn("Preloaded track loading failed!", ex); preloadTrackHandler = null; @@ -408,9 +408,9 @@ public void playbackResumedFromHalt(@NotNull TrackHandler handler, int chunk, lo } private void handleSeek(int pos) { - if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { - playbackDescriptor.endInterval(state.getPosition()); - playbackDescriptor.startInterval(pos); + if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { + playbackMetrics.endInterval(state.getPosition()); + playbackMetrics.startInterval(pos); } state.setPosition(pos); @@ -418,25 +418,25 @@ private void handleSeek(int pos) { events.seeked(pos); } - private void panicState(@Nullable EventService.PlaybackDescriptor.How how) { + private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { runner.stopMixer(); state.setState(false, false, false); state.updated(); - if (how != null && playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { - playbackDescriptor.endedHow(how, null); - playbackDescriptor.endInterval(state.getPosition()); - session.eventService().trackPlayed(state, playbackDescriptor); - playbackDescriptor = null; + if (reason != null && playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { + playbackMetrics.endedHow(reason, null); + playbackMetrics.endInterval(state.getPosition()); + session.eventService().trackPlayed(state, playbackMetrics); + playbackMetrics = null; } } private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull TransitionInfo trans) { - if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { - playbackDescriptor.endedHow(trans.endedHow, state.getPlayOrigin().getFeatureIdentifier()); - playbackDescriptor.endInterval(trans.endedWhen); - session.eventService().trackPlayed(state, playbackDescriptor); - playbackDescriptor = null; + if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { + playbackMetrics.endedHow(trans.endedReason, state.getPlayOrigin().getFeatureIdentifier()); + playbackMetrics.endInterval(trans.endedWhen); + session.eventService().trackPlayed(state, playbackMetrics); + playbackMetrics = null; } if (trackHandler != null) { @@ -447,7 +447,7 @@ private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull state.renewPlaybackId(); PlayableId id = state.getCurrentPlayableOrThrow(); - playbackDescriptor = new PlaybackDescriptor(id); + playbackMetrics = new PlaybackMetrics(id); if (crossfadeHandler != null && crossfadeHandler.isPlayable(id)) { trackHandler = crossfadeHandler; if (preloadTrackHandler == crossfadeHandler) preloadTrackHandler = null; @@ -509,8 +509,8 @@ private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull releaseLineFuture = null; } - playbackDescriptor.startedHow(trans.startedHow, state.getPlayOrigin().getFeatureIdentifier()); - playbackDescriptor.startInterval(state.getPosition()); + playbackMetrics.startedHow(trans.startedReason, state.getPlayOrigin().getFeatureIdentifier()); + playbackMetrics.startInterval(state.getPosition()); } private void handleResume() { @@ -592,7 +592,7 @@ private void handleNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) loadTrack(next == NextPlayable.OK_PLAY || next == NextPlayable.OK_REPEAT, PushToMixerReason.Next, trans); } else { LOGGER.fatal("Failed loading next song: " + next); - panicState(PlaybackDescriptor.How.END_PLAY); + panicState(PlaybackMetrics.Reason.END_PLAY); } } @@ -661,11 +661,11 @@ private void handlePrev() { @Override public void close() throws IOException { - if (playbackDescriptor != null && trackHandler.isPlayable(playbackDescriptor.id)) { - playbackDescriptor.endedHow(PlaybackDescriptor.How.LOGOUT, null); - playbackDescriptor.endInterval(state.getPosition()); - session.eventService().trackPlayed(state, playbackDescriptor); - playbackDescriptor = null; + if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { + playbackMetrics.endedHow(PlaybackMetrics.Reason.LOGOUT, null); + playbackMetrics.endInterval(state.getPosition()); + session.eventService().trackPlayed(state, playbackMetrics); + playbackMetrics = null; } if (trackHandler != null) { @@ -823,21 +823,21 @@ private static class TransitionInfo { /** * How the next track started */ - final PlaybackDescriptor.How startedHow; + final PlaybackMetrics.Reason startedReason; /** * How the previous track ended */ - final PlaybackDescriptor.How endedHow; + final PlaybackMetrics.Reason endedReason; /** * When the previous track ended */ int endedWhen = -1; - private TransitionInfo(@NotNull PlaybackDescriptor.How endedHow, @NotNull PlaybackDescriptor.How startedHow) { - this.startedHow = startedHow; - this.endedHow = endedHow; + private TransitionInfo(@NotNull EventService.PlaybackMetrics.Reason endedReason, @NotNull EventService.PlaybackMetrics.Reason startedReason) { + this.startedReason = startedReason; + this.endedReason = endedReason; } /** @@ -845,7 +845,7 @@ private TransitionInfo(@NotNull PlaybackDescriptor.How endedHow, @NotNull Playba */ @NotNull static TransitionInfo contextChange(@NotNull StateWrapper state, boolean withSkip) { - TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.END_PLAY, withSkip ? PlaybackDescriptor.How.CLICK_ROW : PlaybackDescriptor.How.PLAY_BTN); + 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; } @@ -855,7 +855,7 @@ static TransitionInfo contextChange(@NotNull StateWrapper state, boolean withSki */ @NotNull static TransitionInfo skipTo(@NotNull StateWrapper state) { - TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.END_PLAY, PlaybackDescriptor.How.CLICK_ROW); + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.END_PLAY, PlaybackMetrics.Reason.CLICK_ROW); if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); return trans; } @@ -865,7 +865,7 @@ static TransitionInfo skipTo(@NotNull StateWrapper state) { */ @NotNull static TransitionInfo skippedPrev(@NotNull StateWrapper state) { - TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.BACK_BTN, PlaybackDescriptor.How.BACK_BTN); + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.BACK_BTN, PlaybackMetrics.Reason.BACK_BTN); if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); return trans; } @@ -875,7 +875,7 @@ static TransitionInfo skippedPrev(@NotNull StateWrapper state) { */ @NotNull static TransitionInfo next(@NotNull StateWrapper state) { - TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.TRACK_DONE, PlaybackDescriptor.How.TRACK_DONE); + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.TRACK_DONE, PlaybackMetrics.Reason.TRACK_DONE); if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); return trans; } @@ -885,7 +885,7 @@ static TransitionInfo next(@NotNull StateWrapper state) { */ @NotNull static TransitionInfo skippedNext(@NotNull StateWrapper state) { - TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.FORWARD_BTN, PlaybackDescriptor.How.FORWARD_BTN); + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.FORWARD_BTN, PlaybackMetrics.Reason.FORWARD_BTN); if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); return trans; } @@ -895,7 +895,7 @@ static TransitionInfo skippedNext(@NotNull StateWrapper state) { */ @NotNull static TransitionInfo nextError(@NotNull StateWrapper state) { - TransitionInfo trans = new TransitionInfo(PlaybackDescriptor.How.TRACK_ERROR, PlaybackDescriptor.How.TRACK_ERROR); + TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.TRACK_ERROR, PlaybackMetrics.Reason.TRACK_ERROR); if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); return trans; } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java index 8710383c..23fe1fac 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java @@ -513,6 +513,28 @@ private CommandBundle(@NotNull Command cmd, int id, Object... args) { } } + public static class PlayerMetrics { + public int decodedLength = 0; + public int size = 0; + public int bitrate = 0; + public int duration = 0; + public String encoding = null; + + private PlayerMetrics(@Nullable Codec codec) { + if (codec == null) return; + + size = codec.size(); + duration = codec.duration(); + decodedLength = codec.decodedLength(); + + AudioFormat format = codec.getAudioFormat(); + bitrate = (int) (format.getSampleRate() * format.getSampleSizeInBits()); + + if (codec instanceof VorbisCodec) encoding = "vorbis"; + else if (codec instanceof Mp3Codec) encoding = "mp3"; + } + } + public class TrackHandler implements HaltListener, Closeable, Runnable { private final int id; private final PlayableId playable; @@ -793,29 +815,9 @@ boolean isInMixer() { return firstHandler == this || secondHandler == this; } - public int duration() { - return codec == null ? 0 : codec.duration(); - } - - @Nullable - public String encoding() { - if (codec instanceof VorbisCodec) return "vorbis"; - else if (codec instanceof Mp3Codec) return "mp3"; - else return null; - } - - public int bitrate() { - AudioFormat format = codec == null ? null : codec.getAudioFormat(); - if (format == null) return 0; - else return (int) (format.getSampleRate() * format.getSampleSizeInBits()); - } - - public int size() { - return codec == null ? 0 : codec.size(); - } - - public int decodedLength() { - return codec == null ? 0 : codec.decodedLength(); + @NotNull + public PlayerMetrics metrics() { + return new PlayerMetrics(codec); } } } From 48a8416e2ce7b8cbaa50e76f442e8bec6c217a14 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Tue, 21 Apr 2020 12:05:58 +0200 Subject: [PATCH 13/32] Rewritten player (missing crossfade) + fixed issue with cache + general refactoring --- .../librespot/api/handlers/EventsHandler.java | 17 +- .../librespot/api/handlers/PlayerHandler.java | 19 +- .../librespot/common/NameThreadFactory.java | 14 +- .../gianlu/librespot/FileConfiguration.java | 4 +- .../gianlu/librespot/cache/CacheJournal.java | 12 +- .../gianlu/librespot/cache/CacheManager.java | 4 +- .../gianlu/librespot/cache/JournalHeader.java | 4 +- .../connectstate/DeviceStateHandler.java | 7 +- .../gianlu/librespot/core/EventService.java | 8 +- .../librespot/mercury/model/EpisodeId.java | 13 + .../librespot/mercury/model/PlayableId.java | 2 + .../librespot/mercury/model/TrackId.java | 13 + .../xyz/gianlu/librespot/player/Player.java | 648 ++++++-------- .../gianlu/librespot/player/PlayerRunner.java | 823 ------------------ .../gianlu/librespot/player/StateWrapper.java | 13 +- .../librespot/player/TrackOrEpisode.java | 83 ++ .../gianlu/librespot/player/codecs/Codec.java | 2 +- .../player/feeders/storage/AudioFile.java | 2 +- .../feeders/storage/AudioFileFetch.java | 4 +- .../feeders/storage/AudioFileStreaming.java | 2 +- .../librespot/player/mixing/AudioSink.java | 302 +++++++ .../librespot/player/mixing/MixingLine.java | 10 +- .../librespot/player/queue/PlayerQueue.java | 406 +++++++++ .../librespot/player/queue/QueueEntry.java | 270 ++++++ 24 files changed, 1428 insertions(+), 1254 deletions(-) delete mode 100644 core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java create mode 100644 core/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java create mode 100644 core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java create mode 100644 core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java create mode 100644 core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java 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..35e3d2e1 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; } @@ -64,22 +64,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 aa697a20..e9ee57b4 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; 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 062bf6b3..29f09605 100644 --- a/core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java +++ b/core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java @@ -89,7 +89,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); @@ -98,7 +98,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(); @@ -250,17 +250,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++) { @@ -298,7 +298,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..be7627e5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java +++ b/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java @@ -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<>(); @@ -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 b3efa599..5e6f03d6 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; @@ -75,7 +74,7 @@ private static Connect.DeviceInfo.Builder initializeDeviceInfo(@NotNull Session .setCanBePlayer(true).setGaiaEqConnectId(true).setSupportsLogout(true) .setIsObservable(true).setCommandAcks(true).setSupportsRename(false) .setSupportsPlaylistV2(true).setIsControllable(true).setSupportsTransferCommand(true) - .setSupportsCommandRequest(true).setVolumeSteps(PlayerRunner.VOLUME_STEPS) + .setSupportsCommandRequest(true).setVolumeSteps(xyz.gianlu.librespot.player.Player.VOLUME_STEPS) .setSupportsGzipPushes(true).setNeedsFullPlayerState(false) .addSupportedTypes("audio/episode") .addSupportedTypes("audio/track") @@ -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); @@ -220,7 +219,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 diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index 400f8f0f..ae3ac39a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -10,8 +10,8 @@ import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.RawMercuryRequest; import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.player.PlayerRunner; import xyz.gianlu.librespot.player.StateWrapper; +import xyz.gianlu.librespot.player.queue.PlayerQueue; import java.io.ByteArrayOutputStream; import java.io.Closeable; @@ -31,7 +31,7 @@ public final class EventService implements Closeable { EventService(@NotNull Session session) { this.session = session; - this.asyncWorker = new AsyncWorker<>("event-service", eventBuilder -> { + this.asyncWorker = new AsyncWorker<>("event-service-sender", eventBuilder -> { try { byte[] body = eventBuilder.toArray(); MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() @@ -199,7 +199,7 @@ byte[] toArray() { public static class PlaybackMetrics { public final PlayableId id; final List intervals = new ArrayList<>(10); - PlayerRunner.PlayerMetrics player = null; + PlayerQueue.PlayerMetrics player = null; Interval lastInterval = null; Reason reasonStart = null; String sourceStart = null; @@ -275,7 +275,7 @@ String endedHow() { return reasonEnd == null ? null : reasonEnd.val; } - public void update(@NotNull PlayerRunner.PlayerMetrics playerMetrics) { + public void update(@NotNull PlayerQueue.PlayerMetrics playerMetrics) { player = playerMetrics; } 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 20abb554..a78ca947 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -13,7 +13,6 @@ 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; @@ -23,12 +22,12 @@ 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.mixing.AudioSink; +import xyz.gianlu.librespot.player.queue.PlayerQueue; import java.io.Closeable; import java.io.File; @@ -44,17 +43,17 @@ /** * @author Gianlu */ -public class Player implements Closeable, DeviceStateHandler.Listener, PlayerRunner.Listener { +public class Player implements Closeable, DeviceStateHandler.Listener, PlayerQueue.Listener { // TODO: Reduce calls to state update + public static final int VOLUME_STEPS = 64; + public static final int VOLUME_MAX = 65536; + public static final int VOLUME_ONE_STEP = VOLUME_MAX / VOLUME_STEPS; 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 PlayerQueue queue; private final EventsDispatcher events; private StateWrapper state; - private TrackHandler trackHandler; - private TrackHandler crossfadeHandler; - private TrackHandler preloadTrackHandler; private ScheduledFuture releaseLineFuture = null; private PlaybackMetrics playbackMetrics = null; @@ -62,7 +61,7 @@ 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.queue = new PlayerQueue(session, conf, this); } public void addEventsListener(@NotNull EventsListener listener) { @@ -73,6 +72,11 @@ public void removeEventsListener(@NotNull EventsListener listener) { events.listeners.remove(listener); } + + // ================================ // + // =========== Commands =========== // + // ================================ // + public void initState() { this.state = new StateWrapper(session); state.addListener(this); @@ -80,16 +84,16 @@ public void initState() { public void volumeUp() { if (state == null) return; - setVolume(Math.min(PlayerRunner.VOLUME_MAX, state.getVolume() + PlayerRunner.VOLUME_ONE_STEP)); + setVolume(Math.min(VOLUME_MAX, state.getVolume() + VOLUME_ONE_STEP)); } public void volumeDown() { if (state == null) return; - setVolume(Math.max(0, state.getVolume() - PlayerRunner.VOLUME_ONE_STEP)); + setVolume(Math.max(0, state.getVolume() - VOLUME_ONE_STEP)); } 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); @@ -128,9 +132,14 @@ public void load(@NotNull String uri, boolean play) { } events.contextChanged(); - loadTrack(play, PushToMixerReason.None, TransitionInfo.contextChange(state, true)); + loadTrack(play, TransitionInfo.contextChange(state, true)); } + + // ================================ // + // ======== Internal state ======== // + // ================================ // + private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { LOGGER.debug(String.format("Loading context (transfer), uri: %s", cmd.getCurrentSession().getContext().getUri())); @@ -147,7 +156,7 @@ private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { } events.contextChanged(); - loadTrack(!cmd.getPlayback().getIsPaused(), PushToMixerReason.None, TransitionInfo.contextChange(state, true)); + loadTrack(!cmd.getPlayback().getIsPaused(), TransitionInfo.contextChange(state, true)); session.eventService().newSessionId(state); session.eventService().newPlaybackId(state); @@ -172,7 +181,20 @@ private void handleLoad(@NotNull JsonObject obj) { Boolean paused = PlayCommandHelper.isInitiallyPaused(obj); if (paused == null) paused = true; - loadTrack(!paused, PushToMixerReason.None, TransitionInfo.contextChange(state, PlayCommandHelper.willSkipToSomething(obj))); + loadTrack(!paused, TransitionInfo.contextChange(state, PlayCommandHelper.willSkipToSomething(obj))); + } + + private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { + queue.pause(true); + state.setState(false, false, false); + state.updated(); + + if (reason != null && playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { + playbackMetrics.endedHow(reason, null); + playbackMetrics.endInterval(state.getPosition()); + session.eventService().trackPlayed(state, playbackMetrics); + playbackMetrics = null; + } } @Override @@ -235,273 +257,112 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi @Override public void volumeChanged() { - runner.setVolume(state.getVolume()); + queue.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(); - } + queue.pause(true); } - 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!"); + private void entryIsReady() { + if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) + playbackMetrics.update(queue.currentMetrics()); - if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) - playbackMetrics.update(trackHandler.metrics()); - - 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."); + TrackOrEpisode metadata = currentMetadata(); + if (metadata != null) { + state.enrichWithMetadata(metadata); + events.metadataAvailable(); } } - @Override - public void mixerError(@NotNull Exception ex) { - LOGGER.fatal("Mixer error!", ex); - panicState(PlaybackMetrics.Reason.TRACK_ERROR); - } - - @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, TransitionInfo.nextError(state)); - return; - } - - LOGGER.fatal(String.format("Failed loading track, gid: %s", Utils.bytesToHex(id.getGid())), ex); - panicState(PlaybackMetrics.Reason.TRACK_ERROR); - } 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, TransitionInfo.next(state)); - - 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)); + private void handleSeek(int pos) { + if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { + playbackMetrics.endInterval(state.getPosition()); + playbackMetrics.startInterval(pos); } - } - @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())); - } - } + state.setPosition(pos); + queue.seekCurrent(pos); + events.seeked(pos); } - @Override - public void crossfadeNextTrack(@NotNull TrackHandler handler, @Nullable String uri) { - if (handler == trackHandler) { - PlayableId next = state.nextPlayableDoNotSet(); - if (next == null) return; + private void handleResume() { + if (state.isPaused()) { + state.setState(true, false, false); + queue.resume(); - 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)); + state.updated(); + events.playbackResumed(); - if (preloadTrackHandler != null && preloadTrackHandler.isPlayable(next)) { - crossfadeHandler = preloadTrackHandler; - } else { - LOGGER.warn("Did not preload crossfade track. That's bad."); - crossfadeHandler = runner.load(next, 0); + if (releaseLineFuture != null) { + releaseLineFuture.cancel(true); + releaseLineFuture = null; } - - crossfadeHandler.waitReady(); - LOGGER.info("Crossfading to next track."); - crossfadeHandler.pushToMixer(PushToMixerReason.FadeNext); - } - } - - @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(PlaybackMetrics.Reason.TRACK_ERROR); - } 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)); - - state.setBuffering(true); - state.updated(); - - events.playbackHaltStateChanged(true); - } - } + private void handlePause() { + if (state.isPlaying()) { + state.setState(true, true, false); + queue.pause(false); - @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)); + try { + state.setPosition(queue.currentTime()); + } catch (Codec.CannotGetTimeException ignored) { + } - state.setPosition(state.getPosition() - diff); - state.setBuffering(false); state.updated(); + events.playbackPaused(); - events.playbackHaltStateChanged(false); - } - } - - private void handleSeek(int pos) { - if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { - playbackMetrics.endInterval(state.getPosition()); - playbackMetrics.startInterval(pos); - } - - state.setPosition(pos); - if (trackHandler != null) trackHandler.seek(pos); - events.seeked(pos); - } - - private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { - runner.stopMixer(); - state.setState(false, false, false); - state.updated(); + if (releaseLineFuture != null) releaseLineFuture.cancel(true); + releaseLineFuture = scheduler.schedule(() -> { + if (!state.isPaused()) return; - if (reason != null && playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { - playbackMetrics.endedHow(reason, null); - playbackMetrics.endInterval(state.getPosition()); - session.eventService().trackPlayed(state, playbackMetrics); - playbackMetrics = null; + events.inactiveSession(true); + queue.pause(true); + }, conf.releaseLineDelay(), TimeUnit.SECONDS); } } - private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull TransitionInfo trans) { - if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { + private void loadTrack(boolean play, @NotNull TransitionInfo trans) { + if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { playbackMetrics.endedHow(trans.endedReason, state.getPlayOrigin().getFeatureIdentifier()); playbackMetrics.endInterval(trans.endedWhen); session.eventService().trackPlayed(state, playbackMetrics); playbackMetrics = null; } - if (trackHandler != null) { - trackHandler.stop(); - trackHandler = null; - } - state.renewPlaybackId(); - PlayableId id = state.getCurrentPlayableOrThrow(); - playbackMetrics = new PlaybackMetrics(id); - if (crossfadeHandler != null && crossfadeHandler.isPlayable(id)) { - trackHandler = crossfadeHandler; - if (preloadTrackHandler == crossfadeHandler) preloadTrackHandler = null; - crossfadeHandler = null; + PlayableId playable = state.getCurrentPlayableOrThrow(); + playbackMetrics = new PlaybackMetrics(playable); + if (!queue.isCurrent(playable)) { + queue.clear(); - 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.setState(true, !play, true); + int id = queue.load(playable); + queue.follows(id); + queue.seek(id, state.getPosition()); state.updated(); events.trackChanged(); - if (!play) { - runner.pauseMixer(); - events.playbackPaused(); - } else { + if (play) { + queue.resume(); 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()); + queue.pause(false); + events.playbackPaused(); } + } else { + entryIsReady(); state.updated(); events.trackChanged(); - if (play) { - trackHandler.pushToMixer(reason); - runner.playMixer(); - events.playbackResumed(); - } else { - events.playbackPaused(); - } + if (!play) queue.pause(false); } if (releaseLineFuture != null) { @@ -513,45 +374,6 @@ private void loadTrack(boolean play, @NotNull PushToMixerReason reason, @NotNull playbackMetrics.startInterval(state.getPosition()); } - private void handleResume() { - if (state.isPaused()) { - state.setState(true, false, false); - if (!trackHandler.isInMixer()) trackHandler.pushToMixer(PushToMixerReason.None); - runner.playMixer(); - - state.updated(); - events.playbackResumed(); - - if (releaseLineFuture != null) { - releaseLineFuture.cancel(true); - releaseLineFuture = null; - } - } - } - - private void handlePause() { - if (state.isPlaying()) { - state.setState(true, true, false); - runner.pauseMixer(); - - try { - state.setPosition(trackHandler.time()); - } catch (Codec.CannotGetTimeException ignored) { - } - - state.updated(); - events.playbackPaused(); - - if (releaseLineFuture != null) releaseLineFuture.cancel(true); - releaseLineFuture = scheduler.schedule(() -> { - if (!state.isPaused()) return; - - events.inactiveSession(true); - if (runner.pauseAndRelease()) LOGGER.debug("Released line after a period of inactivity."); - }, conf.releaseLineDelay(), TimeUnit.SECONDS); - } - } - private void setQueue(@NotNull JsonObject obj) { List prevTracks = PlayCommandHelper.getPrevTracks(obj); List nextTracks = PlayCommandHelper.getNextTracks(obj); @@ -575,7 +397,7 @@ private void handleNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) if (track != null) { state.skipTo(track); - loadTrack(true, PushToMixerReason.Next, TransitionInfo.skipTo(state)); + loadTrack(true, TransitionInfo.skipTo(state)); return; } @@ -589,13 +411,30 @@ private void handleNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) trans.endedWhen = state.getPosition(); state.setPosition(0); - loadTrack(next == NextPlayable.OK_PLAY || next == NextPlayable.OK_REPEAT, PushToMixerReason.Next, trans); + loadTrack(next == NextPlayable.OK_PLAY || next == NextPlayable.OK_REPEAT, trans); } else { LOGGER.fatal("Failed loading next song: " + next); panicState(PlaybackMetrics.Reason.END_PLAY); } } + private void handlePrev() { + 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 { + state.setPosition(0); + queue.seekCurrent(0); + state.updated(); + } + } + private void loadAutoplay() { String context = state.getContextUri(); if (context == null) { @@ -614,7 +453,7 @@ private void loadAutoplay() { state.setContextMetadata("context_description", contextDesc); events.contextChanged(); - loadTrack(true, PushToMixerReason.None, TransitionInfo.contextChange(state, false)); + loadTrack(true, TransitionInfo.contextChange(state, false)); LOGGER.debug(String.format("Loading context for autoplay, uri: %s", newContext)); } else if (resp.statusCode == 204) { @@ -623,7 +462,7 @@ private void loadAutoplay() { state.setContextMetadata("context_description", contextDesc); events.contextChanged(); - loadTrack(true, PushToMixerReason.None, TransitionInfo.contextChange(state, false)); + loadTrack(true, TransitionInfo.contextChange(state, false)); LOGGER.debug(String.format("Loading context for autoplay (using radio-apollo), uri: %s", state.getContextUri())); } else { @@ -642,62 +481,135 @@ private void loadAutoplay() { } } - private void handlePrev() { - if (state.getPosition() < 3000) { - StateWrapper.PreviousPlayable prev = state.previousPlayable(); - if (prev.isOk()) { - state.setPosition(0); - loadTrack(true, PushToMixerReason.Prev, TransitionInfo.skippedPrev(state)); - } else { - LOGGER.fatal("Failed loading previous song: " + prev); - panicState(null); + + // ================================ // + // ======== Player events ========= // + // ================================ // + + @Override + public void startedLoading(int id) { + if (queue.isCurrent(id)) { + if (!state.isBuffering()) { + state.setBuffering(true); + state.updated(); } - } else { - state.setPosition(0); - if (trackHandler != null) trackHandler.seek(0); + } + } + + @Override + public void finishedLoading(int id) { + if (queue.isCurrent(id)) { + state.setBuffering(false); + entryIsReady(); state.updated(); + } else if (queue.isNext(id)) { + LOGGER.trace("Preloaded track is ready."); } } @Override - public void close() throws IOException { - if (playbackMetrics != null && trackHandler.isPlayable(playbackMetrics.id)) { - playbackMetrics.endedHow(PlaybackMetrics.Reason.LOGOUT, null); - playbackMetrics.endInterval(state.getPosition()); - session.eventService().trackPlayed(state, playbackMetrics); - playbackMetrics = null; + public void sinkError(@NotNull Exception ex) { + LOGGER.fatal("Sink error!", ex); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); + } + + @Override + public void loadingError(int id, @NotNull PlayableId playable, @NotNull Exception ex) { + if (queue.isCurrent(id)) { + if (ex instanceof ContentRestrictedException) { + LOGGER.fatal(String.format("Can't load track (content restricted). {uri: %s}", playable.toSpotifyUri()), ex); + handleNext(null, TransitionInfo.nextError(state)); + return; + } + + LOGGER.fatal(String.format("Failed loading track. {uri: %s}", playable.toSpotifyUri()), ex); + panicState(PlaybackMetrics.Reason.TRACK_ERROR); } + } - if (trackHandler != null) { - trackHandler.close(); - trackHandler = null; + @Override + public void endOfPlayback(int id) { + if (queue.isCurrent(id)) { + LOGGER.trace(String.format("End of track. {id: %d}", id)); + handleNext(null, TransitionInfo.next(state)); } + } - if (crossfadeHandler != null) { - crossfadeHandler.close(); - crossfadeHandler = null; + @Override + public void startedNextTrack(int id, int next) { + if (queue.isCurrent(next)) { + LOGGER.trace(String.format("Playing next track. {id: %d}", next)); + handleNext(null, TransitionInfo.next(state)); } + } - if (preloadTrackHandler != null) { - preloadTrackHandler.close(); - preloadTrackHandler = null; + @Override + public void preloadNextTrack(int id) { + if (queue.isCurrent(id)) { + PlayableId next = state.nextPlayableDoNotSet(); + if (next != null) { + int nextId = queue.load(next); + queue.follows(nextId); + LOGGER.trace("Started next track preload, uri: " + next.toSpotifyUri()); + } } + } - state.close(); - events.listeners.clear(); + @Override + public void finishedSeek(int id, int pos) { + if (queue.isCurrent(id)) { + state.setPosition(pos); + state.updated(); + } + } - runner.close(); - if (state != null) state.removeListener(this); + @Override + public void playbackError(int id, @NotNull Exception ex) { + if (queue.isCurrent(id)) { + if (ex instanceof AbsChunkedInputStream.ChunkException) + LOGGER.fatal("Failed retrieving chunk, playback failed!", ex); + else + LOGGER.fatal("Playback error!", ex); + + panicState(PlaybackMetrics.Reason.TRACK_ERROR); + } else if (queue.isNext(id)) { + LOGGER.warn("Preloaded track loading failed!", ex); + } } - @Nullable - public Metadata.Track currentTrack() { - return trackHandler == null ? null : trackHandler.track(); + @Override + public void playbackHalted(int id, int chunk) { + if (queue.isCurrent(id)) { + LOGGER.debug(String.format("Playback halted on retrieving chunk %d.", chunk)); + + state.setBuffering(true); + state.updated(); + + events.playbackHaltStateChanged(true); + } + } + + @Override + public void playbackResumedFromHalt(int id, int chunk, long diff) { + if (queue.isCurrent(id)) { + 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 ============ // + // ================================ // + @Nullable - public Metadata.Episode currentEpisode() { - return trackHandler == null ? null : trackHandler.episode(); + public TrackOrEpisode currentMetadata() { + return queue.currentMetadata(); } @Nullable @@ -707,28 +619,19 @@ public PlayableId currentPlayableId() { @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; } } @@ -755,12 +658,33 @@ public byte[] currentCoverImage() throws IOException { */ public long time() { try { - return trackHandler == null ? 0 : trackHandler.time(); + return queue.currentTime(); } catch (Codec.CannotGetTimeException ex) { return -1; } } + + // ================================ // + // ============ Close! ============ // + // ================================ // + + @Override + public void close() { + if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { + playbackMetrics.endedHow(PlaybackMetrics.Reason.LOGOUT, null); + playbackMetrics.endInterval(state.getPosition()); + session.eventService().trackPlayed(state, playbackMetrics); + playbackMetrics = null; + } + + state.close(); + events.listeners.clear(); + + queue.close(); + if (state != null) state.removeListener(this); + } + public interface Configuration { @NotNull AudioQuality preferredQuality(); @@ -799,7 +723,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); @@ -807,7 +731,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); @@ -977,45 +901,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); } @@ -1051,18 +957,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) { @@ -1072,8 +969,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)); @@ -1082,14 +979,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 23fe1fac..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/player/PlayerRunner.java +++ /dev/null @@ -1,823 +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_STEPS = 64; - public static final int VOLUME_MAX = 65536; - public static final int VOLUME_ONE_STEP = VOLUME_MAX / VOLUME_STEPS; - 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() { - 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) { - } - } - - @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, FadeNext - } - - 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 static class PlayerMetrics { - public int decodedLength = 0; - public int size = 0; - public int bitrate = 0; - public int duration = 0; - public String encoding = null; - - private PlayerMetrics(@Nullable Codec codec) { - if (codec == null) return; - - size = codec.size(); - duration = codec.duration(); - decodedLength = codec.decodedLength(); - - AudioFormat format = codec.getAudioFormat(); - bitrate = (int) (format.getSampleRate() * format.getSampleSizeInBits()); - - if (codec instanceof VorbisCodec) encoding = "vorbis"; - else if (codec instanceof Mp3Codec) encoding = "mp3"; - } - } - - 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() { - waitReady(); - - int seekTo = -1; - if (pushReason == PushToMixerReason.FadeNext) - 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(); - } - - boolean isInMixer() { - return firstHandler == this || secondHandler == this; - } - - @NotNull - public PlayerMetrics metrics() { - return new PlayerMetrics(codec); - } - } -} 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 b5a746a6..c0d45fe6 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -135,6 +135,10 @@ boolean isPaused() { return state.getIsPlaying() && state.getIsPaused(); } + boolean isBuffering() { + return state.getIsPlaying() && state.getIsBuffering(); + } + private boolean isShufflingContext() { return state.getOptions().getShufflingContext(); } @@ -316,7 +320,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())); @@ -377,7 +386,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())); 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 d385d3da..9fbd1271 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 @@ -38,7 +38,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); 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..8612180a 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 @@ -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..10c56c74 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 @@ -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 b1f063e2..b3d3a2e6 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 @@ -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 } 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..a81e2ef0 --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java @@ -0,0 +1,302 @@ +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(); + } + + @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/MixingLine.java b/core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java index fe21a407..36669d60 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,6 +2,7 @@ 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; @@ -12,7 +13,7 @@ /** * @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; @@ -58,6 +59,13 @@ public synchronized int read(@NotNull byte[] b, int off, int len) throws IOExcep return len; } + @Nullable + public MixingOutput someOut() { + if (fout == null) return firstOut(); + else if (sout == null) return secondOut(); + else return null; + } + @NotNull public MixingOutput firstOut() { if (fout == null) { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java new file mode 100644 index 00000000..b99b968a --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java @@ -0,0 +1,406 @@ +package xyz.gianlu.librespot.player.queue; + +import org.apache.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.NameThreadFactory; +import xyz.gianlu.librespot.core.Session; +import xyz.gianlu.librespot.mercury.MercuryClient; +import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.player.ContentRestrictedException; +import xyz.gianlu.librespot.player.Player; +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.feeders.cdn.CdnManager; +import xyz.gianlu.librespot.player.mixing.AudioSink; +import xyz.gianlu.librespot.player.mixing.MixingLine; + +import javax.sound.sampled.AudioFormat; +import java.io.Closeable; +import java.io.IOException; +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 PlayerQueue implements Closeable, QueueEntry.@NotNull Listener { + private static final Logger LOGGER = Logger.getLogger(PlayerQueue.class); + private static final AtomicInteger IDS = new AtomicInteger(0); + private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory(r -> "player-queue-worker-" + r.hashCode())); + private final Session session; + private final Player.Configuration conf; + private final Listener listener; + private final Map entries = new HashMap<>(5); + private final AudioSink sink; + private int currentEntryId = -1; + private int nextEntryId = -1; + + public PlayerQueue(@NotNull Session session, @NotNull Player.Configuration conf, @NotNull Listener listener) { + this.session = session; + this.conf = conf; + this.listener = listener; + this.sink = new AudioSink(conf, listener); + } + + /** + * Resume the sink. + */ + public void resume() { + sink.resume(); + } + + /** + * Pause the sink + */ + public void pause(boolean release) { + if (sink.pause(release)) + LOGGER.info("Sink released line."); + } + + /** + * Set the volume for the sink. + * + * @param volume The volume value from 0 to {@link Player#VOLUME_MAX}, inclusive. + */ + public void setVolume(int volume) { + sink.setVolume(volume); + } + + /** + * Clear the queue, the outputs and close all entries. + */ + public void clear() { + currentEntryId = -1; + nextEntryId = -1; + entries.values().removeIf(entry -> { + entry.close(); + return true; + }); + + sink.clearOutputs(); + } + + /** + * Create an entry for the specified content and start loading it asynchronously. + * + * @param playable The content this entry will play. + * @return The entry ID + */ + public int load(@NotNull PlayableId playable) { + int id = IDS.getAndIncrement(); + QueueEntry entry = new QueueEntry(id, playable, this); + executorService.execute(() -> { + try { + entry.load(session, conf, sink.getFormat()); + LOGGER.debug(String.format("Preloaded entry. {id: %d}", id)); + } catch (IOException | Codec.CodecException | MercuryClient.MercuryException | CdnManager.CdnException ex) { + LOGGER.error(String.format("Failed preloading entry. {id: %d}", id), ex); + listener.loadingError(id, playable, ex); + } catch (ContentRestrictedException ex) { + LOGGER.warn(String.format("Preloaded entry is content restricted. {id: %d}", id)); + entry.setContentRestricted(ex); + } + }); + + entries.put(id, entry); + LOGGER.debug(String.format("Created new entry. {id: %d, content: %s}", id, playable)); + return id; + } + + /** + * Seek the specified entry to the specified position. + * + * @param id The entry ID + * @param pos The time in milliseconds + */ + public void seek(int id, int pos) { + QueueEntry entry = entries.get(id); + if (entry == null) throw new IllegalArgumentException(); + + executorService.execute(() -> { + sink.flush(); + entry.seek(pos); + listener.finishedSeek(id, pos); + }); + } + + public void seekCurrent(int pos) { + seek(currentEntryId, pos); + } + + /** + * Specifies what's going to play next, will start immediately if there's no current entry. + * + * @param id The ID of the next entry + */ + public void follows(int id) { + if (!entries.containsKey(id)) + throw new IllegalArgumentException(); + + if (currentEntryId == -1) { + currentEntryId = id; + nextEntryId = -1; + start(id); + } else { + nextEntryId = id; + } + } + + /** + * Return metadata for the specified entry. + * + * @param id The entry ID + * @return The metadata for the track or episode + */ + @Nullable + public TrackOrEpisode metadata(int id) { + QueueEntry entry = entries.get(id); + if (entry == null) return null; + else return entry.metadata(); + } + + @Nullable + public TrackOrEpisode currentMetadata() { + return metadata(currentEntryId); + } + + /** + * Return the current position for the specified entry. + * + * @param id The entry ID + * @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. + */ + public int time(int id) throws Codec.CannotGetTimeException { + QueueEntry entry = entries.get(id); + if (entry == null) throw new IllegalArgumentException(); + + return entry.getTime(); + } + + public int currentTime() throws Codec.CannotGetTimeException { + if (currentEntryId == -1) return -1; + else return time(currentEntryId); + } + + @NotNull + public PlayerMetrics currentMetrics() { + QueueEntry entry = entries.get(currentEntryId); + if (entry == null) throw new IllegalStateException(); + + return entry.metrics(); + } + + /** + * @param playable The {@link PlayableId} + * @return Whether the given playable is the current entry. + */ + public boolean isCurrent(@NotNull PlayableId playable) { + QueueEntry entry = entries.get(currentEntryId); + if (entry == null) return false; + else return playable.equals(entry.playable); + } + + /** + * @param id The entry ID + * @return Whether the given ID is the current entry ID. + */ + public boolean isCurrent(int id) { + return id == currentEntryId; + } + + /** + * @param id The entry ID + * @return Whether the given ID is the next entry ID. + */ + public boolean isNext(int id) { + return id == nextEntryId; + } + + /** + * Close the queue by closing all entries and the sink. + */ + @Override + public void close() { + clear(); + sink.close(); + } + + private void start(int id) { + MixingLine.MixingOutput out = sink.someOutput(); + if (out == null) throw new IllegalStateException(); + + QueueEntry entry = entries.get(id); + if (entry == null) throw new IllegalStateException(); + + try { + entry.load(session, conf, sink.getFormat()); + } catch (CdnManager.CdnException | IOException | MercuryClient.MercuryException | Codec.CodecException | ContentRestrictedException ex) { + listener.loadingError(id, entry.playable, ex); + } + + entry.setOutput(out); + executorService.execute(entry); + } + + @Override + public void playbackException(int id, @NotNull Exception ex) { + listener.playbackError(id, ex); + } + + @Override + public void playbackEnded(int id) { + if (id == currentEntryId) { + QueueEntry old = entries.remove(currentEntryId); + if (old != null) old.close(); + + if (nextEntryId != -1) { + currentEntryId = nextEntryId; + nextEntryId = -1; + start(currentEntryId); + listener.startedNextTrack(id, currentEntryId); + } else { + listener.endOfPlayback(id); + } + } + } + + @Override + public void playbackHalted(int id, int chunk) { + listener.playbackHalted(id, chunk); + } + + @Override + public void playbackResumed(int id, int chunk, int duration) { + listener.playbackResumedFromHalt(id, chunk, duration); + } + + @Override + public void instantReached(int id, int exact) { // TODO: Preload, crossfade + listener.preloadNextTrack(id); + } + + @Override + public void startedLoading(int id) { + listener.startedLoading(id); + } + + @Override + public void finishedLoading(int id) { + listener.finishedLoading(id); + + if (conf.preloadEnabled()) { + QueueEntry entry = entries.get(id); + entry.notifyInstant((int) (entry.metadata().duration() - TimeUnit.SECONDS.toMillis(20))); // FIXME + } + } + + public interface Listener extends AudioSink.Listener { + /** + * The track started loading. + * + * @param id The entry ID + */ + void startedLoading(int id); + + /** + * The track finished loading. + * + * @param id The entry ID + */ + void finishedLoading(int id); + + /** + * The track failed loading. + * + * @param id The entry ID + * @param track The content playable + * @param ex The exception thrown + */ + void loadingError(int id, @NotNull PlayableId track, @NotNull Exception ex); + + /** + * The playback of the current entry finished if no following track was specified. + * + * @param id The entry ID + */ + void endOfPlayback(int id); + + /** + * The playback of the current entry finished the next track already started played and is now {@link PlayerQueue#currentEntryId}. + * + * @param id The entry ID + * @param next The ID of the next entry + */ + void startedNextTrack(int id, int next); + + /** + * Instruct the player that it should start preloading the next track. + * + * @param id The entry ID of the currently playing track + */ + void preloadNextTrack(int id); + + /** + * An error occurred during playback. + * + * @param id The entry ID + * @param ex The exception thrown + */ + void playbackError(int id, @NotNull Exception ex); + + /** + * The playback halted while trying to receive a chunk. + * + * @param id The entry ID + * @param chunk The chunk that is being retrieved + */ + void playbackHalted(int id, int chunk); + + /** + * The playback resumed from halt. + * + * @param id The entry ID + * @param chunk The chunk that was being retrieved + * @param diff The time taken to retrieve the chunk + */ + void playbackResumedFromHalt(int id, int chunk, long diff); + + /** + * The entry finished seeking. + * + * @param id The entry ID + * @param pos The seeked position + */ + void finishedSeek(int id, int pos); + } + + public static class PlayerMetrics { + public int decodedLength = 0; + public int size = 0; + public int bitrate = 0; + public int duration = 0; + public String encoding = null; + + PlayerMetrics(@Nullable Codec codec) { + if (codec == null) return; + + size = codec.size(); + duration = codec.duration(); + decodedLength = codec.decodedLength(); + + AudioFormat format = codec.getAudioFormat(); + bitrate = (int) (format.getSampleRate() * format.getSampleSizeInBits()); + + if (codec instanceof VorbisCodec) encoding = "vorbis"; + else if (codec instanceof Mp3Codec) encoding = "mp3"; + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java new file mode 100644 index 00000000..2fe7c8ee --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java @@ -0,0 +1,270 @@ +package xyz.gianlu.librespot.player.queue; + +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.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.HaltListener; +import xyz.gianlu.librespot.player.Player; +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.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; + +/** + * @author devgianlu + */ +class QueueEntry implements Closeable, Runnable, @Nullable HaltListener { + private static final Logger LOGGER = Logger.getLogger(QueueEntry.class); + final PlayableId playable; + private final int id; + private final Listener listener; + private final Object playbackLock = new Object(); + private Codec codec; + private TrackOrEpisode metadata; + private volatile boolean closed = false; + private volatile MixingLine.MixingOutput output; + private long playbackHaltedAt = 0; + private int notifyInstant = -1; + private ContentRestrictedException contentRestricted = null; + + QueueEntry(int id, @NotNull PlayableId playable, @NotNull Listener listener) { + this.id = id; + this.playable = playable; + this.listener = listener; + } + + @Nullable + public TrackOrEpisode metadata() { + return metadata; + } + + synchronized void load(@NotNull Session session, @NotNull Player.Configuration conf, @NotNull AudioFormat format) throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { + if (contentRestricted != null) throw contentRestricted; + if (codec != null) { + notifyAll(); + return; + } + + listener.startedLoading(id); + + PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(conf.preferredQuality()), this); + metadata = new TrackOrEpisode(stream.track, stream.episode); + + if (playable instanceof EpisodeId && stream.episode != null) { + LOGGER.info(String.format("Loaded episode. {name: '%s', uri: %s, id: %d}", stream.episode.getName(), playable.toSpotifyUri(), id)); + } else if (playable instanceof TrackId && stream.track != null) { + LOGGER.info(String.format("Loaded track. {name: '%s', artists: '%s', uri: %s, id: %d}", stream.track.getName(), + Utils.artistsToString(stream.track.getArtistList()), playable.toSpotifyUri(), id)); + } + + switch (stream.in.codec()) { + case VORBIS: + codec = new VorbisCodec(format, stream.in, stream.normalizationData, conf, metadata.duration()); + break; + case MP3: + try { + codec = new Mp3Codec(format, stream.in, stream.normalizationData, conf, metadata.duration()); + } catch (BitstreamException ex) { + throw new IOException(ex); + } + break; + default: + throw new IllegalArgumentException("Unknown codec: " + stream.in.codec()); + } + + contentRestricted = null; + LOGGER.trace(String.format("Loaded %s codec. {fileId: %s, format: %s, id: %d}", stream.in.codec(), stream.in.describe(), codec.getAudioFormat(), id)); + notifyAll(); + + listener.finishedLoading(id); + } + + private synchronized void waitReady() throws InterruptedException { + if (codec != null) return; + wait(); + } + + void setContentRestricted(@NotNull ContentRestrictedException ex) { + contentRestricted = ex; + } + + /** + * Returns the metrics for this entry. + * + * @return A {@link xyz.gianlu.librespot.player.queue.PlayerQueue.PlayerMetrics} object + */ + @NotNull + PlayerQueue.PlayerMetrics metrics() { + return new PlayerQueue.PlayerMetrics(codec); + } + + /** + * Return 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(); + } + + /** + * Seek to the specified position. + * + * @param pos The time in milliseconds + */ + void seek(int pos) { + try { + waitReady(); + } catch (InterruptedException ex) { + return; + } + + output.stream().emptyBuffer(); + codec.seek(pos); + } + + /** + * Set the output. + */ + void setOutput(@NotNull MixingLine.MixingOutput output) { + synchronized (playbackLock) { + this.output = output; + playbackLock.notifyAll(); + } + + this.output.toggle(true); + } + + /** + * Remove the output. + */ + void clearOutput() { + if (output != null) { + output.toggle(false); + output.clear(); + } + + synchronized (playbackLock) { + output = null; + playbackLock.notifyAll(); + } + } + + /** + * Instructs to notify when this time instant is reached. + * + * @param when The time in milliseconds + */ + void notifyInstant(int when) { + if (codec != null) { + try { + int time = codec.time(); + if (time >= when) listener.instantReached(id, time); + } catch (Codec.CannotGetTimeException ex) { + return; + } + } + + notifyInstant = when; + } + + @Override + public void run() { + if (codec == null) { + try { + waitReady(); + } catch (InterruptedException ex) { + return; + } + } + + boolean canGetTime = true; + while (!closed) { + if (output == null) { + synchronized (playbackLock) { + try { + playbackLock.wait(); + } catch (InterruptedException ex) { + break; + } + } + } + + if (canGetTime && notifyInstant != -1) { + try { + int time = codec.time(); + if (time >= notifyInstant) { + notifyInstant = -1; + listener.instantReached(id, time); + } + } catch (Codec.CannotGetTimeException ex) { + canGetTime = false; + } + } + + try { + if (codec.writeSomeTo(output.stream()) == -1) { + listener.playbackEnded(id); + break; + } + } catch (IOException | Codec.CodecException ex) { + if (closed) break; + + listener.playbackException(id, ex); + } + } + } + + @Override + public void close() { + closed = true; + clearOutput(); + } + + @Override + public void streamReadHalted(int chunk, long time) { + playbackHaltedAt = time; + listener.playbackHalted(id, chunk); + } + + @Override + public void streamReadResumed(int chunk, long time) { + if (playbackHaltedAt == 0) return; + + int duration = (int) (time - playbackHaltedAt); + listener.playbackResumed(id, chunk, duration); + } + + interface Listener { + void playbackException(int id, @NotNull Exception ex); + + void playbackEnded(int id); + + void playbackHalted(int id, int chunk); + + void playbackResumed(int id, int chunk, int duration); + + void instantReached(int id, int exact); + + void startedLoading(int id); + + void finishedLoading(int id); + } +} From 39c7b7643c2c536cc4d658a5a98d1757cbaf32d3 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Tue, 21 Apr 2020 12:16:05 +0200 Subject: [PATCH 14/32] Fixed merge issues --- .../src/main/java/xyz/gianlu/librespot/FileConfiguration.java | 2 +- core/src/main/java/xyz/gianlu/librespot/player/Player.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java b/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java index a3f99c58..2eb2d794 100644 --- a/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java +++ b/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java @@ -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/player/Player.java b/core/src/main/java/xyz/gianlu/librespot/player/Player.java index b8ec82fd..3110c8e5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -84,7 +84,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() { @@ -93,7 +93,7 @@ public void volumeDown() { } private int oneVolumeStep() { - return PlayerRunner.VOLUME_MAX / conf.volumeSteps(); + return Player.VOLUME_MAX / conf.volumeSteps(); } public void setVolume(int val) { From f3fd6d1e48083e92af3f445dacdf551abca28488 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Tue, 21 Apr 2020 12:57:19 +0200 Subject: [PATCH 15/32] Refactored instants logic --- .../xyz/gianlu/librespot/player/Player.java | 8 +- .../librespot/player/queue/PlayerQueue.java | 18 ++--- .../librespot/player/queue/QueueEntry.java | 79 +++++++++++++++---- 3 files changed, 73 insertions(+), 32 deletions(-) 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 3110c8e5..d17b5db9 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -44,9 +44,7 @@ * @author Gianlu */ public class Player implements Closeable, DeviceStateHandler.Listener, PlayerQueue.Listener { // TODO: Reduce calls to state update - public static final int VOLUME_STEPS = 64; public static final int VOLUME_MAX = 65536; - public static final int VOLUME_ONE_STEP = VOLUME_MAX / VOLUME_STEPS; 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; @@ -506,8 +504,6 @@ public void finishedLoading(int id) { state.setBuffering(false); entryIsReady(); state.updated(); - } else if (queue.isNext(id)) { - LOGGER.trace("Preloaded track is ready."); } } @@ -554,7 +550,7 @@ public void preloadNextTrack(int id) { if (next != null) { int nextId = queue.load(next); queue.follows(nextId); - LOGGER.trace("Started next track preload, uri: " + next.toSpotifyUri()); + LOGGER.trace(String.format("Started next track preload. {uri: %s}", next.toSpotifyUri())); } } } @@ -576,8 +572,6 @@ public void playbackError(int id, @NotNull Exception ex) { LOGGER.fatal("Playback error!", ex); panicState(PlaybackMetrics.Reason.TRACK_ERROR); - } else if (queue.isNext(id)) { - LOGGER.warn("Preloaded track loading failed!", ex); } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java index b99b968a..7d857cfb 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java @@ -33,6 +33,7 @@ public class PlayerQueue implements Closeable, QueueEntry.@NotNull Listener { private static final Logger LOGGER = Logger.getLogger(PlayerQueue.class); private static final AtomicInteger IDS = new AtomicInteger(0); + private static final int INSTANT_PRELOAD = 1; private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory(r -> "player-queue-worker-" + r.hashCode())); private final Session session; private final Player.Configuration conf; @@ -216,14 +217,6 @@ public boolean isCurrent(int id) { return id == currentEntryId; } - /** - * @param id The entry ID - * @return Whether the given ID is the next entry ID. - */ - public boolean isNext(int id) { - return id == nextEntryId; - } - /** * Close the queue by closing all entries and the sink. */ @@ -283,8 +276,9 @@ public void playbackResumed(int id, int chunk, int duration) { } @Override - public void instantReached(int id, int exact) { // TODO: Preload, crossfade - listener.preloadNextTrack(id); + public void instantReached(int entryId, int callbackId, int exact) { + if (callbackId == INSTANT_PRELOAD) + executorService.execute(() -> listener.preloadNextTrack(entryId)); } @Override @@ -298,7 +292,9 @@ public void finishedLoading(int id) { if (conf.preloadEnabled()) { QueueEntry entry = entries.get(id); - entry.notifyInstant((int) (entry.metadata().duration() - TimeUnit.SECONDS.toMillis(20))); // FIXME + TrackOrEpisode metadata = entry.metadata(); + if (metadata != null) + entry.notifyInstant(INSTANT_PRELOAD, (int) (metadata.duration() - TimeUnit.SECONDS.toMillis(20))); } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java index 2fe7c8ee..95dad4c7 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java @@ -25,6 +25,8 @@ import javax.sound.sampled.AudioFormat; import java.io.Closeable; import java.io.IOException; +import java.util.Comparator; +import java.util.TreeMap; /** * @author devgianlu @@ -35,12 +37,12 @@ class QueueEntry implements Closeable, Runnable, @Nullable HaltListener { private final int id; private final Listener listener; private final Object playbackLock = new Object(); + private final TreeMap notifyInstants = new TreeMap<>(Comparator.comparingInt(o -> o)); private Codec codec; private TrackOrEpisode metadata; private volatile boolean closed = false; private volatile MixingLine.MixingOutput output; private long playbackHaltedAt = 0; - private int notifyInstant = -1; private ContentRestrictedException contentRestricted = null; QueueEntry(int id, @NotNull PlayableId playable, @NotNull Listener listener) { @@ -172,17 +174,20 @@ void clearOutput() { * * @param when The time in milliseconds */ - void notifyInstant(int when) { + void notifyInstant(int callbackId, int when) { if (codec != null) { try { int time = codec.time(); - if (time >= when) listener.instantReached(id, time); + if (time >= when) { + listener.instantReached(id, callbackId, time); + return; + } } catch (Codec.CannotGetTimeException ex) { return; } } - notifyInstant = when; + notifyInstants.put(when, callbackId); } @Override @@ -207,13 +212,10 @@ public void run() { } } - if (canGetTime && notifyInstant != -1) { + if (canGetTime && !notifyInstants.isEmpty()) { try { int time = codec.time(); - if (time >= notifyInstant) { - notifyInstant = -1; - listener.instantReached(id, time); - } + checkInstants(time); } catch (Codec.CannotGetTimeException ex) { canGetTime = false; } @@ -226,12 +228,20 @@ public void run() { } } catch (IOException | Codec.CodecException ex) { if (closed) break; - listener.playbackException(id, ex); } } } + private void checkInstants(int time) { + int key = notifyInstants.firstKey(); + if (time >= key) { + int callbackId = notifyInstants.remove(key); + listener.instantReached(id, callbackId, time); + if (!notifyInstants.isEmpty()) checkInstants(time); + } + } + @Override public void close() { closed = true; @@ -253,18 +263,59 @@ public void streamReadResumed(int chunk, long time) { } interface Listener { + /** + * An error occurred during playback. + * + * @param id The entry ID + * @param ex The exception thrown + */ void playbackException(int id, @NotNull Exception ex); + /** + * The playback of the current entry ended. + * + * @param id The entry ID + */ void playbackEnded(int id); + /** + * The playback halted while trying to receive a chunk. + * + * @param id The entry ID + * @param chunk The chunk that is being retrieved + */ void playbackHalted(int id, int chunk); - void playbackResumed(int id, int chunk, int duration); - - void instantReached(int id, int exact); - + /** + * The playback resumed from halt. + * + * @param id The entry ID + * @param chunk The chunk that was being retrieved + * @param diff The time taken to retrieve the chunk + */ + void playbackResumed(int id, int chunk, int diff); + + /** + * Notify that a previously request instant has been reached. This is called from the runner, be careful. + * + * @param entryId The entry ID + * @param callbackId The callback ID for the instant + * @param exactTime The exact time the instant was reached + */ + void instantReached(int entryId, int callbackId, int exactTime); + + /** + * The track started loading. + * + * @param id The entry ID + */ void startedLoading(int id); + /** + * The track finished loading. + * + * @param id The entry ID + */ void finishedLoading(int id); } } From 80e91381a6b01b29319433e80afef1fa729d02c8 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Tue, 21 Apr 2020 18:00:58 +0200 Subject: [PATCH 16/32] Fixed LookupInterpolator + implemented crossfade --- .../xyz/gianlu/librespot/player/Player.java | 15 ++- .../player/crossfade/CrossfadeController.java | 103 +++++++----------- .../player/crossfade/LookupInterpolator.java | 17 ++- .../feeders/storage/AudioFileStreaming.java | 4 +- .../librespot/player/mixing/MixingLine.java | 6 +- .../librespot/player/queue/PlayerQueue.java | 50 ++++++--- .../librespot/player/queue/QueueEntry.java | 87 ++++++++++++--- .../player/crossfade/InterpolatorTest.java | 21 ++++ 8 files changed, 193 insertions(+), 110 deletions(-) create mode 100644 core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java 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 d17b5db9..7c7dc9c4 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -344,9 +344,7 @@ private void loadTrack(boolean play, @NotNull TransitionInfo trans) { queue.clear(); state.setState(true, !play, true); - int id = queue.load(playable); - queue.follows(id); - queue.seek(id, state.getPosition()); + queue.follows(queue.load(playable, state.metadataFor(playable), state.getPosition())); state.updated(); events.trackChanged(); @@ -359,6 +357,11 @@ private void loadTrack(boolean play, @NotNull TransitionInfo trans) { events.playbackPaused(); } } else { + try { + state.setPosition(queue.currentTime()); + } catch (Codec.CannotGetTimeException ignored) { + } + entryIsReady(); state.updated(); @@ -538,7 +541,7 @@ public void endOfPlayback(int id) { @Override public void startedNextTrack(int id, int next) { if (queue.isCurrent(next)) { - LOGGER.trace(String.format("Playing next track. {id: %d}", next)); + LOGGER.trace(String.format("Playing next track. {next: %d}", next)); handleNext(null, TransitionInfo.next(state)); } } @@ -548,9 +551,9 @@ public void preloadNextTrack(int id) { if (queue.isCurrent(id)) { PlayableId next = state.nextPlayableDoNotSet(); if (next != null) { - int nextId = queue.load(next); + int nextId = queue.load(next, state.metadataFor(next), 0); queue.follows(nextId); - LOGGER.trace(String.format("Started next track preload. {uri: %s}", next.toSpotifyUri())); + LOGGER.trace(String.format("Started next track preload. {uri: %s, next: %d}", next.toSpotifyUri(), nextId)); } } } 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..1773556c 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 @@ -13,75 +13,47 @@ public class CrossfadeController { private static final Logger LOGGER = Logger.getLogger(CrossfadeController.class); private final int trackDuration; - 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 final FadeInterval fadeInInterval; + private final FadeInterval fadeOutInterval; + private final int defaultFadeDuration; private FadeInterval activeInterval = null; private float lastGain = 1; - public CrossfadeController(int duration, @NotNull Player.Configuration conf) { - trackDuration = duration; - defaultFadeDuration = conf.crossfadeDuration(); - - fadeInDuration = -1; - fadeInStartTime = -1; - - fadeOutUri = null; - fadeOutDuration = -1; - fadeOutStartTime = -1; - - if (defaultFadeDuration > 0) - startInterval = new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator()); - else - startInterval = null; - - if (defaultFadeDuration > 0) - endInterval = new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator()); - else - endInterval = null; - - LOGGER.debug(String.format("Loaded default intervals. {start: %s, end: %s}", startInterval, endInterval)); - } - 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")); + 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")); + 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 (fadeInDuration == 0) - startInterval = null; + fadeInInterval = null; else if (fadeInCurves.size() > 0) - startInterval = new FadeInterval(fadeInStartTime, fadeInDuration, LookupInterpolator.fromJson(getFadeCurve(fadeInCurves))); + fadeInInterval = new FadeInterval(fadeInStartTime, fadeInDuration, LookupInterpolator.fromJson(getFadeCurve(fadeInCurves))); else if (defaultFadeDuration > 0) - startInterval = new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator()); + fadeInInterval = new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator()); else - startInterval = null; + fadeInInterval = null; if (fadeOutDuration == 0) - endInterval = null; + fadeOutInterval = null; else if (fadeOutCurves.size() > 0) - endInterval = new FadeInterval(fadeOutStartTime, fadeOutDuration, LookupInterpolator.fromJson(getFadeCurve(fadeOutCurves))); + fadeOutInterval = new FadeInterval(fadeOutStartTime, fadeOutDuration, LookupInterpolator.fromJson(getFadeCurve(fadeOutCurves))); else if (defaultFadeDuration > 0) - endInterval = new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator()); + fadeOutInterval = new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator()); else - endInterval = null; + fadeOutInterval = null; - LOGGER.debug(String.format("Loaded intervals. {start: %s, end: %s}", startInterval, endInterval)); + LOGGER.debug(String.format("Loaded crossfade intervals. {start: %s, end: %s}", fadeInInterval, fadeOutInterval)); } @NotNull @@ -93,14 +65,6 @@ private static JsonArray getFadeCurve(@NotNull JsonArray curves) { return curve.getAsJsonArray("fade_curve"); } - public boolean shouldStartNextTrack(int pos) { - return fadeOutEnabled() && endInterval != null && pos >= endInterval.start; - } - - public boolean shouldStop(int pos) { - return endInterval != null && pos >= endInterval.end(); - } - public float getGain(int pos) { if (activeInterval != null && activeInterval.end() <= pos) { lastGain = activeInterval.interpolator.last(); @@ -108,10 +72,10 @@ public float getGain(int pos) { } 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 (fadeInInterval != null && pos >= fadeInInterval.start && fadeInInterval.end() >= pos) + activeInterval = fadeInInterval; + else if (fadeOutInterval != null && pos >= fadeOutInterval.start && fadeOutInterval.end() >= pos) + activeInterval = fadeOutInterval; } if (activeInterval == null) return lastGain; @@ -119,22 +83,37 @@ else if (endInterval != null && pos >= endInterval.start && endInterval.end() >= return lastGain = activeInterval.interpolate(pos); } + public boolean fadeInEnabled() { + return fadeInInterval != null; + } + public int fadeInStartTime() { - if (fadeInStartTime != -1) return fadeInStartTime; + if (fadeInInterval != null) return fadeInInterval.start; else return 0; } + public int fadeInEndTime() { + if (fadeInInterval != null) return fadeInInterval.end(); + else return defaultFadeDuration; + } + + public boolean fadeOutEnabled() { + return fadeOutInterval != null; + } + public int fadeOutStartTime() { - if (fadeOutStartTime != -1) return fadeOutStartTime; + if (fadeOutInterval != null) return fadeOutInterval.start; else return trackDuration - defaultFadeDuration; } - public boolean fadeInEnabled() { - return fadeInDuration != -1 || defaultFadeDuration > 0; + public int fadeOutEndTime() { + if (fadeOutInterval != null) return fadeOutInterval.end(); + else return trackDuration; } - public boolean fadeOutEnabled() { - return fadeOutDuration != -1 || defaultFadeDuration > 0; + public int fadeOutStartTimeFromEnd() { + if (fadeOutInterval != null) return trackDuration - fadeOutInterval.start; + else return defaultFadeDuration; } @Nullable 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/feeders/storage/AudioFileStreaming.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java index b3d3a2e6..d892fbab 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 @@ -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); 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 36669d60..75f9693a 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 @@ -50,13 +50,13 @@ 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 diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java index 7d857cfb..7c342de3 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java @@ -31,9 +31,11 @@ * @author Gianlu */ public class PlayerQueue implements Closeable, QueueEntry.@NotNull Listener { + static final int INSTANT_START_NEXT = 2; + static final int INSTANT_END_NOW = 3; + private static final int INSTANT_PRELOAD = 1; private static final Logger LOGGER = Logger.getLogger(PlayerQueue.class); private static final AtomicInteger IDS = new AtomicInteger(0); - private static final int INSTANT_PRELOAD = 1; private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory(r -> "player-queue-worker-" + r.hashCode())); private final Session session; private final Player.Configuration conf; @@ -94,12 +96,13 @@ public void clear() { * @param playable The content this entry will play. * @return The entry ID */ - public int load(@NotNull PlayableId playable) { + public int load(@NotNull PlayableId playable, @NotNull Map metadata, int pos) { int id = IDS.getAndIncrement(); - QueueEntry entry = new QueueEntry(id, playable, this); + QueueEntry entry = new QueueEntry(id, playable, metadata, this); + executorService.execute(entry); executorService.execute(() -> { try { - entry.load(session, conf, sink.getFormat()); + entry.load(session, conf, sink.getFormat(), pos); LOGGER.debug(String.format("Preloaded entry. {id: %d}", id)); } catch (IOException | Codec.CodecException | MercuryClient.MercuryException | CdnManager.CdnException ex) { LOGGER.error(String.format("Failed preloading entry. {id: %d}", id), ex); @@ -227,20 +230,24 @@ public void close() { } private void start(int id) { - MixingLine.MixingOutput out = sink.someOutput(); - if (out == null) throw new IllegalStateException(); - QueueEntry entry = entries.get(id); if (entry == null) throw new IllegalStateException(); + if (entry.hasOutput()) { + entry.toggleOutput(true); + return; + } + + MixingLine.MixingOutput out = sink.someOutput(); + if (out == null) throw new IllegalStateException(); try { - entry.load(session, conf, sink.getFormat()); + entry.load(session, conf, sink.getFormat(), -1); } catch (CdnManager.CdnException | IOException | MercuryClient.MercuryException | Codec.CodecException | ContentRestrictedException ex) { listener.loadingError(id, entry.playable, ex); } entry.setOutput(out); - executorService.execute(entry); + entry.toggleOutput(true); } @Override @@ -277,8 +284,20 @@ public void playbackResumed(int id, int chunk, int duration) { @Override public void instantReached(int entryId, int callbackId, int exact) { - if (callbackId == INSTANT_PRELOAD) - executorService.execute(() -> listener.preloadNextTrack(entryId)); + switch (callbackId) { + case INSTANT_PRELOAD: + if (entryId == currentEntryId) + executorService.execute(() -> listener.preloadNextTrack(entryId)); + break; + case INSTANT_START_NEXT: + if (entryId == currentEntryId && nextEntryId != -1) + start(nextEntryId); + break; + case INSTANT_END_NOW: + QueueEntry entry = entries.remove(entryId); + if (entry != null) entry.close(); + break; + } } @Override @@ -290,12 +309,9 @@ public void startedLoading(int id) { public void finishedLoading(int id) { listener.finishedLoading(id); - if (conf.preloadEnabled()) { - QueueEntry entry = entries.get(id); - TrackOrEpisode metadata = entry.metadata(); - if (metadata != null) - entry.notifyInstant(INSTANT_PRELOAD, (int) (metadata.duration() - TimeUnit.SECONDS.toMillis(20))); - } + QueueEntry entry = entries.get(id); + if (conf.preloadEnabled() || entry.crossfadeController.fadeInEnabled()) + entry.notifyInstantFromEnd(INSTANT_PRELOAD, (int) TimeUnit.SECONDS.toMillis(20) + entry.crossfadeController.fadeOutStartTimeFromEnd()); } public interface Listener extends AudioSink.Listener { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java index 95dad4c7..b770f2b5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java @@ -18,6 +18,7 @@ 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.MixingLine; @@ -26,6 +27,7 @@ import java.io.Closeable; import java.io.IOException; import java.util.Comparator; +import java.util.Map; import java.util.TreeMap; /** @@ -37,7 +39,9 @@ class QueueEntry implements Closeable, Runnable, @Nullable HaltListener { private final int id; private final Listener listener; private final Object playbackLock = new Object(); + private final Map stateMetadata; private final TreeMap notifyInstants = new TreeMap<>(Comparator.comparingInt(o -> o)); + CrossfadeController crossfadeController; private Codec codec; private TrackOrEpisode metadata; private volatile boolean closed = false; @@ -45,9 +49,10 @@ class QueueEntry implements Closeable, Runnable, @Nullable HaltListener { private long playbackHaltedAt = 0; private ContentRestrictedException contentRestricted = null; - QueueEntry(int id, @NotNull PlayableId playable, @NotNull Listener listener) { + QueueEntry(int id, @NotNull PlayableId playable, @NotNull Map stateMetadata, @NotNull Listener listener) { this.id = id; this.playable = playable; + this.stateMetadata = stateMetadata; this.listener = listener; } @@ -56,9 +61,16 @@ public TrackOrEpisode metadata() { return metadata; } - synchronized void load(@NotNull Session session, @NotNull Player.Configuration conf, @NotNull AudioFormat format) throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { + synchronized void load(@NotNull Session session, @NotNull Player.Configuration conf, @NotNull AudioFormat format, int pos) throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { if (contentRestricted != null) throw contentRestricted; if (codec != null) { + if (pos != -1) { + if (pos == 0 && crossfadeController.fadeInEnabled()) + pos = crossfadeController.fadeInStartTime(); + + codec.seek(pos); + } + notifyAll(); return; } @@ -75,6 +87,12 @@ synchronized void load(@NotNull Session session, @NotNull Player.Configuration c Utils.artistsToString(stream.track.getArtistList()), playable.toSpotifyUri(), id)); } + crossfadeController = new CrossfadeController(metadata.duration(), stateMetadata, conf); + if (crossfadeController.fadeOutEnabled()) { + notifyInstant(PlayerQueue.INSTANT_START_NEXT, crossfadeController.fadeOutStartTime()); + notifyInstant(PlayerQueue.INSTANT_END_NOW, crossfadeController.fadeOutEndTime()); + } + switch (stream.in.codec()) { case VORBIS: codec = new VorbisCodec(format, stream.in, stream.normalizationData, conf, metadata.duration()); @@ -90,6 +108,11 @@ synchronized void load(@NotNull Session session, @NotNull Player.Configuration c throw new IllegalArgumentException("Unknown codec: " + stream.in.codec()); } + if (pos == 0 && crossfadeController.fadeInEnabled()) + pos = crossfadeController.fadeInStartTime(); + + if (pos != -1) codec.seek(pos); + contentRestricted = null; LOGGER.trace(String.format("Loaded %s codec. {fileId: %s, format: %s, id: %d}", stream.in.codec(), stream.in.describe(), codec.getAudioFormat(), id)); notifyAll(); @@ -142,22 +165,27 @@ void seek(int pos) { codec.seek(pos); } + void toggleOutput(boolean enabled) { + if (output == null) throw new IllegalStateException(); + output.toggle(enabled); + } + /** - * Set the output. + * Set the output. As soon as this method returns the entry will start playing. */ void setOutput(@NotNull MixingLine.MixingOutput output) { + if (this.output != null) throw new IllegalStateException(); + synchronized (playbackLock) { this.output = output; playbackLock.notifyAll(); } - - this.output.toggle(true); } /** - * Remove the output. + * Remove the output. As soon as this method is called the entry will stop playing. */ - void clearOutput() { + private void clearOutput() { if (output != null) { output.toggle(false); output.clear(); @@ -169,10 +197,22 @@ void clearOutput() { } } + /** + * Instructs to notify when this time from the end is reached. + * + * @param callbackId The callback ID + * @param when The time in milliseconds from the end + */ + void notifyInstantFromEnd(int callbackId, int when) { + if (metadata == null) throw new IllegalStateException(); + notifyInstant(callbackId, metadata.duration() - when); + } + /** * Instructs to notify when this time instant is reached. * - * @param when The time in milliseconds + * @param callbackId The callback ID + * @param when The time in milliseconds */ void notifyInstant(int callbackId, int when) { if (codec != null) { @@ -210,27 +250,40 @@ public void run() { break; } } + + if (output == null) continue; } - if (canGetTime && !notifyInstants.isEmpty()) { + if (closed) return; + + if (canGetTime) { try { int time = codec.time(); - checkInstants(time); + if (!notifyInstants.isEmpty()) checkInstants(time); + if (output == null) + continue; + + output.gain(crossfadeController.getGain(time)); } catch (Codec.CannotGetTimeException ex) { canGetTime = false; } } try { - if (codec.writeSomeTo(output.stream()) == -1) { - listener.playbackEnded(id); + if (codec.writeSomeTo(output.stream()) == -1) break; - } } catch (IOException | Codec.CodecException ex) { - if (closed) break; - listener.playbackException(id, ex); + if (!closed) { + listener.playbackException(id, ex); + close(); + } + + return; } } + + listener.playbackEnded(id); + close(); } private void checkInstants(int time) { @@ -262,6 +315,10 @@ public void streamReadResumed(int chunk, long time) { listener.playbackResumed(id, chunk, duration); } + boolean hasOutput() { + return output != null; + } + interface Listener { /** * An error occurred during playback. 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..b728dc2b --- /dev/null +++ b/core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java @@ -0,0 +1,21 @@ +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)); + assertEquals(0.272, interpolator.interpolate(0.32f)); + assertEquals(1, interpolator.interpolate(0)); + } +} From b4e305d5d4092e90fb65e062289fed644429c3ad Mon Sep 17 00:00:00 2001 From: Gianlu Date: Tue, 21 Apr 2020 20:44:43 +0200 Subject: [PATCH 17/32] Added transition metrics --- .../java/xyz/gianlu/librespot/core/EventService.java | 4 ++-- .../player/crossfade/CrossfadeController.java | 10 ++++++++++ .../xyz/gianlu/librespot/player/queue/PlayerQueue.java | 9 ++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index ae3ac39a..015a508d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -71,7 +71,7 @@ public void trackTransition(@NotNull StateWrapper state, @NotNull EventService.P 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' /* TODO: Encrypt latency */).append('0' /* TODO: Total fade */).append('0' /* FIXME */).append('0'); + event.append('0' /* TODO: Encrypt latency */).append(String.valueOf(metrics.player.totalFade)).append('0' /* FIXME */).append('0'); event.append(metrics.firstValue() == 0 ? '0' : '1').append(String.valueOf(metrics.firstValue())); event.append('0' /* TODO: Play latency */).append("-1" /* FIXME */).append("context"); event.append("-1" /* TODO: Audio key sync time */).append('0').append('0' /* TODO: Prefetched audio key */).append('0').append('0' /* FIXME */).append('0'); @@ -81,7 +81,7 @@ public void trackTransition(@NotNull StateWrapper state, @NotNull EventService.P event.append(metrics.id.hexId()).append(""); event.append('0').append(String.valueOf(TimeProvider.currentTimeMillis())).append('0'); event.append("context").append(playOrigin.getReferrerIdentifier()).append(playOrigin.getFeatureVersion()); - event.append("com.spotify").append("none" /* TODO: Transition */).append("none").append("local").append("na").append("none"); + event.append("com.spotify").append(metrics.player.transition).append("none").append("local").append("na").append("none"); sendEvent(event); } 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 1773556c..517d1b5b 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 @@ -97,6 +97,11 @@ public int fadeInEndTime() { else return defaultFadeDuration; } + public int fadeInDuration() { + if (fadeInInterval != null) return fadeInInterval.duration; + else return 0; + } + public boolean fadeOutEnabled() { return fadeOutInterval != null; } @@ -116,6 +121,11 @@ public int fadeOutStartTimeFromEnd() { else return defaultFadeDuration; } + public int fadeOutDuration() { + if (fadeOutInterval != null) return fadeOutInterval.duration; + else return 0; + } + @Nullable public String fadeOutUri() { return fadeOutUri; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java index 7c342de3..7a3a6190 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java @@ -400,8 +400,10 @@ public static class PlayerMetrics { public int bitrate = 0; public int duration = 0; public String encoding = null; + public int totalFade = 0; + public String transition = "none"; - PlayerMetrics(@Nullable Codec codec) { + PlayerMetrics(@NotNull QueueEntry entry, @Nullable Codec codec) { if (codec == null) return; size = codec.size(); @@ -413,6 +415,11 @@ public static class PlayerMetrics { if (codec instanceof VorbisCodec) encoding = "vorbis"; else if (codec instanceof Mp3Codec) encoding = "mp3"; + + if (entry.crossfadeController.fadeInEnabled() || entry.crossfadeController.fadeOutEnabled()) { + transition = "crossfade"; + totalFade = entry.crossfadeController.fadeInDuration() + entry.crossfadeController.fadeOutDuration(); + } } } } From 35a0f86054ae0aeb29669aa04c74cbfc32a584f4 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Wed, 22 Apr 2020 15:16:54 +0200 Subject: [PATCH 18/32] Fixed cache clean up + fixed build --- core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java | 2 +- .../main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 be7627e5..6e59436a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java +++ b/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java @@ -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); } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java index b770f2b5..ae3d134e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java @@ -136,7 +136,7 @@ void setContentRestricted(@NotNull ContentRestrictedException ex) { */ @NotNull PlayerQueue.PlayerMetrics metrics() { - return new PlayerQueue.PlayerMetrics(codec); + return new PlayerQueue.PlayerMetrics(this, codec); } /** From 5421e38a07ecc5cb1a7c4371920dff761ef7148a Mon Sep 17 00:00:00 2001 From: Gianlu Date: Wed, 22 Apr 2020 15:25:49 +0200 Subject: [PATCH 19/32] Fixed interpolator tests --- .../player/crossfade/InterpolatorTest.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 index b728dc2b..a66cf171 100644 --- a/core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java +++ b/core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java @@ -14,8 +14,19 @@ public class InterpolatorTest { 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)); - assertEquals(0.272, interpolator.interpolate(0.32f)); + 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)); + } } From 003d78ce36b1ac5b7cbe51fc6f96fe0720772ee6 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Fri, 24 Apr 2020 16:04:53 +0200 Subject: [PATCH 20/32] Major player rewrite done (metrics are still missing) --- .../gianlu/librespot/cache/CacheManager.java | 4 +- .../gianlu/librespot/core/EventService.java | 69 ++- .../xyz/gianlu/librespot/core/Session.java | 2 +- .../xyz/gianlu/librespot/player/Player.java | 458 +++++++++--------- .../gianlu/librespot/player/StateWrapper.java | 87 ++-- .../gianlu/librespot/player/codecs/Codec.java | 5 +- .../librespot/player/codecs/Mp3Codec.java | 3 +- .../{ => codecs}/NormalizationData.java | 3 +- .../librespot/player/codecs/VorbisCodec.java | 9 +- .../player/crossfade/CrossfadeController.java | 250 +++++++--- .../{ => feeders}/AbsChunkedInputStream.java | 3 +- .../{ => feeders}/GeneralAudioStream.java | 2 +- .../{ => feeders}/GeneralWritableStream.java | 2 +- .../player/{ => feeders}/HaltListener.java | 2 +- .../player/feeders/PlayableContentFeeder.java | 6 +- .../player/{ => feeders}/StreamId.java | 2 +- .../player/feeders/cdn/CdnFeedHelper.java | 4 +- .../player/feeders/cdn/CdnManager.java | 3 +- .../player/feeders/storage/AudioFile.java | 2 +- .../feeders/storage/AudioFileFetch.java | 2 +- .../feeders/storage/AudioFileStreaming.java | 6 +- .../feeders/storage/StorageFeedHelper.java | 4 +- .../librespot/player/mixing/AudioSink.java | 2 + .../player/playback/PlayerMetrics.java | 41 ++ .../player/playback/PlayerQueue.java | 193 ++++++++ .../PlayerQueueEntry.java} | 282 ++++++----- .../player/playback/PlayerSession.java | 374 ++++++++++++++ .../librespot/player/queue/PlayerQueue.java | 425 ---------------- 28 files changed, 1293 insertions(+), 952 deletions(-) rename core/src/main/java/xyz/gianlu/librespot/player/{ => codecs}/NormalizationData.java (95%) rename core/src/main/java/xyz/gianlu/librespot/player/{ => feeders}/AbsChunkedInputStream.java (98%) rename core/src/main/java/xyz/gianlu/librespot/player/{ => feeders}/GeneralAudioStream.java (86%) rename core/src/main/java/xyz/gianlu/librespot/player/{ => feeders}/GeneralWritableStream.java (80%) rename core/src/main/java/xyz/gianlu/librespot/player/{ => feeders}/HaltListener.java (78%) rename core/src/main/java/xyz/gianlu/librespot/player/{ => feeders}/StreamId.java (95%) create mode 100644 core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java create mode 100644 core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java rename core/src/main/java/xyz/gianlu/librespot/player/{queue/QueueEntry.java => playback/PlayerQueueEntry.java} (50%) create mode 100644 core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java delete mode 100644 core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java 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 6e59436a..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; diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index 015a508d..a7a4f48c 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -1,6 +1,5 @@ package xyz.gianlu.librespot.core; -import com.spotify.connectstate.Player; import com.spotify.metadata.Metadata; import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; @@ -11,7 +10,7 @@ import xyz.gianlu.librespot.mercury.RawMercuryRequest; import xyz.gianlu.librespot.mercury.model.PlayableId; import xyz.gianlu.librespot.player.StateWrapper; -import xyz.gianlu.librespot.player.queue.PlayerQueue; +import xyz.gianlu.librespot.player.playback.PlayerMetrics; import java.io.ByteArrayOutputStream; import java.io.Closeable; @@ -52,61 +51,78 @@ private void sendEvent(@NotNull EventBuilder builder) { asyncWorker.submit(builder); } - public void reportLang(@NotNull String lang) { + /** + * 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); } - public void trackTransition(@NotNull StateWrapper state, @NotNull EventService.PlaybackMetrics metrics) { - Player.PlayOrigin playOrigin = state.getPlayOrigin(); + private void trackTransition(@NotNull EventService.PlaybackMetrics metrics) { int when = metrics.lastValue(); EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); event.append(String.valueOf(trackTransitionIncremental++)); event.append(session.deviceId()); - event.append(state.getPlaybackId()).append("00000000000000000000000000000000"); + 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' /* TODO: Encrypt latency */).append(String.valueOf(metrics.player.totalFade)).append('0' /* FIXME */).append('0'); + event.append('0' /* TODO: Encrypt latency */).append(String.valueOf(metrics.player.fadeOverlap)).append('0' /* FIXME */).append('0'); event.append(metrics.firstValue() == 0 ? '0' : '1').append(String.valueOf(metrics.firstValue())); event.append('0' /* TODO: Play latency */).append("-1" /* FIXME */).append("context"); event.append("-1" /* TODO: Audio key sync time */).append('0').append('0' /* TODO: Prefetched audio key */).append('0').append('0' /* FIXME */).append('0'); event.append(String.valueOf(when)).append(String.valueOf(when)); event.append('0').append(String.valueOf(metrics.player.bitrate)); - event.append(state.getContextUri()).append(metrics.player.encoding); + event.append(metrics.contextUri).append(metrics.player.encoding); event.append(metrics.id.hexId()).append(""); event.append('0').append(String.valueOf(TimeProvider.currentTimeMillis())).append('0'); - event.append("context").append(playOrigin.getReferrerIdentifier()).append(playOrigin.getFeatureVersion()); + event.append("context").append(metrics.referrerIdentifier).append(metrics.featureVersion); event.append("com.spotify").append(metrics.player.transition).append("none").append("local").append("na").append("none"); sendEvent(event); } - public void trackPlayed(@NotNull StateWrapper state, @NotNull EventService.PlaybackMetrics metrics) { + public void trackPlayed(@NotNull EventService.PlaybackMetrics metrics) { if (metrics.player == null) return; - trackTransition(state, metrics); + trackTransition(metrics); EventBuilder event = new EventBuilder(Type.TRACK_PLAYED); - event.append(state.getPlaybackId()).append(metrics.id.toSpotifyUri()); + event.append(metrics.playbackId).append(metrics.id.toSpotifyUri()); event.append('0').append(metrics.intervalsToSend()); sendEvent(event); } - public void newPlaybackId(@NotNull StateWrapper state) { + /** + * 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(state.getPlaybackId()).append(state.getSessionId()).append(String.valueOf(TimeProvider.currentTimeMillis())); + event.append(playbackId).append(state.getSessionId()).append(String.valueOf(TimeProvider.currentTimeMillis())); sendEvent(event); } - public void newSessionId(@NotNull StateWrapper state) { - EventBuilder event = new EventBuilder(Type.NEW_SESSION_ID); - event.append(state.getSessionId()); + /** + * 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())); @@ -115,7 +131,13 @@ public void newSessionId(@NotNull StateWrapper state) { sendEvent(event); } - public void fetchedFileId(@NotNull Metadata.AudioFile file, @NotNull PlayableId id) { + /** + * 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()); @@ -199,15 +221,20 @@ byte[] toArray() { public static class PlaybackMetrics { public final PlayableId id; final List intervals = new ArrayList<>(10); - PlayerQueue.PlayerMetrics player = null; + final String playbackId; + String featureVersion = null; + String referrerIdentifier = null; + String contextUri = null; + PlayerMetrics player = null; Interval lastInterval = null; Reason reasonStart = null; String sourceStart = null; Reason reasonEnd = null; String sourceEnd = null; - public PlaybackMetrics(@NotNull PlayableId id) { + public PlaybackMetrics(@NotNull PlayableId id, @NotNull String playbackId) { this.id = id; + this.playbackId = playbackId; } @NotNull @@ -275,7 +302,7 @@ String endedHow() { return reasonEnd == null ? null : reasonEnd.val; } - public void update(@NotNull PlayerQueue.PlayerMetrics playerMetrics) { + public void update(@NotNull PlayerMetrics playerMetrics) { player = playerMetrics; } 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 13ddf90e..db8d69f7 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -349,7 +349,7 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I dealer.connect(); player.initState(); TimeProvider.init(this); - eventService.reportLang(conf().preferredLocale()); + eventService.language(conf().preferredLocale()); LOGGER.info(String.format("Authenticated as %s!", apWelcome.getCanonicalUsername())); mercuryClient.interestedIn("spotify:user:attributes:update", this); 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 7c7dc9c4..aa31509e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -26,8 +26,9 @@ 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.queue.PlayerQueue; +import xyz.gianlu.librespot.player.playback.PlayerSession; import java.io.Closeable; import java.io.File; @@ -43,23 +44,23 @@ /** * @author Gianlu */ -public class Player implements Closeable, DeviceStateHandler.Listener, PlayerQueue.Listener { // TODO: Reduce calls to state update +public class Player implements Closeable, DeviceStateHandler.Listener, PlayerSession.Listener, AudioSink.Listener { // TODO: Metrics 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 PlayerQueue queue; private final EventsDispatcher events; + private final AudioSink sink; private StateWrapper state; + private PlayerSession playerSession; private ScheduledFuture releaseLineFuture = null; - private PlaybackMetrics playbackMetrics = null; public Player(@NotNull Player.Configuration conf, @NotNull Session session) { this.conf = conf; this.session = session; this.events = new EventsDispatcher(conf); - this.queue = new PlayerQueue(session, conf, this); + this.sink = new AudioSink(conf, this); } public void addEventsListener(@NotNull EventsListener listener) { @@ -113,28 +114,26 @@ public void pause() { } public void next() { - handleNext(null, TransitionInfo.skippedNext(state)); + 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(null); - return; } catch (AbsSpotifyContext.UnsupportedContextException ex) { LOGGER.fatal("Cannot play local tracks!", ex); panicState(null); - return; } - - events.contextChanged(); - loadTrack(play, TransitionInfo.contextChange(state, true)); } @@ -142,60 +141,61 @@ public void load(@NotNull String uri, boolean play) { // ======== Internal state ======== // // ================================ // - private void transferState(TransferStateOuterClass.@NotNull TransferState cmd) { - LOGGER.debug(String.format("Loading context (transfer), uri: %s", cmd.getCurrentSession().getContext().getUri())); + /** + * Enter a "panic" state where everything is stopped. + * + * @param reason Why we entered this state + */ + private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { + sink.pause(true); + state.setState(false, false, false); + state.updated(); + } - try { - state.transfer(cmd); - } catch (IOException | MercuryClient.MercuryException ex) { - LOGGER.fatal("Failed loading context!", ex); - panicState(null); - return; - } catch (AbsSpotifyContext.UnsupportedContextException ex) { - LOGGER.fatal("Cannot play local tracks!", ex); - panicState(null); - return; + /** + * 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) { + if (playerSession != null) { + playerSession.close(); + playerSession = null; } - events.contextChanged(); - loadTrack(!cmd.getPlayback().getIsPaused(), TransitionInfo.contextChange(state, true)); + playerSession = new PlayerSession(session, sink, sessionId, this); + session.eventService().newSessionId(sessionId, state); - session.eventService().newSessionId(state); - session.eventService().newPlaybackId(state); + loadTrack(play, TransitionInfo.contextChange(state, withSkip)); } - private void handleLoad(@NotNull JsonObject obj) { - LOGGER.debug(String.format("Loading context (play), uri: %s", PlayCommandHelper.getContextUri(obj))); - - try { - state.load(obj); - } catch (IOException | MercuryClient.MercuryException ex) { - LOGGER.fatal("Failed loading context!", ex); - panicState(null); - return; - } catch (AbsSpotifyContext.UnsupportedContextException ex) { - LOGGER.fatal("Cannot play local tracks!", ex); - panicState(null); - return; - } - - events.contextChanged(); + /** + * 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) { + String playbackId = playerSession.play(state.getCurrentPlayableOrThrow(), state.getPosition(), trans.startedReason); + state.setPlaybackId(playbackId); + session.eventService().newPlaybackId(state, playbackId); - Boolean paused = PlayCommandHelper.isInitiallyPaused(obj); - if (paused == null) paused = true; - loadTrack(!paused, TransitionInfo.contextChange(state, PlayCommandHelper.willSkipToSomething(obj))); - } + if (play) sink.resume(); + else sink.pause(false); - private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { - queue.pause(true); - state.setState(false, false, false); + state.setState(true, !play, true); state.updated(); - if (reason != null && playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { - playbackMetrics.endedHow(reason, null); - playbackMetrics.endInterval(state.getPosition()); - session.eventService().trackPlayed(state, playbackMetrics); - playbackMetrics = null; + events.trackChanged(); + if (play) events.playbackResumed(); + else events.playbackPaused(); + + if (releaseLineFuture != null) { + releaseLineFuture.cancel(true); + releaseLineFuture = null; } } @@ -203,16 +203,27 @@ private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { 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: - 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(); @@ -224,10 +235,10 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi handleSeek(data.valueInt()); break; case SkipNext: - handleNext(data.obj(), TransitionInfo.skippedNext(state)); + handleSkipNext(data.obj(), TransitionInfo.skippedNext(state)); break; case SkipPrev: - handlePrev(); + handleSkipPrev(); break; case SetRepeatingContext: state.setRepeatingContext(data.valueBool()); @@ -242,10 +253,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())); @@ -257,43 +268,51 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi } } - @Override - public void volumeChanged() { - queue.setVolume(state.getVolume()); - } + private void handlePlay(@NotNull JsonObject obj) { + LOGGER.debug(String.format("Loading context (play), uri: %s", PlayCommandHelper.getContextUri(obj))); - @Override - public void notActive() { - events.inactiveSession(false); - queue.pause(true); + try { + String sessionId = state.load(obj); + events.contextChanged(); + + 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); + } } - private void entryIsReady() { - if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) - playbackMetrics.update(queue.currentMetrics()); + private void handleTransferState(@NotNull TransferStateOuterClass.TransferState cmd) { + LOGGER.debug(String.format("Loading context (transfer), uri: %s", cmd.getCurrentSession().getContext().getUri())); - TrackOrEpisode metadata = currentMetadata(); - if (metadata != null) { - state.enrichWithMetadata(metadata); - events.metadataAvailable(); + 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) { - if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { - playbackMetrics.endInterval(state.getPosition()); - playbackMetrics.startInterval(pos); - } - + playerSession.seekCurrent(pos); state.setPosition(pos); - queue.seekCurrent(pos); events.seeked(pos); } private void handleResume() { if (state.isPaused()) { state.setState(true, false, false); - queue.resume(); + sink.resume(); state.updated(); events.playbackResumed(); @@ -308,10 +327,11 @@ private void handleResume() { private void handlePause() { if (state.isPlaying()) { state.setState(true, true, false); - queue.pause(false); + sink.pause(false); try { - state.setPosition(queue.currentTime()); + if (playerSession != null) + state.setPosition(playerSession.currentTime()); } catch (Codec.CannotGetTimeException ignored) { } @@ -323,63 +343,12 @@ private void handlePause() { if (!state.isPaused()) return; events.inactiveSession(true); - queue.pause(true); + sink.pause(true); }, conf.releaseLineDelay(), TimeUnit.SECONDS); } } - private void loadTrack(boolean play, @NotNull TransitionInfo trans) { - if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { - playbackMetrics.endedHow(trans.endedReason, state.getPlayOrigin().getFeatureIdentifier()); - playbackMetrics.endInterval(trans.endedWhen); - session.eventService().trackPlayed(state, playbackMetrics); - playbackMetrics = null; - } - - state.renewPlaybackId(); - - PlayableId playable = state.getCurrentPlayableOrThrow(); - playbackMetrics = new PlaybackMetrics(playable); - if (!queue.isCurrent(playable)) { - queue.clear(); - - state.setState(true, !play, true); - queue.follows(queue.load(playable, state.metadataFor(playable), state.getPosition())); - - state.updated(); - events.trackChanged(); - - if (play) { - queue.resume(); - events.playbackResumed(); - } else { - queue.pause(false); - events.playbackPaused(); - } - } else { - try { - state.setPosition(queue.currentTime()); - } catch (Codec.CannotGetTimeException ignored) { - } - - entryIsReady(); - - state.updated(); - events.trackChanged(); - - if (!play) queue.pause(false); - } - - if (releaseLineFuture != null) { - releaseLineFuture.cancel(true); - releaseLineFuture = null; - } - - playbackMetrics.startedHow(trans.startedReason, state.getPlayOrigin().getFeatureIdentifier()); - playbackMetrics.startInterval(state.getPosition()); - } - - 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(); @@ -388,7 +357,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(); @@ -396,7 +365,7 @@ private void addToQueue(@NotNull JsonObject obj) { state.updated(); } - private void handleNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) { + private void handleSkipNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) { ContextTrack track = null; if (obj != null) track = PlayCommandHelper.getTrack(obj); @@ -423,7 +392,7 @@ private void handleNext(@Nullable JsonObject obj, @NotNull TransitionInfo trans) } } - private void handlePrev() { + private void handleSkipPrev() { if (state.getPosition() < 3000) { StateWrapper.PreviousPlayable prev = state.previousPlayable(); if (prev.isOk()) { @@ -434,12 +403,15 @@ private void handlePrev() { panicState(null); } } else { + playerSession.seekCurrent(0); state.setPosition(0); - queue.seekCurrent(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) { @@ -454,20 +426,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, TransitionInfo.contextChange(state, false)); + 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, TransitionInfo.contextChange(state, false)); + loadSession(sessionId, true, false); LOGGER.debug(String.format("Loading context for autoplay (using radio-apollo), uri: %s", state.getContextUri())); } else { @@ -492,22 +464,20 @@ private void loadAutoplay() { // ================================ // @Override - public void startedLoading(int id) { - if (queue.isCurrent(id)) { - if (!state.isBuffering()) { - state.setBuffering(true); - state.updated(); - } + public void startedLoading() { + if (state.isPlaying()) { + state.setBuffering(true); + state.updated(); } } @Override - public void finishedLoading(int id) { - if (queue.isCurrent(id)) { - state.setBuffering(false); - entryIsReady(); - state.updated(); - } + public void finishedLoading(@NotNull TrackOrEpisode metadata) { + state.enrichWithMetadata(metadata); + state.setBuffering(false); + state.updated(); + + events.metadataAvailable(); } @Override @@ -517,90 +487,54 @@ public void sinkError(@NotNull Exception ex) { } @Override - public void loadingError(int id, @NotNull PlayableId playable, @NotNull Exception ex) { - if (queue.isCurrent(id)) { - if (ex instanceof ContentRestrictedException) { - LOGGER.fatal(String.format("Can't load track (content restricted). {uri: %s}", playable.toSpotifyUri()), ex); - handleNext(null, TransitionInfo.nextError(state)); - return; - } - - LOGGER.fatal(String.format("Failed loading track. {uri: %s}", playable.toSpotifyUri()), ex); + 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); } } @Override - public void endOfPlayback(int id) { - if (queue.isCurrent(id)) { - LOGGER.trace(String.format("End of track. {id: %d}", id)); - handleNext(null, TransitionInfo.next(state)); - } - } - - @Override - public void startedNextTrack(int id, int next) { - if (queue.isCurrent(next)) { - LOGGER.trace(String.format("Playing next track. {next: %d}", next)); - handleNext(null, TransitionInfo.next(state)); - } - } + 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); - @Override - public void preloadNextTrack(int id) { - if (queue.isCurrent(id)) { - PlayableId next = state.nextPlayableDoNotSet(); - if (next != null) { - int nextId = queue.load(next, state.metadataFor(next), 0); - queue.follows(nextId); - LOGGER.trace(String.format("Started next track preload. {uri: %s, next: %d}", next.toSpotifyUri(), nextId)); - } - } + panicState(PlaybackMetrics.Reason.TRACK_ERROR); } @Override - public void finishedSeek(int id, int pos) { - if (queue.isCurrent(id)) { - state.setPosition(pos); - state.updated(); - } - } + public void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode metadata, int pos) { + session.eventService().newPlaybackId(state, playbackId); - @Override - public void playbackError(int id, @NotNull Exception ex) { - if (queue.isCurrent(id)) { - if (ex instanceof AbsChunkedInputStream.ChunkException) - LOGGER.fatal("Failed retrieving chunk, playback failed!", ex); - else - LOGGER.fatal("Playback error!", ex); + if (metadata != null) state.enrichWithMetadata(metadata); + state.setPlaybackId(playbackId); + state.setPosition(pos); + state.updated(); - panicState(PlaybackMetrics.Reason.TRACK_ERROR); - } + events.trackChanged(); } @Override - public void playbackHalted(int id, int chunk) { - if (queue.isCurrent(id)) { - LOGGER.debug(String.format("Playback halted on retrieving chunk %d.", chunk)); - - state.setBuffering(true); - state.updated(); + public void playbackHalted(int chunk) { + LOGGER.debug(String.format("Playback halted on retrieving chunk %d.", chunk)); + state.setBuffering(true); + state.updated(); - events.playbackHaltStateChanged(true); - } + events.playbackHaltStateChanged(true); } @Override - public void playbackResumedFromHalt(int id, int chunk, long diff) { - if (queue.isCurrent(id)) { - LOGGER.debug(String.format("Playback resumed, chunk %d retrieved, took %dms.", chunk, diff)); - - state.setPosition(state.getPosition() - diff); - state.setBuffering(false); - state.updated(); + 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); - } + events.playbackHaltStateChanged(false); } @@ -615,16 +549,18 @@ public boolean isActive() { return state.isActive(); } + /** + * @return The metadata for the current entry or {@code null} if not available. + */ @Nullable public TrackOrEpisode currentMetadata() { - return queue.currentMetadata(); - } - - @Nullable - public PlayableId currentPlayableId() { - return state.getCurrentPlayable(); + 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 { TrackOrEpisode metadata = currentMetadata(); @@ -661,34 +597,76 @@ public byte[] currentCoverImage() throws IOException { } } + /** + * @return The current content in the state or {@code null} if not 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 queue.currentTime(); + return playerSession == null ? -1 : playerSession.currentTime(); } catch (Codec.CannotGetTimeException ex) { return -1; } } + // ================================ // // ============ Close! ============ // // ================================ // @Override public void close() { - if (playbackMetrics != null && queue.isCurrent(playbackMetrics.id)) { - playbackMetrics.endedHow(PlaybackMetrics.Reason.LOGOUT, null); - playbackMetrics.endInterval(state.getPosition()); - session.eventService().trackPlayed(state, playbackMetrics); - playbackMetrics = null; - } - state.close(); events.listeners.clear(); - queue.close(); + sink.close(); if (state != null) state.removeListener(this); scheduler.shutdown(); 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 e2686656..5bdcae4e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -92,7 +92,7 @@ private static PlayerState.Builder initState(@NotNull PlayerState.Builder builde } @NotNull - private static String generatePlaybackId(@NotNull Random random) { + public static String generatePlaybackId(@NotNull Random random) { byte[] bytes = new byte[16]; random.nextBytes(bytes); bytes[0] = 1; @@ -120,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(); @@ -139,8 +135,8 @@ boolean isPaused() { return state.getIsPlaying() && state.getIsPaused(); } - boolean isBuffering() { - return state.getIsPlaying() && state.getIsBuffering(); + void setBuffering(boolean buffering) { + setState(state.getIsPlaying(), state.getIsPaused(), buffering); } private boolean isShufflingContext() { @@ -217,7 +213,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); @@ -236,10 +233,11 @@ private void setContext(@NotNull String uri) { this.device.setIsActive(true); - renewSessionId(); + 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); @@ -260,7 +258,7 @@ private void setContext(@NotNull Context ctx) { this.device.setIsActive(true); - renewSessionId(); + return renewSessionId(); } private void updateRestrictions() { @@ -431,35 +429,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()); @@ -469,12 +472,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); @@ -498,6 +503,7 @@ void load(@NotNull JsonObject obj) throws AbsSpotifyContext.UnsupportedContextEx else setPosition(0); loadTransforming(); + return sessionId; } synchronized void updateContext(@NotNull JsonObject obj) { @@ -516,17 +522,6 @@ void skipTo(@NotNull ContextTrack track) { setPosition(0); } - @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 public PlayableId getCurrentPlayable() { return tracksKeeper == null ? null : PlayableId.from(tracksKeeper.getCurrentTrack()); @@ -551,6 +546,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; @@ -740,26 +746,19 @@ public void setContextMetadata(@NotNull String key, @Nullable String value) { } @NotNull - public PlayOrigin getPlayOrigin() { - return state.getPlayOrigin(); - } - - @Nullable - public String getPlaybackId() { - return state.getPlaybackId(); + private String renewSessionId() { + String sessionId = generateSessionId(session.random()); + state.setSessionId(sessionId); + return sessionId; } - @Nullable + @NotNull public String getSessionId() { return state.getSessionId(); } - private void renewSessionId() { - state.setSessionId(generateSessionId(session.random())); - } - - void renewPlaybackId() { - state.setPlaybackId(generatePlaybackId(session.random())); + public void setPlaybackId(@NotNull String playbackId) { + state.setPlaybackId(playbackId); } @Override 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 9fbd1271..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; 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 517d1b5b..d21ce942 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,76 +6,131 @@ 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 String fadeOutUri; - private final FadeInterval fadeInInterval; - private final FadeInterval fadeOutInterval; + private final Map fadeOutMap = new HashMap<>(8); + private final Map fadeInMap = new HashMap<>(8); private final int defaultFadeDuration; + 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 Map metadata, @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(); + fadeOutUri = metadata.get("audio.fade_out_uri"); + + populateFadeIn(metadata); + populateFadeOut(metadata); + LOGGER.debug(String.format("Loaded crossfade intervals {id: %s, in: %s, out: %s}", playbackId, fadeInMap, fadeOutMap)); + } + + @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(); + + return curve.getAsJsonArray("fade_curve"); + } + + 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"); + if (fadeInDuration != 0 && fadeInCurves.size() > 0) + fadeInMap.put(Reason.TRACK_DONE, new FadeInterval(fadeInStartTime, fadeInDuration, LookupInterpolator.fromJson(getFadeCurve(fadeInCurves)))); + else if (defaultFadeDuration > 0) + fadeInMap.put(Reason.TRACK_DONE, new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator())); + + + 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())); + else if (defaultFadeDuration > 0) + fadeInMap.put(Reason.FORWARD_BTN, new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator())); + + 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())); + else if (defaultFadeDuration > 0) + fadeInMap.put(Reason.BACK_BTN, new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator())); + } + + 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 (fadeInDuration == 0) - fadeInInterval = null; - else if (fadeInCurves.size() > 0) - fadeInInterval = new FadeInterval(fadeInStartTime, fadeInDuration, LookupInterpolator.fromJson(getFadeCurve(fadeInCurves))); - else if (defaultFadeDuration > 0) - fadeInInterval = new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator()); - else - fadeInInterval = null; - - if (fadeOutDuration == 0) - fadeOutInterval = null; - else if (fadeOutCurves.size() > 0) - fadeOutInterval = new FadeInterval(fadeOutStartTime, fadeOutDuration, LookupInterpolator.fromJson(getFadeCurve(fadeOutCurves))); + if (fadeOutDuration != 0 && fadeOutCurves.size() > 0) + fadeOutMap.put(Reason.TRACK_DONE, new FadeInterval(fadeOutStartTime, fadeOutDuration, LookupInterpolator.fromJson(getFadeCurve(fadeOutCurves)))); else if (defaultFadeDuration > 0) - fadeOutInterval = new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator()); - else - fadeOutInterval = null; + fadeOutMap.put(Reason.TRACK_DONE, new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator())); - LOGGER.debug(String.format("Loaded crossfade intervals. {start: %s, end: %s}", fadeInInterval, fadeOutInterval)); - } - @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 backFadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.backbtn.fade_out_duration", "-1")); + if (backFadeOutDuration > 0) + fadeOutMap.put(Reason.BACK_BTN, new PartialFadeInterval(backFadeOutDuration, new LinearDecreasingInterpolator())); + else if (defaultFadeDuration > 0) + fadeOutMap.put(Reason.BACK_BTN, new PartialFadeInterval(defaultFadeDuration, new LinearDecreasingInterpolator())); - return curve.getAsJsonArray("fade_curve"); + 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())); + else if (defaultFadeDuration > 0) + fadeOutMap.put(Reason.FORWARD_BTN, new PartialFadeInterval(defaultFadeDuration, 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 (fadeInInterval != null && pos >= fadeInInterval.start && fadeInInterval.end() >= pos) - activeInterval = fadeInInterval; - else if (fadeOutInterval != null && pos >= fadeOutInterval.start && fadeOutInterval.end() >= pos) - activeInterval = fadeOutInterval; + 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; @@ -83,55 +138,112 @@ else if (fadeOutInterval != null && pos >= fadeOutInterval.start && fadeOutInter return lastGain = activeInterval.interpolate(pos); } - public boolean fadeInEnabled() { - return fadeInInterval != null; + /** + * 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 fadeInStartTime() { - if (fadeInInterval != null) return fadeInInterval.start; - else return 0; + /** + * 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 int fadeInEndTime() { - if (fadeInInterval != null) return fadeInInterval.end(); - else return defaultFadeDuration; + @Nullable + public String fadeOutUri() { + return fadeOutUri; // TODO: Is this useful? } - public int fadeInDuration() { - if (fadeInInterval != null) return fadeInInterval.duration; - else return 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; - public boolean fadeOutEnabled() { - return fadeOutInterval != null; - } + if (fadeOutStartTime == -1 || fadeOutStartTime > interval.start) + fadeOutStartTime = interval.start; + } - public int fadeOutStartTime() { - if (fadeOutInterval != null) return fadeOutInterval.start; - else return trackDuration - defaultFadeDuration; + if (fadeOutStartTime == -1) return trackDuration; + else return fadeOutStartTime; } - public int fadeOutEndTime() { - if (fadeOutInterval != null) return fadeOutInterval.end(); - else return trackDuration; + /** + * @return Whether there is any possibility of a fade out. + */ + public boolean hasAnyFadeOut() { + return !fadeOutMap.isEmpty(); } - public int fadeOutStartTimeFromEnd() { - if (fadeOutInterval != null) return trackDuration - fadeOutInterval.start; - else return defaultFadeDuration; + /** + * @return The amount of fade overlap accumulated during playback. + */ + public int fadeOverlap() { + return fadeOverlap; } - public int fadeOutDuration() { - if (fadeOutInterval != null) return fadeOutInterval.duration; - else return 0; - } + /** + * An interval without a start. Used when crossfading due to an user interaction. + */ + public static class PartialFadeInterval extends FadeInterval { + private int partialStart = -1; - @Nullable - public String fadeOutUri() { - return fadeOutUri; + 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 + '}'; + } } - private static class FadeInterval { + /** + * 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; @@ -142,10 +254,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/AbsChunkedInputStream.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/AbsChunkedInputStream.java similarity index 98% 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 5b3055fb..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; 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 f0c1dc3a..a2429b93 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 @@ -17,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; @@ -106,7 +104,7 @@ private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Meta if (track == null && episode == null) throw new IllegalStateException(); - session.eventService().fetchedFileId(file, track != null ? PlayableId.from(track) : PlayableId.from(episode)); + session.eventService().fetchedFileId(track != null ? PlayableId.from(track) : PlayableId.from(episode), file); StorageResolveResponse resp = resolveStorageInteractive(file.getFileId()); switch (resp.getResult()) { 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..ac021176 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,8 @@ 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.LoadedStream; import java.io.IOException; 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 2f8d012b..ebebe509 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; 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 8612180a..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; 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 10c56c74..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; 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 d892fbab..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; 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..61ec716a 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; 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 index a81e2ef0..b11dd1a7 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java @@ -121,6 +121,8 @@ public void setVolume(int volume) { public void close() { closed = true; thread.interrupt(); + + clearOutputs(); } @Override 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..772d0d72 --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java @@ -0,0 +1,41 @@ +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 javax.sound.sampled.AudioFormat; + +/** + * @author devgianlu + */ +public final class PlayerMetrics { + 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 CrossfadeController crossfade, @Nullable Codec codec) { + if (codec == null) return; + + size = codec.size(); + duration = codec.duration(); + decodedLength = codec.decodedLength(); + + AudioFormat format = codec.getAudioFormat(); + bitrate = (int) (format.getSampleRate() * format.getSampleSizeInBits()); + + 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..42aaa39d --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java @@ -0,0 +1,193 @@ +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 once that gets removed. + * + * @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); + } + + if (swapped) { + oldEntry.close(); + 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; // Avoid garbage collection issues + 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(); + } + + if (next != null) { + next.clear(); + next.close(); + } + + ((PlayerQueueEntry) this).close(); + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java similarity index 50% rename from core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java rename to core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java index ae3d134e..1565e591 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/QueueEntry.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.queue; +package xyz.gianlu.librespot.player.playback; import javazoom.jl.decoder.BitstreamException; import org.apache.log4j.Logger; @@ -11,14 +11,14 @@ 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.HaltListener; -import xyz.gianlu.librespot.player.Player; +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; @@ -26,121 +26,118 @@ 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 QueueEntry implements Closeable, Runnable, @Nullable HaltListener { - private static final Logger LOGGER = Logger.getLogger(QueueEntry.class); +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; - private final int id; + final String playbackId; private final Listener listener; private final Object playbackLock = new Object(); - private final Map stateMetadata; private final TreeMap notifyInstants = new TreeMap<>(Comparator.comparingInt(o -> o)); - CrossfadeController crossfadeController; + private final Session session; + private final AudioFormat format; + CrossfadeController crossfade; private Codec codec; private TrackOrEpisode metadata; private volatile boolean closed = false; private volatile MixingLine.MixingOutput output; private long playbackHaltedAt = 0; - private ContentRestrictedException contentRestricted = null; + private volatile int seekTime = -1; + private boolean retried = false; - QueueEntry(int id, @NotNull PlayableId playable, @NotNull Map stateMetadata, @NotNull Listener listener) { - this.id = id; + PlayerQueueEntry(@NotNull Session session, @NotNull AudioFormat format, @NotNull PlayableId playable, @NotNull Listener listener) { + this.session = session; + this.format = format; + this.playbackId = StateWrapper.generatePlaybackId(session.random()); this.playable = playable; - this.stateMetadata = stateMetadata; this.listener = listener; - } - @Nullable - public TrackOrEpisode metadata() { - return metadata; + LOGGER.trace(String.format("Created new %s.", this)); } - synchronized void load(@NotNull Session session, @NotNull Player.Configuration conf, @NotNull AudioFormat format, int pos) throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { - if (contentRestricted != null) throw contentRestricted; - if (codec != null) { - if (pos != -1) { - if (pos == 0 && crossfadeController.fadeInEnabled()) - pos = crossfadeController.fadeInStartTime(); - - codec.seek(pos); - } - - notifyAll(); - return; - } + @NotNull + PlayerQueueEntry retrySelf() { + if (retried) throw new IllegalStateException(); - listener.startedLoading(id); + PlayerQueueEntry retry = new PlayerQueueEntry(session, format, playable, listener); + retry.retried = true; + return retry; + } - PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(conf.preferredQuality()), this); + /** + * 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() throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { + PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(session.conf().preferredQuality()), this); metadata = new TrackOrEpisode(stream.track, stream.episode); if (playable instanceof EpisodeId && stream.episode != null) { - LOGGER.info(String.format("Loaded episode. {name: '%s', uri: %s, id: %d}", stream.episode.getName(), playable.toSpotifyUri(), id)); + 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: %d}", stream.track.getName(), - Utils.artistsToString(stream.track.getArtistList()), playable.toSpotifyUri(), id)); + 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)); } - crossfadeController = new CrossfadeController(metadata.duration(), stateMetadata, conf); - if (crossfadeController.fadeOutEnabled()) { - notifyInstant(PlayerQueue.INSTANT_START_NEXT, crossfadeController.fadeOutStartTime()); - notifyInstant(PlayerQueue.INSTANT_END_NOW, crossfadeController.fadeOutEndTime()); - } + 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, conf, metadata.duration()); + codec = new VorbisCodec(format, stream.in, stream.normalizationData, session.conf(), metadata.duration()); break; case MP3: try { - codec = new Mp3Codec(format, stream.in, stream.normalizationData, conf, metadata.duration()); + codec = new Mp3Codec(format, stream.in, stream.normalizationData, session.conf(), metadata.duration()); } catch (BitstreamException ex) { throw new IOException(ex); } break; default: - throw new IllegalArgumentException("Unknown codec: " + stream.in.codec()); + throw new UnsupportedEncodingException(stream.in.codec().toString()); } - if (pos == 0 && crossfadeController.fadeInEnabled()) - pos = crossfadeController.fadeInStartTime(); - - if (pos != -1) codec.seek(pos); - - contentRestricted = null; - LOGGER.trace(String.format("Loaded %s codec. {fileId: %s, format: %s, id: %d}", stream.in.codec(), stream.in.describe(), codec.getAudioFormat(), id)); - notifyAll(); - - listener.finishedLoading(id); - } - - private synchronized void waitReady() throws InterruptedException { - if (codec != null) return; - wait(); + LOGGER.trace(String.format("Loaded %s codec. {fileId: %s, format: %s, id: %s}", stream.in.codec(), stream.in.describe(), codec.getAudioFormat(), playbackId)); } - void setContentRestricted(@NotNull ContentRestrictedException ex) { - contentRestricted = ex; + /** + * 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 xyz.gianlu.librespot.player.queue.PlayerQueue.PlayerMetrics} object + * @return A {@link PlayerMetrics} object */ @NotNull - PlayerQueue.PlayerMetrics metrics() { - return new PlayerQueue.PlayerMetrics(this, codec); + PlayerMetrics metrics() { + return new PlayerMetrics(crossfade, codec); } /** - * Return the current position. + * 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. @@ -150,64 +147,51 @@ int getTime() throws Codec.CannotGetTimeException { } /** - * Seek to the specified position. + * Seeks to the specified position. * * @param pos The time in milliseconds */ void seek(int pos) { - try { - waitReady(); - } catch (InterruptedException ex) { - return; - } - - output.stream().emptyBuffer(); - codec.seek(pos); - } - - void toggleOutput(boolean enabled) { - if (output == null) throw new IllegalStateException(); - output.toggle(enabled); + seekTime = pos; + if (output != null) output.stream().emptyBuffer(); } /** - * Set the output. As soon as this method returns the entry will start playing. + * Sets the output. As soon as this method returns the entry will start playing. */ void setOutput(@NotNull MixingLine.MixingOutput output) { - if (this.output != null) throw new IllegalStateException(); + if (closed) return; + + if (this.output != null) + throw new IllegalStateException("Output is already set for " + this); synchronized (playbackLock) { this.output = output; playbackLock.notifyAll(); } + + this.output.toggle(true); } /** - * Remove the output. As soon as this method is called the entry will stop playing. + * Removes the output. As soon as this method is called the entry will stop playing. */ private void clearOutput() { if (output != null) { - output.toggle(false); - output.clear(); + MixingLine.MixingOutput tmp = output; + output = null; + + tmp.toggle(false); + tmp.clear(); + + LOGGER.debug(String.format("%s has been removed from output.", this)); } synchronized (playbackLock) { - output = null; playbackLock.notifyAll(); } } - /** - * Instructs to notify when this time from the end is reached. - * - * @param callbackId The callback ID - * @param when The time in milliseconds from the end - */ - void notifyInstantFromEnd(int callbackId, int when) { - if (metadata == null) throw new IllegalStateException(); - notifyInstant(callbackId, metadata.duration() - when); - } - /** * Instructs to notify when this time instant is reached. * @@ -219,7 +203,7 @@ void notifyInstant(int callbackId, int when) { try { int time = codec.time(); if (time >= when) { - listener.instantReached(id, callbackId, time); + listener.instantReached(this, callbackId, time); return; } } catch (Codec.CannotGetTimeException ex) { @@ -232,14 +216,24 @@ void notifyInstant(int callbackId, int when) { @Override public void run() { - if (codec == null) { - try { - waitReady(); - } catch (InterruptedException ex) { - return; - } + listener.startedLoading(this); + + try { + load(); + } 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)); + return; + } + + if (seekTime != -1) { + codec.seek(seekTime); + seekTime = -1; } + listener.finishedLoading(this, metadata); + boolean canGetTime = true; while (!closed) { if (output == null) { @@ -256,6 +250,11 @@ public void run() { if (closed) return; + if (seekTime != -1) { + codec.seek(seekTime); + seekTime = -1; + } + if (canGetTime) { try { int time = codec.time(); @@ -263,7 +262,7 @@ public void run() { if (output == null) continue; - output.gain(crossfadeController.getGain(time)); + output.gain(crossfade.getGain(time)); } catch (Codec.CannotGetTimeException ex) { canGetTime = false; } @@ -274,27 +273,42 @@ public void run() { break; } catch (IOException | Codec.CodecException ex) { if (!closed) { - listener.playbackException(id, ex); close(); + listener.playbackError(this, ex); } return; } } - listener.playbackEnded(id); close(); + 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(id, callbackId, time); + 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; @@ -304,7 +318,7 @@ public void close() { @Override public void streamReadHalted(int chunk, long time) { playbackHaltedAt = time; - listener.playbackHalted(id, chunk); + listener.playbackHalted(this, chunk); } @Override @@ -312,67 +326,87 @@ public void streamReadResumed(int chunk, long time) { if (playbackHaltedAt == 0) return; int duration = (int) (time - playbackHaltedAt); - listener.playbackResumed(id, chunk, duration); + listener.playbackResumed(this, chunk, duration); } - boolean hasOutput() { - return output != null; + @Override + public String toString() { + return "PlayerQueueEntry{" + playbackId + "}"; } interface Listener { /** * An error occurred during playback. * - * @param id The entry ID - * @param ex The exception thrown + * @param entry The {@link PlayerQueueEntry} that called this + * @param ex The exception thrown */ - void playbackException(int id, @NotNull Exception ex); + void playbackError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex); /** * The playback of the current entry ended. * - * @param id The entry ID + * @param entry The {@link PlayerQueueEntry} that called this */ - void playbackEnded(int id); + void playbackEnded(@NotNull PlayerQueueEntry entry); /** * The playback halted while trying to receive a chunk. * - * @param id The entry ID + * @param entry The {@link PlayerQueueEntry} that called this * @param chunk The chunk that is being retrieved */ - void playbackHalted(int id, int chunk); + void playbackHalted(@NotNull PlayerQueueEntry entry, int chunk); /** * The playback resumed from halt. * - * @param id The entry ID + * @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(int id, int chunk, int diff); + 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 entryId The entry ID + * @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(int entryId, int callbackId, int exactTime); + void instantReached(@NotNull PlayerQueueEntry entry, int callbackId, int exactTime); /** * The track started loading. * - * @param id The entry ID + * @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 startedLoading(int id); + void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, boolean retried); /** * The track finished loading. * - * @param id The entry ID + * @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 */ - void finishedLoading(int id); + @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..ed862457 --- /dev/null +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java @@ -0,0 +1,374 @@ +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; + + 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()); + } + + /** + * Creates and adds a new entry to the queue. + * + * @param playable The content for the new entry + */ + private void add(@NotNull PlayableId playable) { + queue.add(new PlayerQueueEntry(session, sink.getFormat(), playable, this)); + } + + /** + * Adds the next content to the queue. + */ + private void addNext() { + PlayableId playable = listener.nextPlayableDoNotSet(); + if (playable != null) add(playable); + } + + /** + * Tries to advance to the given content. This is a destructive operation as it will close every entry that passes by. + * + * @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)) return true; + } while (queue.advance()); + return false; + } + + /** + * Gets the next content and tries to advance, notifying if successful. + */ + private void advance(@NotNull Reason reason) { + PlayableId next = listener.nextPlayable(); + if (next == null) + return; + + EntryWithPos entry = playInternal(next, 0, reason); + listener.trackChanged(entry.entry.playbackId, entry.entry.metadata(), entry.pos); + } + + @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) { + 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(); + executorService.execute(() -> { + queue.swap(entry, newEntry); + playInternal(newEntry.playable, 0, Reason.TRACK_ERROR); // FIXME: Use correct values + }); + return; + } + + listener.loadingError(ex); + } else if (entry == queue.next()) { + if (!(ex instanceof ContentRestrictedException) && !retried) { + PlayerQueueEntry newEntry = entry.retrySelf(); + 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); + } + + @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) { + if (!advanceTo(playable)) { + add(playable); + queue.advance(); + } + + PlayerQueueEntry head = queue.head(); + if (head == null) + throw new IllegalStateException(); + + if (head.prev != null) { + 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 fadeOut; + if (head.crossfade != null && (fadeOut = head.crossfade.selectFadeOut(Reason.TRACK_DONE)) != null) { + head.notifyInstant(PlayerQueueEntry.INSTANT_START_NEXT, fadeOut.start()); + } + + 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 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(); + } + + /** + * Close the session by clearing the queue which will close all entries. + */ + @Override + public void close() { + 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 + */ + void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode metadata, int pos); + } + + 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/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java b/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java deleted file mode 100644 index 7a3a6190..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/player/queue/PlayerQueue.java +++ /dev/null @@ -1,425 +0,0 @@ -package xyz.gianlu.librespot.player.queue; - -import org.apache.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.common.NameThreadFactory; -import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.player.ContentRestrictedException; -import xyz.gianlu.librespot.player.Player; -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.feeders.cdn.CdnManager; -import xyz.gianlu.librespot.player.mixing.AudioSink; -import xyz.gianlu.librespot.player.mixing.MixingLine; - -import javax.sound.sampled.AudioFormat; -import java.io.Closeable; -import java.io.IOException; -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 PlayerQueue implements Closeable, QueueEntry.@NotNull Listener { - static final int INSTANT_START_NEXT = 2; - static final int INSTANT_END_NOW = 3; - private static final int INSTANT_PRELOAD = 1; - private static final Logger LOGGER = Logger.getLogger(PlayerQueue.class); - private static final AtomicInteger IDS = new AtomicInteger(0); - private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory(r -> "player-queue-worker-" + r.hashCode())); - private final Session session; - private final Player.Configuration conf; - private final Listener listener; - private final Map entries = new HashMap<>(5); - private final AudioSink sink; - private int currentEntryId = -1; - private int nextEntryId = -1; - - public PlayerQueue(@NotNull Session session, @NotNull Player.Configuration conf, @NotNull Listener listener) { - this.session = session; - this.conf = conf; - this.listener = listener; - this.sink = new AudioSink(conf, listener); - } - - /** - * Resume the sink. - */ - public void resume() { - sink.resume(); - } - - /** - * Pause the sink - */ - public void pause(boolean release) { - if (sink.pause(release)) - LOGGER.info("Sink released line."); - } - - /** - * Set the volume for the sink. - * - * @param volume The volume value from 0 to {@link Player#VOLUME_MAX}, inclusive. - */ - public void setVolume(int volume) { - sink.setVolume(volume); - } - - /** - * Clear the queue, the outputs and close all entries. - */ - public void clear() { - currentEntryId = -1; - nextEntryId = -1; - entries.values().removeIf(entry -> { - entry.close(); - return true; - }); - - sink.clearOutputs(); - } - - /** - * Create an entry for the specified content and start loading it asynchronously. - * - * @param playable The content this entry will play. - * @return The entry ID - */ - public int load(@NotNull PlayableId playable, @NotNull Map metadata, int pos) { - int id = IDS.getAndIncrement(); - QueueEntry entry = new QueueEntry(id, playable, metadata, this); - executorService.execute(entry); - executorService.execute(() -> { - try { - entry.load(session, conf, sink.getFormat(), pos); - LOGGER.debug(String.format("Preloaded entry. {id: %d}", id)); - } catch (IOException | Codec.CodecException | MercuryClient.MercuryException | CdnManager.CdnException ex) { - LOGGER.error(String.format("Failed preloading entry. {id: %d}", id), ex); - listener.loadingError(id, playable, ex); - } catch (ContentRestrictedException ex) { - LOGGER.warn(String.format("Preloaded entry is content restricted. {id: %d}", id)); - entry.setContentRestricted(ex); - } - }); - - entries.put(id, entry); - LOGGER.debug(String.format("Created new entry. {id: %d, content: %s}", id, playable)); - return id; - } - - /** - * Seek the specified entry to the specified position. - * - * @param id The entry ID - * @param pos The time in milliseconds - */ - public void seek(int id, int pos) { - QueueEntry entry = entries.get(id); - if (entry == null) throw new IllegalArgumentException(); - - executorService.execute(() -> { - sink.flush(); - entry.seek(pos); - listener.finishedSeek(id, pos); - }); - } - - public void seekCurrent(int pos) { - seek(currentEntryId, pos); - } - - /** - * Specifies what's going to play next, will start immediately if there's no current entry. - * - * @param id The ID of the next entry - */ - public void follows(int id) { - if (!entries.containsKey(id)) - throw new IllegalArgumentException(); - - if (currentEntryId == -1) { - currentEntryId = id; - nextEntryId = -1; - start(id); - } else { - nextEntryId = id; - } - } - - /** - * Return metadata for the specified entry. - * - * @param id The entry ID - * @return The metadata for the track or episode - */ - @Nullable - public TrackOrEpisode metadata(int id) { - QueueEntry entry = entries.get(id); - if (entry == null) return null; - else return entry.metadata(); - } - - @Nullable - public TrackOrEpisode currentMetadata() { - return metadata(currentEntryId); - } - - /** - * Return the current position for the specified entry. - * - * @param id The entry ID - * @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. - */ - public int time(int id) throws Codec.CannotGetTimeException { - QueueEntry entry = entries.get(id); - if (entry == null) throw new IllegalArgumentException(); - - return entry.getTime(); - } - - public int currentTime() throws Codec.CannotGetTimeException { - if (currentEntryId == -1) return -1; - else return time(currentEntryId); - } - - @NotNull - public PlayerMetrics currentMetrics() { - QueueEntry entry = entries.get(currentEntryId); - if (entry == null) throw new IllegalStateException(); - - return entry.metrics(); - } - - /** - * @param playable The {@link PlayableId} - * @return Whether the given playable is the current entry. - */ - public boolean isCurrent(@NotNull PlayableId playable) { - QueueEntry entry = entries.get(currentEntryId); - if (entry == null) return false; - else return playable.equals(entry.playable); - } - - /** - * @param id The entry ID - * @return Whether the given ID is the current entry ID. - */ - public boolean isCurrent(int id) { - return id == currentEntryId; - } - - /** - * Close the queue by closing all entries and the sink. - */ - @Override - public void close() { - clear(); - sink.close(); - } - - private void start(int id) { - QueueEntry entry = entries.get(id); - if (entry == null) throw new IllegalStateException(); - if (entry.hasOutput()) { - entry.toggleOutput(true); - return; - } - - MixingLine.MixingOutput out = sink.someOutput(); - if (out == null) throw new IllegalStateException(); - - try { - entry.load(session, conf, sink.getFormat(), -1); - } catch (CdnManager.CdnException | IOException | MercuryClient.MercuryException | Codec.CodecException | ContentRestrictedException ex) { - listener.loadingError(id, entry.playable, ex); - } - - entry.setOutput(out); - entry.toggleOutput(true); - } - - @Override - public void playbackException(int id, @NotNull Exception ex) { - listener.playbackError(id, ex); - } - - @Override - public void playbackEnded(int id) { - if (id == currentEntryId) { - QueueEntry old = entries.remove(currentEntryId); - if (old != null) old.close(); - - if (nextEntryId != -1) { - currentEntryId = nextEntryId; - nextEntryId = -1; - start(currentEntryId); - listener.startedNextTrack(id, currentEntryId); - } else { - listener.endOfPlayback(id); - } - } - } - - @Override - public void playbackHalted(int id, int chunk) { - listener.playbackHalted(id, chunk); - } - - @Override - public void playbackResumed(int id, int chunk, int duration) { - listener.playbackResumedFromHalt(id, chunk, duration); - } - - @Override - public void instantReached(int entryId, int callbackId, int exact) { - switch (callbackId) { - case INSTANT_PRELOAD: - if (entryId == currentEntryId) - executorService.execute(() -> listener.preloadNextTrack(entryId)); - break; - case INSTANT_START_NEXT: - if (entryId == currentEntryId && nextEntryId != -1) - start(nextEntryId); - break; - case INSTANT_END_NOW: - QueueEntry entry = entries.remove(entryId); - if (entry != null) entry.close(); - break; - } - } - - @Override - public void startedLoading(int id) { - listener.startedLoading(id); - } - - @Override - public void finishedLoading(int id) { - listener.finishedLoading(id); - - QueueEntry entry = entries.get(id); - if (conf.preloadEnabled() || entry.crossfadeController.fadeInEnabled()) - entry.notifyInstantFromEnd(INSTANT_PRELOAD, (int) TimeUnit.SECONDS.toMillis(20) + entry.crossfadeController.fadeOutStartTimeFromEnd()); - } - - public interface Listener extends AudioSink.Listener { - /** - * The track started loading. - * - * @param id The entry ID - */ - void startedLoading(int id); - - /** - * The track finished loading. - * - * @param id The entry ID - */ - void finishedLoading(int id); - - /** - * The track failed loading. - * - * @param id The entry ID - * @param track The content playable - * @param ex The exception thrown - */ - void loadingError(int id, @NotNull PlayableId track, @NotNull Exception ex); - - /** - * The playback of the current entry finished if no following track was specified. - * - * @param id The entry ID - */ - void endOfPlayback(int id); - - /** - * The playback of the current entry finished the next track already started played and is now {@link PlayerQueue#currentEntryId}. - * - * @param id The entry ID - * @param next The ID of the next entry - */ - void startedNextTrack(int id, int next); - - /** - * Instruct the player that it should start preloading the next track. - * - * @param id The entry ID of the currently playing track - */ - void preloadNextTrack(int id); - - /** - * An error occurred during playback. - * - * @param id The entry ID - * @param ex The exception thrown - */ - void playbackError(int id, @NotNull Exception ex); - - /** - * The playback halted while trying to receive a chunk. - * - * @param id The entry ID - * @param chunk The chunk that is being retrieved - */ - void playbackHalted(int id, int chunk); - - /** - * The playback resumed from halt. - * - * @param id The entry ID - * @param chunk The chunk that was being retrieved - * @param diff The time taken to retrieve the chunk - */ - void playbackResumedFromHalt(int id, int chunk, long diff); - - /** - * The entry finished seeking. - * - * @param id The entry ID - * @param pos The seeked position - */ - void finishedSeek(int id, int pos); - } - - public static class PlayerMetrics { - public int decodedLength = 0; - public int size = 0; - public int bitrate = 0; - public int duration = 0; - public String encoding = null; - public int totalFade = 0; - public String transition = "none"; - - PlayerMetrics(@NotNull QueueEntry entry, @Nullable Codec codec) { - if (codec == null) return; - - size = codec.size(); - duration = codec.duration(); - decodedLength = codec.decodedLength(); - - AudioFormat format = codec.getAudioFormat(); - bitrate = (int) (format.getSampleRate() * format.getSampleSizeInBits()); - - if (codec instanceof VorbisCodec) encoding = "vorbis"; - else if (codec instanceof Mp3Codec) encoding = "mp3"; - - if (entry.crossfadeController.fadeInEnabled() || entry.crossfadeController.fadeOutEnabled()) { - transition = "crossfade"; - totalFade = entry.crossfadeController.fadeInDuration() + entry.crossfadeController.fadeOutDuration(); - } - } - } -} From 0198c4317b2035acffc207712cd127714d63379b Mon Sep 17 00:00:00 2001 From: Gianlu Date: Fri, 24 Apr 2020 18:27:17 +0200 Subject: [PATCH 21/32] Fixed crossfade not working on first track + fixed possible leaked output + minor logging changes --- .../librespot/api/handlers/PlayerHandler.java | 7 ++++++- .../java/xyz/gianlu/librespot/player/Player.java | 3 ++- .../xyz/gianlu/librespot/player/StateWrapper.java | 2 +- .../librespot/player/feeders/cdn/CdnManager.java | 7 +------ .../librespot/player/playback/PlayerQueue.java | 4 ++-- .../player/playback/PlayerQueueEntry.java | 14 ++++++++------ .../librespot/player/playback/PlayerSession.java | 9 ++++----- 7 files changed, 24 insertions(+), 22 deletions(-) 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 35e3d2e1..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 @@ -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()); 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 aa31509e..bf97e89b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -598,7 +598,8 @@ public byte[] currentCoverImage() throws IOException { } /** - * @return The current content in the state or {@code null} if not set. + * @return The current content in the state + * @throws IllegalStateException If there is no current content set */ @Override public @NotNull PlayableId currentPlayable() { 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 5bdcae4e..590d419a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -1073,7 +1073,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/feeders/cdn/CdnManager.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java index ebebe509..e31f84fd 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 @@ -122,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; @@ -256,7 +251,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); 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 index 42aaa39d..ee257156 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java @@ -66,7 +66,7 @@ synchronized void add(@NotNull PlayerQueueEntry entry) { } /** - * Swap two entries, closing the once that gets removed. + * Swap two entries, closing the old one in any case. * * @param oldEntry The old entry * @param newEntry The new entry @@ -84,8 +84,8 @@ synchronized void swap(@NotNull PlayerQueueEntry oldEntry, @NotNull PlayerQueueE swapped = head.swap(oldEntry, newEntry); } + oldEntry.close(); if (swapped) { - oldEntry.close(); executorService.execute(newEntry); LOGGER.trace(String.format("%s swapped with %s.", oldEntry, newEntry)); } 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 index 1565e591..e637fcec 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java @@ -113,7 +113,7 @@ private void load() throws IOException, Codec.CodecException, MercuryClient.Merc throw new UnsupportedEncodingException(stream.in.codec().toString()); } - LOGGER.trace(String.format("Loaded %s codec. {fileId: %s, format: %s, id: %s}", stream.in.codec(), stream.in.describe(), codec.getAudioFormat(), playbackId)); + LOGGER.trace(String.format("Loaded %s codec. {of: %s, format: %s, playbackId: %s}", stream.in.codec(), stream.in.describe(), codec.getAudioFormat(), playbackId)); } /** @@ -157,13 +157,15 @@ void seek(int pos) { } /** - * Sets the output. As soon as this method returns the entry will start playing. + * 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) return; - - if (this.output != null) - throw new IllegalStateException("Output is already set for " + this); + if (closed || this.output != null) { + output.clear(); + throw new IllegalStateException("Cannot set output for " + this); + } synchronized (playbackLock) { this.output = output; 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 index ed862457..34d8557e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java @@ -149,6 +149,10 @@ public void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, 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 @@ -216,11 +220,6 @@ public void playbackResumed(@NotNull PlayerQueueEntry entry, int chunk, int diff if (out == null) throw new IllegalStateException("No output is available for " + head); - CrossfadeController.FadeInterval fadeOut; - if (head.crossfade != null && (fadeOut = head.crossfade.selectFadeOut(Reason.TRACK_DONE)) != null) { - head.notifyInstant(PlayerQueueEntry.INSTANT_START_NEXT, fadeOut.start()); - } - CrossfadeController.FadeInterval fadeIn; if (head.crossfade != null && (fadeIn = head.crossfade.selectFadeIn(reason)) != null) { head.seek(pos = fadeIn.start()); From 73ca627a21a99665bdb6239cb8b5f4259a11ccd1 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Fri, 24 Apr 2020 21:11:34 +0200 Subject: [PATCH 22/32] Audio key metrics + preload flag + minor fixes --- .../gianlu/librespot/core/EventService.java | 30 +++--- .../xyz/gianlu/librespot/player/Player.java | 96 +++++++++++++------ .../gianlu/librespot/player/StateWrapper.java | 5 + .../player/crossfade/CrossfadeController.java | 8 +- .../player/feeders/PlayableContentFeeder.java | 61 ++++++++---- .../player/feeders/cdn/CdnFeedHelper.java | 21 ++-- .../player/playback/PlayerMetrics.java | 22 +++-- .../player/playback/PlayerQueueEntry.java | 21 ++-- .../player/playback/PlayerSession.java | 52 +++++++--- 9 files changed, 212 insertions(+), 104 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index a7a4f48c..bc5e85af 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -62,7 +62,7 @@ public void language(@NotNull String lang) { sendEvent(event); } - private void trackTransition(@NotNull EventService.PlaybackMetrics metrics) { + private void trackTransition(@NotNull PlaybackMetrics metrics) { int when = metrics.lastValue(); EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); @@ -77,20 +77,23 @@ private void trackTransition(@NotNull EventService.PlaybackMetrics metrics) { event.append('0' /* TODO: Encrypt latency */).append(String.valueOf(metrics.player.fadeOverlap)).append('0' /* FIXME */).append('0'); event.append(metrics.firstValue() == 0 ? '0' : '1').append(String.valueOf(metrics.firstValue())); event.append('0' /* TODO: Play latency */).append("-1" /* FIXME */).append("context"); - event.append("-1" /* TODO: Audio key sync time */).append('0').append('0' /* TODO: Prefetched audio key */).append('0').append('0' /* FIXME */).append('0'); + event.append(String.valueOf(metrics.player.contentMetrics.audioKeyTime)).append('0'); + event.append(metrics.player.contentMetrics.preloadedAudioKey ? '1' : '0').append('0').append('0' /* FIXME */).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(TimeProvider.currentTimeMillis())).append('0'); + 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").append("local").append("na").append("none"); sendEvent(event); } - public void trackPlayed(@NotNull EventService.PlaybackMetrics metrics) { - if (metrics.player == null) + public void trackPlayed(@NotNull PlaybackMetrics metrics) { + if (metrics.player == null || metrics.player.contentMetrics == null) { + LOGGER.warn("Did not send event because of missing metrics: " + metrics.playbackId); return; + } trackTransition(metrics); @@ -222,19 +225,24 @@ public static class PlaybackMetrics { public final PlayableId id; final List intervals = new ArrayList<>(10); final String playbackId; - String featureVersion = null; - String referrerIdentifier = null; - String contextUri = null; + final String featureVersion; + final String referrerIdentifier; + final String contextUri; + final long timestamp; PlayerMetrics player = null; - Interval lastInterval = null; Reason reasonStart = null; String sourceStart = null; Reason reasonEnd = null; String sourceEnd = null; + Interval lastInterval = null; - public PlaybackMetrics(@NotNull PlayableId id, @NotNull String playbackId) { + 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 @@ -302,7 +310,7 @@ String endedHow() { return reasonEnd == null ? null : reasonEnd.val; } - public void update(@NotNull PlayerMetrics playerMetrics) { + public void update(@Nullable PlayerMetrics playerMetrics) { player = playerMetrics; } 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 bf97e89b..7843e2c6 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -15,7 +15,6 @@ import xyz.gianlu.librespot.common.NameThreadFactory; import xyz.gianlu.librespot.connectstate.DeviceStateHandler; import xyz.gianlu.librespot.connectstate.DeviceStateHandler.PlayCommandHelper; -import xyz.gianlu.librespot.core.EventService; import xyz.gianlu.librespot.core.EventService.PlaybackMetrics; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; @@ -28,6 +27,7 @@ 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; @@ -44,7 +44,7 @@ /** * @author Gianlu */ -public class Player implements Closeable, DeviceStateHandler.Listener, PlayerSession.Listener, AudioSink.Listener { // TODO: Metrics +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())); @@ -55,6 +55,7 @@ public class Player implements Closeable, DeviceStateHandler.Listener, PlayerSes private StateWrapper state; private PlayerSession playerSession; private ScheduledFuture releaseLineFuture = null; + private PlaybackMetrics metrics = null; public Player(@NotNull Player.Configuration conf, @NotNull Session session) { this.conf = conf; @@ -146,10 +147,20 @@ public void load(@NotNull String uri, boolean play) { * * @param reason Why we entered this state */ - private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { + private void panicState(@Nullable PlaybackMetrics.Reason reason) { sink.pause(true); state.setState(false, false, false); state.updated(); + + if (reason == null) { + metrics = null; + } else if (metrics != null) { + metrics.endedHow(reason, null); + metrics.endInterval(state.getPosition()); + metrics.update(playerSession != null ? playerSession.currentMetrics() : null); + session.eventService().trackPlayed(metrics); + metrics = null; + } } /** @@ -159,7 +170,17 @@ private void panicState(@Nullable EventService.PlaybackMetrics.Reason reason) { * @param play Whether the playback should start immediately */ private void loadSession(@NotNull String sessionId, boolean play, boolean withSkip) { + TransitionInfo trans = TransitionInfo.contextChange(state, withSkip); + if (playerSession != null) { + if (metrics != null) { + metrics.endedHow(trans.endedReason, state.getPlayOrigin().getFeatureIdentifier()); + metrics.endInterval(trans.endedWhen); + metrics.update(playerSession.currentMetrics()); + session.eventService().trackPlayed(metrics); + metrics = null; + } + playerSession.close(); playerSession = null; } @@ -167,7 +188,7 @@ private void loadSession(@NotNull String sessionId, boolean play, boolean withSk playerSession = new PlayerSession(session, sink, sessionId, this); session.eventService().newSessionId(sessionId, state); - loadTrack(play, TransitionInfo.contextChange(state, withSkip)); + loadTrack(play, trans); } /** @@ -179,6 +200,14 @@ private void loadSession(@NotNull String sessionId, boolean play, boolean withSk * @param trans A {@link TransitionInfo} object containing information about this track change */ private void loadTrack(boolean play, @NotNull TransitionInfo trans) { + if (metrics != null) { + metrics.endedHow(trans.endedReason, state.getPlayOrigin().getFeatureIdentifier()); + metrics.endInterval(trans.endedWhen); + metrics.update(playerSession.currentMetrics()); + session.eventService().trackPlayed(metrics); + metrics = null; + } + String playbackId = playerSession.play(state.getCurrentPlayableOrThrow(), state.getPosition(), trans.startedReason); state.setPlaybackId(playbackId); session.eventService().newPlaybackId(state, playbackId); @@ -193,6 +222,10 @@ private void loadTrack(boolean play, @NotNull TransitionInfo trans) { if (play) events.playbackResumed(); else events.playbackPaused(); + metrics = new PlaybackMetrics(state.getCurrentPlayableOrThrow(), playbackId, state); + metrics.startedHow(trans.startedReason, state.getPlayOrigin().getFeatureIdentifier()); + metrics.startInterval(state.getPosition()); + if (releaseLineFuture != null) { releaseLineFuture.cancel(true); releaseLineFuture = null; @@ -307,6 +340,11 @@ private void handleSeek(int pos) { playerSession.seekCurrent(pos); state.setPosition(pos); events.seeked(pos); + + if (metrics != null) { + metrics.endInterval(state.getPosition()); + metrics.startInterval(pos); + } } private void handleResume() { @@ -507,15 +545,29 @@ public void playbackError(@NotNull Exception ex) { } @Override - public void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode metadata, int pos) { - session.eventService().newPlaybackId(state, playbackId); - + 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); + metrics = new PlaybackMetrics(state.getCurrentPlayableOrThrow(), playbackId, state); + metrics.startedHow(startedReason, state.getPlayOrigin().getFeatureIdentifier()); + metrics.startInterval(pos); + } + + @Override + public void trackPlayed(@NotNull PlaybackMetrics.Reason endReason, @NotNull PlayerMetrics playerMetrics, int willEndAt) { + if (metrics != null) { + metrics.endedHow(endReason, state.getPlayOrigin().getFeatureIdentifier()); + metrics.endInterval(willEndAt); + metrics.update(playerMetrics); + session.eventService().trackPlayed(metrics); + metrics = null; + } } @Override @@ -664,6 +716,14 @@ public long time() { @Override public void close() { + if (metrics != null && playerSession != null) { + metrics.endedHow(PlaybackMetrics.Reason.LOGOUT, state.getPlayOrigin().getFeatureIdentifier()); + metrics.endInterval(state.getPosition()); + metrics.update(playerSession.currentMetrics()); + session.eventService().trackPlayed(metrics); + metrics = null; + } + state.close(); events.listeners.clear(); @@ -750,7 +810,7 @@ private static class TransitionInfo { */ int endedWhen = -1; - private TransitionInfo(@NotNull EventService.PlaybackMetrics.Reason endedReason, @NotNull EventService.PlaybackMetrics.Reason startedReason) { + private TransitionInfo(@NotNull PlaybackMetrics.Reason endedReason, @NotNull PlaybackMetrics.Reason startedReason) { this.startedReason = startedReason; this.endedReason = endedReason; } @@ -785,16 +845,6 @@ static TransitionInfo skippedPrev(@NotNull StateWrapper state) { return trans; } - /** - * Proceeding to the next track without user intervention. - */ - @NotNull - static TransitionInfo next(@NotNull StateWrapper state) { - TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.TRACK_DONE, PlaybackMetrics.Reason.TRACK_DONE); - if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); - return trans; - } - /** * Skipping to next track. */ @@ -804,16 +854,6 @@ static TransitionInfo skippedNext(@NotNull StateWrapper state) { if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); return trans; } - - /** - * Skipping to next track due to an error. - */ - @NotNull - static TransitionInfo nextError(@NotNull StateWrapper state) { - TransitionInfo trans = new TransitionInfo(PlaybackMetrics.Reason.TRACK_ERROR, PlaybackMetrics.Reason.TRACK_ERROR); - if (state.getCurrentPlayable() != null) trans.endedWhen = state.getPosition(); - return trans; - } } private static class MetadataPipe { 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 590d419a..7130e537 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -761,6 +761,11 @@ public void setPlaybackId(@NotNull String playbackId) { state.setPlaybackId(playbackId); } + @NotNull + public PlayOrigin getPlayOrigin() { + return state.getPlayOrigin(); + } + @Override public void close() { session.dealer().removeMessageListener(this); 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 d21ce942..3ebae0e6 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 @@ -16,7 +16,6 @@ public class CrossfadeController { private static final Logger LOGGER = Logger.getLogger(CrossfadeController.class); private final String playbackId; private final int trackDuration; - private final String fadeOutUri; private final Map fadeOutMap = new HashMap<>(8); private final Map fadeInMap = new HashMap<>(8); private final int defaultFadeDuration; @@ -30,7 +29,7 @@ public CrossfadeController(@NotNull String playbackId, int duration, @NotNull Ma this.playbackId = playbackId; trackDuration = duration; defaultFadeDuration = conf.crossfadeDuration(); - fadeOutUri = metadata.get("audio.fade_out_uri"); + // Didn't ever find an use for "audio.fade_out_uri" populateFadeIn(metadata); populateFadeOut(metadata); @@ -166,11 +165,6 @@ public FadeInterval selectFadeOut(@NotNull Reason reason) { return fadeOut; } - @Nullable - public String fadeOutUri() { - return fadeOutUri; // TODO: Is this useful? - } - /** * @return The first (scheduled) fade out start time. */ 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 a2429b93..0e0a70a6 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 @@ -32,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) { @@ -55,15 +57,16 @@ 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); + 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 PlayableId: " + 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(); @@ -73,7 +76,7 @@ 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) { @@ -84,32 +87,32 @@ private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fil throw new FeederException(); } - return loadTrack(track, audioQualityPreference, haltListener); + return loadTrack(track, audioQualityPreference, preload, haltListener); } @NotNull - @Contract("_, null, null, _, _ -> fail") - 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); + if (track != null) return CdnFeedHelper.loadTrack(session, track, file, url, preload, haltListener); else return CdnFeedHelper.loadEpisode(session, episode, file, url, haltListener); } @NotNull - @Contract("_, null, null, _ -> fail") - private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Metadata.Track track, @Nullable Metadata.Episode episode, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { + @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()); + StorageResolveResponse resp = resolveStorageInteractive(file.getFileId(), preload); switch (resp.getResult()) { case CDN: - if (track != null) return CdnFeedHelper.loadTrack(session, track, file, resp, haltListener); + if (track != null) return CdnFeedHelper.loadTrack(session, track, file, resp, preload, haltListener); else return CdnFeedHelper.loadEpisode(session, episode, file, resp, haltListener); case STORAGE: try { @@ -117,7 +120,7 @@ private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Meta else return StorageFeedHelper.loadEpisode(session, episode, file, 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!"); @@ -129,18 +132,18 @@ private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Meta } @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()) { @@ -152,7 +155,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); } } @@ -161,22 +164,38 @@ 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 boolean preloadedAudioKey; + public final int audioKeyTime; + + public Metrics(boolean preloadedAudioKey, int audioKeyTime) { // TODO: Check values + 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/feeders/cdn/CdnFeedHelper.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java index ac021176..61a18508 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 @@ -12,6 +12,7 @@ import xyz.gianlu.librespot.core.Session; 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(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(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(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/playback/PlayerMetrics.java b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java index 772d0d72..95186baa 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java @@ -5,6 +5,7 @@ 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; @@ -12,6 +13,7 @@ * @author devgianlu */ public final class PlayerMetrics { + public final PlayableContentFeeder.Metrics contentMetrics; public int decodedLength = 0; public int size = 0; public int bitrate = 0; @@ -20,18 +22,20 @@ public final class PlayerMetrics { public int fadeOverlap = 0; public String transition = "none"; - PlayerMetrics(@Nullable CrossfadeController crossfade, @Nullable Codec codec) { - if (codec == null) return; + PlayerMetrics(@Nullable PlayableContentFeeder.Metrics contentMetrics, @Nullable CrossfadeController crossfade, @Nullable Codec codec) { + this.contentMetrics = contentMetrics; - size = codec.size(); - duration = codec.duration(); - decodedLength = codec.decodedLength(); + if (codec != null) { + size = codec.size(); + duration = codec.duration(); + decodedLength = codec.decodedLength(); - AudioFormat format = codec.getAudioFormat(); - bitrate = (int) (format.getSampleRate() * format.getSampleSizeInBits()); + AudioFormat format = codec.getAudioFormat(); + bitrate = (int) (format.getFrameRate() * format.getFrameSize()); - if (codec instanceof VorbisCodec) encoding = "vorbis"; - else if (codec instanceof Mp3Codec) encoding = "mp3"; + if (codec instanceof VorbisCodec) encoding = "vorbis"; + else if (codec instanceof Mp3Codec) encoding = "mp3"; + } if (crossfade != null) { transition = "crossfade"; 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 index e637fcec..43848339 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java @@ -45,6 +45,7 @@ class PlayerQueueEntry extends PlayerQueue.Entry implements Closeable, Runnable, 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)); @@ -58,22 +59,24 @@ class PlayerQueueEntry extends PlayerQueue.Entry implements Closeable, Runnable, 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, @NotNull Listener listener) { + 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() { + PlayerQueueEntry retrySelf(boolean preloaded) { if (retried) throw new IllegalStateException(); - PlayerQueueEntry retry = new PlayerQueueEntry(session, format, playable, listener); + PlayerQueueEntry retry = new PlayerQueueEntry(session, format, playable, preloaded, listener); retry.retried = true; return retry; } @@ -83,9 +86,10 @@ PlayerQueueEntry retrySelf() { * * @throws ContentRestrictedException If the content cannot be retrieved because of restrictions (this condition won't change with a retry). */ - private void load() throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { - PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(session.conf().preferredQuality()), this); + 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)); @@ -133,7 +137,7 @@ public TrackOrEpisode metadata() { */ @NotNull PlayerMetrics metrics() { - return new PlayerMetrics(crossfade, codec); + return new PlayerMetrics(contentMetrics, crossfade, codec); } /** @@ -221,7 +225,7 @@ public void run() { listener.startedLoading(this); try { - load(); + load(preloaded); } catch (IOException | ContentRestrictedException | CdnManager.CdnException | MercuryClient.MercuryException | Codec.CodecException ex) { close(); listener.loadingError(this, ex, retried); @@ -250,7 +254,7 @@ public void run() { if (output == null) continue; } - if (closed) return; + if (closed) break; if (seekTime != -1) { codec.seek(seekTime); @@ -283,7 +287,6 @@ public void run() { } } - close(); listener.playbackEnded(this); LOGGER.trace(String.format("%s terminated.", this)); } 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 index 34d8557e..0b637627 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java @@ -34,6 +34,8 @@ public class PlayerSession implements Closeable, PlayerQueueEntry.Listener { private final String sessionId; private final Listener listener; private final PlayerQueue queue; + private int lastPlayPos = 0; + private Reason lastPlayReason = null; public PlayerSession(@NotNull Session session, @NotNull AudioSink sink, @NotNull String sessionId, @NotNull Listener listener) { this.session = session; @@ -44,7 +46,7 @@ public PlayerSession(@NotNull Session session, @NotNull AudioSink sink, @NotNull LOGGER.info(String.format("Created new session. {id: %s}", sessionId)); sink.clearOutputs(); - add(listener.currentPlayable()); + add(listener.currentPlayable(), false); } /** @@ -52,8 +54,8 @@ public PlayerSession(@NotNull Session session, @NotNull AudioSink sink, @NotNull * * @param playable The content for the new entry */ - private void add(@NotNull PlayableId playable) { - queue.add(new PlayerQueueEntry(session, sink.getFormat(), playable, this)); + private void add(@NotNull PlayableId playable, boolean preloaded) { + queue.add(new PlayerQueueEntry(session, sink.getFormat(), playable, preloaded, this)); } /** @@ -61,7 +63,7 @@ private void add(@NotNull PlayableId playable) { */ private void addNext() { PlayableId playable = listener.nextPlayableDoNotSet(); - if (playable != null) add(playable); + if (playable != null) add(playable, true); } /** @@ -83,12 +85,14 @@ private boolean advanceTo(@NotNull PlayableId id) { * Gets the next content and tries to advance, notifying if successful. */ private void advance(@NotNull Reason reason) { + // TODO: Call #trackPlayed somewhere!! + PlayableId next = listener.nextPlayable(); if (next == null) return; EntryWithPos entry = playInternal(next, 0, reason); - listener.trackChanged(entry.entry.playbackId, entry.entry.metadata(), entry.pos); + listener.trackChanged(entry.entry.playbackId, entry.entry.metadata(), entry.pos, reason); } @Override @@ -125,10 +129,10 @@ public void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, if (ex instanceof ContentRestrictedException) { advance(Reason.TRACK_ERROR); } else if (!retried) { - PlayerQueueEntry newEntry = entry.retrySelf(); + PlayerQueueEntry newEntry = entry.retrySelf(false); executorService.execute(() -> { queue.swap(entry, newEntry); - playInternal(newEntry.playable, 0, Reason.TRACK_ERROR); // FIXME: Use correct values + playInternal(newEntry.playable, lastPlayPos, lastPlayReason == null ? Reason.TRACK_ERROR : lastPlayReason); }); return; } @@ -136,7 +140,7 @@ public void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, listener.loadingError(ex); } else if (entry == queue.next()) { if (!(ex instanceof ContentRestrictedException) && !retried) { - PlayerQueueEntry newEntry = entry.retrySelf(); + PlayerQueueEntry newEntry = entry.retrySelf(true); executorService.execute(() -> queue.swap(entry, newEntry)); return; } @@ -176,6 +180,7 @@ public void playbackResumed(@NotNull PlayerQueueEntry entry, int chunk, int diff if (entry == queue.head()) listener.playbackResumedFromHalt(chunk, diff); } + // ================================ // // =========== Playback =========== // // ================================ // @@ -189,8 +194,11 @@ public void playbackResumed(@NotNull PlayerQueueEntry entry, int chunk, int diff */ @Contract("_, _, _ -> new") private @NotNull EntryWithPos playInternal(@NotNull PlayableId playable, int pos, @NotNull Reason reason) { + lastPlayPos = pos; + lastPlayReason = reason; + if (!advanceTo(playable)) { - add(playable); + add(playable, false); queue.advance(); } @@ -265,6 +273,15 @@ public void seekCurrent(int pos) { // =========== 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. */ @@ -354,11 +371,20 @@ public interface Listener { /** * 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 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 endReason The reason why this track ended + * @param playerMetrics The {@link PlayerMetrics} for this entry */ - void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode metadata, int pos); + void trackPlayed(@NotNull Reason endReason, @NotNull PlayerMetrics playerMetrics, int endedAt); } private static class EntryWithPos { From b78cd68f65707c49182df5792309508229802374 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sat, 25 Apr 2020 10:42:11 +0200 Subject: [PATCH 23/32] Fixed build --- .../player/feeders/PlayableContentFeeder.java | 4 ++-- .../player/feeders/storage/StorageFeedHelper.java | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) 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 0e0a70a6..4573d209 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 @@ -116,8 +116,8 @@ private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Meta else return CdnFeedHelper.loadEpisode(session, episode, file, resp, haltListener); case STORAGE: try { - if (track != null) return StorageFeedHelper.loadTrack(session, track, file, haltListener); - else return StorageFeedHelper.loadEpisode(session, episode, file, haltListener); + 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, preload, haltListener); 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 61ec716a..2032d8c8 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 @@ -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(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(preload, preload ? -1 : audioKeyTime)); } } From ec72d05a9b8bec985fb32a40ac7cd51449d6dcdc Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sat, 25 Apr 2020 10:43:23 +0200 Subject: [PATCH 24/32] Removed default fade to respect original client behaviour --- .../player/crossfade/CrossfadeController.java | 10 ---------- 1 file changed, 10 deletions(-) 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 3ebae0e6..0f2d1ac4 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 @@ -62,15 +62,11 @@ else if (defaultFadeDuration > 0) 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())); - else if (defaultFadeDuration > 0) - fadeInMap.put(Reason.FORWARD_BTN, new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator())); 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())); - else if (defaultFadeDuration > 0) - fadeInMap.put(Reason.BACK_BTN, new FadeInterval(0, defaultFadeDuration, new LinearIncreasingInterpolator())); } private void populateFadeOut(@NotNull Map metadata) { @@ -81,21 +77,15 @@ private void populateFadeOut(@NotNull Map metadata) { 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())); 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())); - else if (defaultFadeDuration > 0) - fadeOutMap.put(Reason.BACK_BTN, new PartialFadeInterval(defaultFadeDuration, 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())); - else if (defaultFadeDuration > 0) - fadeOutMap.put(Reason.FORWARD_BTN, new PartialFadeInterval(defaultFadeDuration, new LinearDecreasingInterpolator())); } /** From 8f134bc243893e6f76ab614f1b35cf6fd882e7c3 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sat, 25 Apr 2020 14:59:42 +0200 Subject: [PATCH 25/32] Fixed crossfade issue + added logging for time offset + send metrics --- .../xyz/gianlu/librespot/player/Player.java | 89 +++++++++---------- .../player/crossfade/CrossfadeController.java | 2 + .../player/feeders/PlayableContentFeeder.java | 12 +-- .../player/playback/PlayerQueueEntry.java | 31 ++++++- .../player/playback/PlayerSession.java | 20 ++++- 5 files changed, 94 insertions(+), 60 deletions(-) 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 7843e2c6..851ca267 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -35,10 +35,7 @@ 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.*; /** @@ -55,7 +52,7 @@ public class Player implements Closeable, DeviceStateHandler.Listener, PlayerSes private StateWrapper state; private PlayerSession playerSession; private ScheduledFuture releaseLineFuture = null; - private PlaybackMetrics metrics = null; + private Map metrics = new HashMap<>(5); public Player(@NotNull Player.Configuration conf, @NotNull Session session) { this.conf = conf; @@ -154,12 +151,8 @@ private void panicState(@Nullable PlaybackMetrics.Reason reason) { if (reason == null) { metrics = null; - } else if (metrics != null) { - metrics.endedHow(reason, null); - metrics.endInterval(state.getPosition()); - metrics.update(playerSession != null ? playerSession.currentMetrics() : null); - session.eventService().trackPlayed(metrics); - metrics = null; + } else if (playerSession != null) { + endMetrics(playerSession.currentPlaybackId(), reason, playerSession.currentMetrics(), state.getPosition()); } } @@ -173,13 +166,7 @@ private void loadSession(@NotNull String sessionId, boolean play, boolean withSk TransitionInfo trans = TransitionInfo.contextChange(state, withSkip); if (playerSession != null) { - if (metrics != null) { - metrics.endedHow(trans.endedReason, state.getPlayOrigin().getFeatureIdentifier()); - metrics.endInterval(trans.endedWhen); - metrics.update(playerSession.currentMetrics()); - session.eventService().trackPlayed(metrics); - metrics = null; - } + endMetrics(playerSession.currentPlaybackId(), trans.endedReason, playerSession.currentMetrics(), trans.endedWhen); playerSession.close(); playerSession = null; @@ -200,13 +187,7 @@ private void loadSession(@NotNull String sessionId, boolean play, boolean withSk * @param trans A {@link TransitionInfo} object containing information about this track change */ private void loadTrack(boolean play, @NotNull TransitionInfo trans) { - if (metrics != null) { - metrics.endedHow(trans.endedReason, state.getPlayOrigin().getFeatureIdentifier()); - metrics.endInterval(trans.endedWhen); - metrics.update(playerSession.currentMetrics()); - session.eventService().trackPlayed(metrics); - metrics = null; - } + endMetrics(playerSession.currentPlaybackId(), trans.endedReason, playerSession.currentMetrics(), trans.endedWhen); String playbackId = playerSession.play(state.getCurrentPlayableOrThrow(), state.getPosition(), trans.startedReason); state.setPlaybackId(playbackId); @@ -222,9 +203,7 @@ private void loadTrack(boolean play, @NotNull TransitionInfo trans) { if (play) events.playbackResumed(); else events.playbackPaused(); - metrics = new PlaybackMetrics(state.getCurrentPlayableOrThrow(), playbackId, state); - metrics.startedHow(trans.startedReason, state.getPlayOrigin().getFeatureIdentifier()); - metrics.startInterval(state.getPosition()); + startMetrics(playbackId, trans.startedReason, state.getPosition()); if (releaseLineFuture != null) { releaseLineFuture.cancel(true); @@ -341,9 +320,10 @@ private void handleSeek(int pos) { state.setPosition(pos); events.seeked(pos); - if (metrics != null) { - metrics.endInterval(state.getPosition()); - metrics.startInterval(pos); + PlaybackMetrics pm = metrics.get(playerSession.currentPlaybackId()); + if (pm != null) { + pm.endInterval(state.getPosition()); + pm.startInterval(pos); } } @@ -497,6 +477,30 @@ private void loadAutoplay() { } + // ================================ // + // =========== 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); + } + + // ================================ // // ======== Player events ========= // // ================================ // @@ -554,20 +558,12 @@ public void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode me events.trackChanged(); session.eventService().newPlaybackId(state, playbackId); - metrics = new PlaybackMetrics(state.getCurrentPlayableOrThrow(), playbackId, state); - metrics.startedHow(startedReason, state.getPlayOrigin().getFeatureIdentifier()); - metrics.startInterval(pos); + startMetrics(playbackId, startedReason, pos); } @Override - public void trackPlayed(@NotNull PlaybackMetrics.Reason endReason, @NotNull PlayerMetrics playerMetrics, int willEndAt) { - if (metrics != null) { - metrics.endedHow(endReason, state.getPlayOrigin().getFeatureIdentifier()); - metrics.endInterval(willEndAt); - metrics.update(playerMetrics); - session.eventService().trackPlayed(metrics); - metrics = null; - } + public void trackPlayed(@NotNull String playbackId, @NotNull PlaybackMetrics.Reason endReason, @NotNull PlayerMetrics playerMetrics, int when) { + endMetrics(playbackId, endReason, playerMetrics, when); } @Override @@ -716,13 +712,8 @@ public long time() { @Override public void close() { - if (metrics != null && playerSession != null) { - metrics.endedHow(PlaybackMetrics.Reason.LOGOUT, state.getPlayOrigin().getFeatureIdentifier()); - metrics.endInterval(state.getPosition()); - metrics.update(playerSession.currentMetrics()); - session.eventService().trackPlayed(metrics); - metrics = null; - } + if (playerSession != null) + endMetrics(playerSession.currentPlaybackId(), PlaybackMetrics.Reason.LOGOUT, playerSession.currentMetrics(), state.getPosition()); state.close(); events.listeners.clear(); 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 0f2d1ac4..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 @@ -77,6 +77,8 @@ private void populateFadeOut(@NotNull Map metadata) { 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())); int backFadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.backbtn.fade_out_duration", "-1")); 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 4573d209..69907d97 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 @@ -33,7 +33,7 @@ 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"; + private static final String STORAGE_RESOLVE_INTERACTIVE_PREFETCH = "/storage-resolve/files/audio/interactive_prefetch/%s"; protected final Session session; public PlayableContentFeeder(@NotNull Session session) { @@ -58,10 +58,12 @@ private static Metadata.Track pickAlternativeIfNecessary(@NotNull Metadata.Track @NotNull 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); + 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 PlayableId: " + id); + else + throw new IllegalArgumentException("Unknown content: " + id); } @NotNull @@ -83,7 +85,7 @@ private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fil 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(); } @@ -187,7 +189,7 @@ public static class Metrics { public final boolean preloadedAudioKey; public final int audioKeyTime; - public Metrics(boolean preloadedAudioKey, int audioKeyTime) { // TODO: Check values + public Metrics(boolean preloadedAudioKey, int audioKeyTime) { this.preloadedAudioKey = preloadedAudioKey; this.audioKeyTime = audioKeyTime; 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 index 43848339..170f8290 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java @@ -5,6 +5,7 @@ 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; @@ -52,6 +53,7 @@ class PlayerQueueEntry extends PlayerQueue.Entry implements Closeable, Runnable, 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; @@ -150,6 +152,20 @@ 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. * @@ -229,7 +245,7 @@ public void run() { } 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)); + LOGGER.trace(String.format("%s terminated at loading.", this), ex); return; } @@ -275,15 +291,24 @@ public void run() { } try { - if (codec.writeSomeTo(output.stream()) == -1) + 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; } - return; + break; } } 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 index 0b637627..31df1ace 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java @@ -36,6 +36,7 @@ public class PlayerSession implements Closeable, PlayerQueueEntry.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; @@ -85,7 +86,7 @@ private boolean advanceTo(@NotNull PlayableId id) { * Gets the next content and tries to advance, notifying if successful. */ private void advance(@NotNull Reason reason) { - // TODO: Call #trackPlayed somewhere!! + if (closed) return; PlayableId next = listener.nextPlayable(); if (next == null) @@ -114,7 +115,10 @@ public void instantReached(@NotNull PlayerQueueEntry entry, int callbackId, int @Override public void playbackEnded(@NotNull PlayerQueueEntry entry) { - if (entry == queue.head()) advance(Reason.TRACK_DONE); + listener.trackPlayed(entry.playbackId, entry.endReason, entry.metrics(), entry.getTimeNoThrow()); + + if (entry == queue.head()) + advance(Reason.TRACK_DONE); } @Override @@ -207,6 +211,7 @@ public void playbackResumed(@NotNull PlayerQueueEntry entry, int chunk, int diff 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(); @@ -300,11 +305,18 @@ public long currentTime() throws Codec.CannotGetTimeException { 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(); } @@ -381,10 +393,12 @@ public interface Listener { /** * 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 Reason endReason, @NotNull PlayerMetrics playerMetrics, int endedAt); + void trackPlayed(@NotNull String playbackId, @NotNull Reason endReason, @NotNull PlayerMetrics playerMetrics, int endedAt); } private static class EntryWithPos { From 65b11c2d00ebd1c0a0e39026c9705cc75a725097 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sun, 26 Apr 2020 18:46:15 +0200 Subject: [PATCH 26/32] Fixed memory leak --- .../xyz/gianlu/librespot/player/playback/PlayerQueue.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index ee257156..01066f83 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java @@ -122,7 +122,8 @@ synchronized boolean advance() { return false; PlayerQueueEntry tmp = head.next; - head.next = null; // Avoid garbage collection issues + head.next = null; + head.prev = null; if (!head.closeIfUseless()) tmp.prev = head; head = tmp; return true; @@ -180,11 +181,13 @@ void clear() { if (prev != null) { prev.clear(); prev.close(); + prev = null; } if (next != null) { next.clear(); next.close(); + next = null; } ((PlayerQueueEntry) this).close(); From 5baafe9fb4e982ae0f699d28d5d82387031c1931 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Sun, 26 Apr 2020 18:47:25 +0200 Subject: [PATCH 27/32] Fixed cache failing if first chunk is missing --- .../player/feeders/cdn/CdnManager.java | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) 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 e31f84fd..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 @@ -206,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) @@ -216,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]; From eb3b9548399ab5ef7ade38925992e55a3f40fcca Mon Sep 17 00:00:00 2001 From: devgianlu Date: Sun, 26 Apr 2020 22:11:53 +0200 Subject: [PATCH 28/32] Await termination of EventService to sends all events --- .../main/java/xyz/gianlu/librespot/core/EventService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index bc5e85af..f65ec30b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; /** * @author Gianlu @@ -152,6 +153,11 @@ public void fetchedFileId(@NotNull PlayableId id, @NotNull Metadata.AudioFile fi @Override public void close() { asyncWorker.close(); + + try { + asyncWorker.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } } private enum Type { From 5fea1ab9fda66520c0b27642598853304d17a0a0 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 27 Apr 2020 18:04:52 +0200 Subject: [PATCH 29/32] Fixed repeating track + added 0x82 packet --- .../main/java/xyz/gianlu/librespot/core/EventService.java | 8 ++++++++ core/src/main/java/xyz/gianlu/librespot/core/Session.java | 2 +- .../src/main/java/xyz/gianlu/librespot/crypto/Packet.java | 1 + .../gianlu/librespot/player/playback/PlayerSession.java | 7 ++++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index f65ec30b..32c0c427 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -6,6 +6,7 @@ import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.common.AsyncWorker; import xyz.gianlu.librespot.common.Utils; +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; @@ -15,6 +16,7 @@ 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; @@ -66,6 +68,12 @@ public void language(@NotNull String lang) { private void trackTransition(@NotNull PlaybackMetrics metrics) { 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()); 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 db8d69f7..2b5cde28 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -514,7 +514,7 @@ public void send(Packet.Type cmd, byte[] payload) throws IOException { try { authLock.wait(); } catch (InterruptedException ex) { - throw new IllegalStateException(ex); + return; } } 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/player/playback/PlayerSession.java b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java index 31df1ace..1e42a3db 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java @@ -69,6 +69,7 @@ private void addNext() { /** * 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 @@ -77,7 +78,11 @@ private boolean advanceTo(@NotNull PlayableId id) { do { PlayerQueueEntry entry = queue.head(); if (entry == null) return false; - if (entry.playable.equals(id)) return true; + if (entry.playable.equals(id)) { + PlayerQueueEntry next = queue.next(); + if (next == null || !next.playable.equals(id)) + return true; + } } while (queue.advance()); return false; } From 2985888948501c3c077ee63739b6dd3da7c97f1a Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 27 Apr 2020 22:06:28 +0200 Subject: [PATCH 30/32] CDN request + remote device ID --- .../connectstate/DeviceStateHandler.java | 5 +++ .../gianlu/librespot/core/EventService.java | 39 ++++++++++++++----- .../xyz/gianlu/librespot/player/Player.java | 2 +- .../gianlu/librespot/player/StateWrapper.java | 5 +++ .../player/feeders/PlayableContentFeeder.java | 4 +- .../player/feeders/cdn/CdnFeedHelper.java | 6 +-- .../feeders/storage/StorageFeedHelper.java | 4 +- 7 files changed, 48 insertions(+), 17 deletions(-) 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 388f572a..7011b4c9 100644 --- a/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java +++ b/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java @@ -175,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(); } diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index 32c0c427..010ca7ad 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -6,6 +6,7 @@ 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; @@ -65,7 +66,7 @@ public void language(@NotNull String lang) { sendEvent(event); } - private void trackTransition(@NotNull PlaybackMetrics metrics) { + private void trackTransition(@NotNull PlaybackMetrics metrics, @NotNull DeviceStateHandler device) { int when = metrics.lastValue(); try { @@ -94,22 +95,37 @@ private void trackTransition(@NotNull PlaybackMetrics metrics) { 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").append("local").append("na").append("none"); + 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) { + 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); + trackTransition(metrics, device); - EventBuilder event = new EventBuilder(Type.TRACK_PLAYED); - event.append(metrics.playbackId).append(metrics.id.toSpotifyUri()); - event.append('0').append(metrics.intervalsToSend()); + + 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); } /** @@ -170,7 +186,8 @@ public void close() { 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"); + 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; @@ -329,8 +346,10 @@ public void update(@Nullable PlayerMetrics 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"); + 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; 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 851ca267..647d0846 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -497,7 +497,7 @@ private void endMetrics(String playbackId, @NotNull PlaybackMetrics.Reason reaso pm.endedHow(reason, state.getPlayOrigin().getFeatureIdentifier()); pm.endInterval(when); pm.update(playerMetrics); - session.eventService().trackPlayed(pm); + session.eventService().trackPlayed(pm, state.device()); } 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 7130e537..c2758907 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -172,6 +172,11 @@ void setRepeatingTrack(boolean value) { state.getOptionsBuilder().setRepeatingTrack(value && context.restrictions.can(Action.REPEAT_TRACK)); } + @NotNull + public DeviceStateHandler device() { + return device; + } + @Nullable public String getContextUri() { return state.getContextUri(); 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 69907d97..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 @@ -186,10 +186,12 @@ public LoadedStream(@NotNull Metadata.Episode episode, @NotNull GeneralAudioStre } public static class Metrics { + public final String fileId; public final boolean preloadedAudioKey; public final int audioKeyTime; - public Metrics(boolean preloadedAudioKey, 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; 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 61a18508..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 @@ -42,7 +42,7 @@ private static HttpUrl getUrl(@NotNull Session session, @NotNull StorageResolveR 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, new PlayableContentFeeder.Metrics(preload, preload ? -1 : audioKeyTime)); + 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, @@ -61,7 +61,7 @@ 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, new PlayableContentFeeder.Metrics(false, -1)); + return new LoadedStream(episode, streamer, null, new PlayableContentFeeder.Metrics(null, false, -1)); } } @@ -74,7 +74,7 @@ private static HttpUrl getUrl(@NotNull Session session, @NotNull StorageResolveR 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, new PlayableContentFeeder.Metrics(false, audioKeyTime)); + 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/storage/StorageFeedHelper.java b/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java index 2032d8c8..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 @@ -34,7 +34,7 @@ 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, new PlayableContentFeeder.Metrics(preload, preload ? -1 : audioKeyTime)); + 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, boolean preload, @Nullable HaltListener haltListener) throws IOException { @@ -49,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, new PlayableContentFeeder.Metrics(preload, preload ? -1 : audioKeyTime)); + return new PlayableContentFeeder.LoadedStream(episode, stream, normalizationData, new PlayableContentFeeder.Metrics(file.getFileId(), preload, preload ? -1 : audioKeyTime)); } } From 34365a48e9b77d053f945f6424591bee1be4d59e Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 27 Apr 2020 22:06:54 +0200 Subject: [PATCH 31/32] Fixed IllegalStateException when output has been already cleared --- .../gianlu/librespot/player/mixing/MixingLine.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 75f9693a..d571e6a2 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 @@ -133,21 +133,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; @@ -176,21 +176,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; From 190aa07b4ed5a2b45628c5fd098d2dcfe5f1bb52 Mon Sep 17 00:00:00 2001 From: Gianlu Date: Mon, 27 Apr 2020 22:15:42 +0200 Subject: [PATCH 32/32] Clean up --- .../gianlu/librespot/core/EventService.java | 8 +++--- .../librespot/mercury/RawMercuryRequest.java | 2 +- .../player/mixing/CircularBuffer.java | 28 +++++++++---------- .../mixing/GainAwareCircularBuffer.java | 12 +++----- .../librespot/player/mixing/MixingLine.java | 7 ++--- .../gianlu/librespot/CircularBufferTest.java | 8 ++---- 6 files changed, 28 insertions(+), 37 deletions(-) diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java index 010ca7ad..c6f06b9e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -84,11 +84,11 @@ private void trackTransition(@NotNull PlaybackMetrics metrics, @NotNull DeviceSt 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' /* TODO: Encrypt latency */).append(String.valueOf(metrics.player.fadeOverlap)).append('0' /* FIXME */).append('0'); + 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' /* TODO: Play latency */).append("-1" /* FIXME */).append("context"); + 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' /* FIXME */).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); @@ -170,7 +170,7 @@ public void fetchedFileId(@NotNull PlayableId id, @NotNull Metadata.AudioFile fi event.append('2').append('2'); event.append(Utils.bytesToHex(file.getFileId()).toLowerCase()); event.append(id.toSpotifyUri()); - event.append('1').append('2').append('2'); // FIXME + event.append('1').append('2').append('2'); sendEvent(event); } 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/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 d571e6a2..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 @@ -6,7 +6,6 @@ import xyz.gianlu.librespot.player.codecs.Codec; import javax.sound.sampled.AudioFormat; -import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -39,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); @@ -95,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(); @@ -116,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); } 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());