All Downloads are FREE. Search and download functionalities are using the official Maven repository.

xyz.gianlu.librespot.player.crossfade.CrossfadeController Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2021 devgianlu
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package xyz.gianlu.librespot.player.crossfade;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import xyz.gianlu.librespot.metadata.PlayableId;
import xyz.gianlu.librespot.player.PlayerConfiguration;
import xyz.gianlu.librespot.player.metrics.PlaybackMetrics.Reason;

import java.util.HashMap;
import java.util.Map;

public class CrossfadeController {
    private static final Logger LOGGER = LoggerFactory.getLogger(CrossfadeController.class);
    private final String playbackId;
    private final int trackDuration;
    private final Map fadeOutMap = new HashMap<>(8);
    private final Map fadeInMap = new HashMap<>(8);
    private final int defaultFadeDuration;
    private final PlayableId fadeOutPlayable;
    private FadeInterval fadeIn = null;
    private FadeInterval fadeOut = null;
    private FadeInterval activeInterval = null;
    private float lastGain = 1;
    private int fadeOverlap = 0;

    public CrossfadeController(@NotNull String playbackId, int duration, @NotNull Map metadata, @NotNull PlayerConfiguration conf) {
        this.playbackId = playbackId;
        trackDuration = duration;
        defaultFadeDuration = conf.crossfadeDuration;

        String fadeOutUri = metadata.get("audio.fade_out_uri");
        fadeOutPlayable = fadeOutUri == null ? null : PlayableId.fromUri(fadeOutUri);

        populateFadeIn(metadata);
        populateFadeOut(metadata);

        LOGGER.debug("Loaded crossfade intervals {id: {}, in: {}, out: {}}", 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());

        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()));

        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()));
    }

    private void populateFadeOut(@NotNull Map metadata) {
        int fadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.fade_out_duration", "-1"));
        int fadeOutStartTime = Integer.parseInt(metadata.getOrDefault("audio.fade_out_start_time", "-1"));
        JsonArray fadeOutCurves = JsonParser.parseString(metadata.getOrDefault("audio.fade_out_curves", "[]")).getAsJsonArray();
        if (fadeOutCurves.size() > 1) throw new UnsupportedOperationException(fadeOutCurves.toString());

        if (fadeOutDuration != 0 && fadeOutCurves.size() > 0)
            fadeOutMap.put(Reason.TRACK_DONE, new FadeInterval(fadeOutStartTime, fadeOutDuration, LookupInterpolator.fromJson(getFadeCurve(fadeOutCurves))));
        else if (defaultFadeDuration > 0)
            fadeOutMap.put(Reason.TRACK_DONE, new FadeInterval(trackDuration - defaultFadeDuration, defaultFadeDuration, new LinearDecreasingInterpolator()));


        int backFadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.backbtn.fade_out_duration", "-1"));
        if (backFadeOutDuration > 0)
            fadeOutMap.put(Reason.BACK_BTN, new PartialFadeInterval(backFadeOutDuration, new LinearDecreasingInterpolator()));

        int fwdFadeOutDuration = Integer.parseInt(metadata.getOrDefault("audio.fwdbtn.fade_out_duration", "-1"));
        if (fwdFadeOutDuration > 0)
            fadeOutMap.put(Reason.FORWARD_BTN, new PartialFadeInterval(fwdFadeOutDuration, new LinearDecreasingInterpolator()));
    }

    /**
     * Get the gain at this specified position, switching out intervals if needed.
     *
     * @param pos The time in milliseconds
     * @return The gain value from 0 to 1
     */
    public float getGain(int pos) {
        if (activeInterval == null && fadeIn == null && fadeOut == null)
            return lastGain;

        if (activeInterval != null && activeInterval.end() <= pos) {
            lastGain = activeInterval.interpolator.last();

            if (activeInterval == fadeIn) {
                fadeIn = null;
                LOGGER.debug("Cleared fade in. {id: {}}", playbackId);
            } else if (activeInterval == fadeOut) {
                fadeOut = null;
                LOGGER.debug("Cleared fade out. {id: {}}", playbackId);
            }

            activeInterval = null;
        }

        if (activeInterval == null) {
            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;

        return lastGain = activeInterval.interpolate(pos);
    }

    /**
     * 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
     * @param customFade Whether the previous track was {@link CrossfadeController#fadeOutPlayable()}
     * @return The interval that has just been selected
     */
    @Nullable
    public FadeInterval selectFadeIn(@NotNull Reason reason, boolean customFade) {
        if ((!customFade && fadeOutPlayable != null) && reason == Reason.TRACK_DONE) {
            fadeIn = null;
            activeInterval = null;
            LOGGER.debug("Cleared fade in because custom fade doesn't apply. {id: {}}", playbackId);
            return null;
        } else {
            fadeIn = fadeInMap.get(reason);
            activeInterval = null;
            LOGGER.debug("Changed fade in. {curr: {}, custom: {}, why: {}, id: {}}", fadeIn, customFade, reason, playbackId);
            return fadeIn;
        }
    }

    /**
     * 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
     * @param customFade Whether the next track is {@link CrossfadeController#fadeOutPlayable()}
     * @return The interval that has just been selected
     */
    @Nullable
    public FadeInterval selectFadeOut(@NotNull Reason reason, boolean customFade) {
        if ((!customFade && fadeOutPlayable != null) && reason == Reason.TRACK_DONE) {
            fadeOut = null;
            activeInterval = null;
            LOGGER.debug("Cleared fade out because custom fade doesn't apply. {id: {}}", playbackId);
            return null;
        } else {
            fadeOut = fadeOutMap.get(reason);
            activeInterval = null;
            LOGGER.debug("Changed fade out. {curr: {}, custom: {}, why: {}, id: {}}", fadeOut, customFade, reason, playbackId);
            return fadeOut;
        }
    }

    /**
     * @return The first (scheduled) fade out start time.
     */
    public int fadeOutStartTimeMin() {
        int fadeOutStartTime = -1;
        for (FadeInterval interval : fadeOutMap.values()) {
            if (interval instanceof PartialFadeInterval) continue;

            if (fadeOutStartTime == -1 || fadeOutStartTime > interval.start)
                fadeOutStartTime = interval.start;
        }

        if (fadeOutStartTime == -1) return trackDuration;
        else return fadeOutStartTime;
    }

    /**
     * @return Whether there is any possibility of a fade out.
     */
    public boolean hasAnyFadeOut() {
        return !fadeOutMap.isEmpty();
    }

    /**
     * @return The amount of fade overlap accumulated during playback.
     */
    public int fadeOverlap() {
        return fadeOverlap;
    }

    /**
     * @return The content that should be played next for custom fade in/out to apply.
     */
    @Nullable
    public PlayableId fadeOutPlayable() {
        return fadeOutPlayable;
    }

    /**
     * An interval without a start. Used when crossfading due to an user interaction.
     */
    public static class PartialFadeInterval extends FadeInterval {
        private int partialStart = -1;

        PartialFadeInterval(int duration, @NotNull GainInterpolator interpolator) {
            super(-1, duration, interpolator);
        }

        @Override
        public int start() {
            if (partialStart == -1) throw new IllegalStateException();
            return partialStart;
        }

        public int end(int now) {
            partialStart = now;
            return end();
        }

        @Override
        public int end() {
            if (partialStart == -1) throw new IllegalStateException();
            return partialStart + duration;
        }

        @Override
        float interpolate(int trackPos) {
            if (partialStart == -1) throw new IllegalStateException();
            return super.interpolate(trackPos - 1 - partialStart);
        }

        @Override
        public String toString() {
            return "PartialFadeInterval{duration=" + duration + ", interpolator=" + interpolator + '}';
        }
    }

    /**
     * An interval representing when the fade should start, end, how much should last and how should behave.
     */
    public static class FadeInterval {
        final int start;
        final int duration;
        final GainInterpolator interpolator;

        FadeInterval(int start, int duration, @NotNull GainInterpolator interpolator) {
            this.start = start;
            this.duration = duration;
            this.interpolator = interpolator;
        }

        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);
            pos = Math.max(pos, 0);
            return interpolator.interpolate(pos);
        }

        @Override
        public String toString() {
            return "FadeInterval{start=" + start + ", duration=" + duration + ", interpolator=" + interpolator + '}';
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy