diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/AlbumMusicVideoPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/AlbumMusicVideoPatch.java new file mode 100644 index 000000000..d7ccccbad --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/AlbumMusicVideoPatch.java @@ -0,0 +1,109 @@ +package app.revanced.extension.music.patches.misc; + +import androidx.annotation.NonNull; + +import java.util.concurrent.atomic.AtomicBoolean; + +import app.revanced.extension.music.patches.misc.requests.PipedRequester; +import app.revanced.extension.music.settings.Settings; +import app.revanced.extension.music.utils.VideoUtils; +import app.revanced.extension.shared.utils.Logger; + +@SuppressWarnings("unused") +public class AlbumMusicVideoPatch { + private static final String YOUTUBE_MUSIC_ALBUM_PREFIX = "OLAK"; + private static final boolean DISABLE_MUSIC_VIDEO_IN_ALBUM = + Settings.DISABLE_MUSIC_VIDEO_IN_ALBUM.get(); + + private static final AtomicBoolean isVideoLaunched = new AtomicBoolean(false); + + @NonNull + private static volatile String playerResponseVideoId = ""; + + @NonNull + private static volatile String currentVideoId = ""; + + /** + * Injection point. + */ + public static void newPlayerResponse(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) { + if (!DISABLE_MUSIC_VIDEO_IN_ALBUM) { + return; + } + if (!playlistId.startsWith(YOUTUBE_MUSIC_ALBUM_PREFIX)) { + return; + } + if (playlistIndex < 0) { + return; + } + if (playerResponseVideoId.equals(videoId)) { + return; + } + playerResponseVideoId = videoId; + + // Fetch from piped instances. + PipedRequester.fetchRequestIfNeeded(videoId, playlistId, playlistIndex); + } + + /** + * Injection point. + */ + public static void newVideoLoaded(@NonNull String videoId) { + if (!DISABLE_MUSIC_VIDEO_IN_ALBUM) { + return; + } + if (currentVideoId.equals(videoId)) { + return; + } + currentVideoId = videoId; + + // If the user is using a not fast enough internet connection, there will be a slight delay. + // Otherwise, the video may open repeatedly. + VideoUtils.runOnMainThreadDelayed(() -> openOfficialMusicIfNeeded(videoId), 750); + } + + private static void openOfficialMusicIfNeeded(@NonNull String videoId) { + try { + PipedRequester request = PipedRequester.getRequestForVideoId(videoId); + if (request == null) { + return; + } + String songId = request.getStream(); + if (songId == null) { + return; + } + + // It is handled by YouTube Music's internal code. + // There is a slight delay before the dismiss request is reflected. + VideoUtils.dismissQueue(); + + // Every time a new video is opened, a snack bar appears indicating that the account has been switched. + // To prevent this, hide the snack bar while a new video is opening. + isVideoLaunched.compareAndSet(false, true); + + // The newly opened video is not a music video. + // To prevent fetch requests from being sent, set the video id to the newly opened video + VideoUtils.runOnMainThreadDelayed(() -> { + playerResponseVideoId = songId; + currentVideoId = songId; + VideoUtils.openInYouTubeMusic(songId); + }, 750); + + // If a new video is opened, the snack bar will be shown. + VideoUtils.runOnMainThreadDelayed(() -> isVideoLaunched.compareAndSet(true, false), 1500); + } catch (Exception ex) { + Logger.printException(() -> "openOfficialMusicIfNeeded failure", ex); + } + } + + /** + * Injection point. + */ + public static boolean hideSnackBar() { + if (!DISABLE_MUSIC_VIDEO_IN_ALBUM) { + return false; + } + return isVideoLaunched.get(); + } + +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/requests/PipedRequester.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/requests/PipedRequester.java new file mode 100644 index 000000000..d88623742 --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/requests/PipedRequester.java @@ -0,0 +1,177 @@ +package app.revanced.extension.music.patches.misc.requests; + +import android.annotation.SuppressLint; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.utils.Logger; +import app.revanced.extension.shared.utils.Utils; + +public class PipedRequester { + /** + * How long to keep fetches until they are expired. + */ + private static final long CACHE_RETENTION_TIME_MILLISECONDS = 60 * 1000; // 1 Minute + + private static final long MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000; // 20 seconds + + @GuardedBy("itself") + private static final Map cache = new HashMap<>(); + + @SuppressLint("ObsoleteSdkInt") + public static void fetchRequestIfNeeded(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) { + synchronized (cache) { + final long now = System.currentTimeMillis(); + + cache.values().removeIf(request -> { + final boolean expired = request.isExpired(now); + if (expired) Logger.printDebug(() -> "Removing expired stream: " + request.videoId); + return expired; + }); + + if (!cache.containsKey(videoId)) { + PipedRequester pipedRequester = new PipedRequester(videoId, playlistId, playlistIndex); + cache.put(videoId, pipedRequester); + } + } + } + + @Nullable + public static PipedRequester getRequestForVideoId(@Nullable String videoId) { + synchronized (cache) { + return cache.get(videoId); + } + } + + /** + * TCP timeout + */ + private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 2 * 1000; // 2 seconds + + /** + * HTTP response timeout + */ + private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 4 * 1000; // 4 seconds + + @Nullable + private static JSONObject send(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) { + final long startTime = System.currentTimeMillis(); + Logger.printDebug(() -> "Fetching piped instances (videoId: '" + videoId + + "', playlistId: '" + playlistId + "', playlistIndex: '" + playlistIndex + "'"); + + try { + HttpURLConnection connection = PipedRoutes.getPlaylistConnectionFromRoute(playlistId); + connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS); + connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS); + + final int responseCode = connection.getResponseCode(); + if (responseCode == 200) return Requester.parseJSONObject(connection); + + handleConnectionError("API not available: " + responseCode); + } catch (SocketTimeoutException ex) { + handleConnectionError("Connection timeout", ex); + } catch (IOException ex) { + handleConnectionError("Network error", ex); + } catch (Exception ex) { + Logger.printException(() -> "send failed", ex); + } finally { + Logger.printDebug(() -> "playlist: " + playlistId + " took: " + (System.currentTimeMillis() - startTime) + "ms"); + } + + return null; + } + + @Nullable + private static String fetch(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) { + final JSONObject playlistJson = send(videoId, playlistId, playlistIndex); + if (playlistJson != null) { + try { + final String songId = playlistJson.getJSONArray("relatedStreams") + .getJSONObject(playlistIndex) + .getString("url") + .replaceAll("/.+=", ""); + if (songId.isEmpty()) { + handleConnectionError("Url is empty!"); + } else if (!songId.equals(videoId)) { + return songId; + } + } catch (JSONException e) { + Logger.printDebug(() -> "Fetch failed while processing response data for response: " + playlistJson); + } + } + + return null; + } + + private static void handleConnectionError(@NonNull String errorMessage) { + handleConnectionError(errorMessage, null); + } + + private static void handleConnectionError(@NonNull String errorMessage, @Nullable Exception ex) { + if (ex != null) { + Logger.printInfo(() -> errorMessage, ex); + } + } + + + /** + * Time this instance and the fetch future was created. + */ + private final long timeFetched; + private final String videoId; + private final Future future; + + private PipedRequester(@NonNull String videoId, @NonNull String playlistId, final int playlistIndex) { + this.timeFetched = System.currentTimeMillis(); + this.videoId = videoId; + this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playlistId, playlistIndex)); + } + + public boolean isExpired(long now) { + final long timeSinceCreation = now - timeFetched; + if (timeSinceCreation > CACHE_RETENTION_TIME_MILLISECONDS) { + return true; + } + + // Only expired if the fetch failed (API null response). + return (fetchCompleted() && getStream() == null); + } + + /** + * @return if the fetch call has completed. + */ + public boolean fetchCompleted() { + return future.isDone(); + } + + public String getStream() { + try { + return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS); + } catch (TimeoutException ex) { + Logger.printInfo(() -> "getStream timed out", ex); + } catch (InterruptedException ex) { + Logger.printException(() -> "getStream interrupted", ex); + Thread.currentThread().interrupt(); // Restore interrupt status flag. + } catch (ExecutionException ex) { + Logger.printException(() -> "getStream failure", ex); + } + + return null; + } +} diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/requests/PipedRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/requests/PipedRoutes.java new file mode 100644 index 000000000..fff5070df --- /dev/null +++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/requests/PipedRoutes.java @@ -0,0 +1,22 @@ +package app.revanced.extension.music.patches.misc.requests; + +import static app.revanced.extension.shared.requests.Route.Method.GET; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import app.revanced.extension.shared.requests.Requester; +import app.revanced.extension.shared.requests.Route; + +class PipedRoutes { + private static final String PIPED_URL = "https://pipedapi.kavin.rocks/"; + private static final Route GET_PLAYLIST = new Route(GET, "playlists/{playlist_id}"); + + private PipedRoutes() { + } + + static HttpURLConnection getPlaylistConnectionFromRoute(String... params) throws IOException { + return Requester.getConnectionFromRoute(PIPED_URL, GET_PLAYLIST, params); + } + +} \ No newline at end of file diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java index 77bac406c..4ff5dc57e 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java +++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java @@ -178,6 +178,7 @@ public class Settings extends BaseSettings { public static final BooleanSetting CHANGE_SHARE_SHEET = new BooleanSetting("revanced_change_share_sheet", FALSE, true); public static final BooleanSetting DISABLE_CAIRO_SPLASH_ANIMATION = new BooleanSetting("revanced_disable_cairo_splash_animation", FALSE, true); public static final BooleanSetting DISABLE_DRC_AUDIO = new BooleanSetting("revanced_disable_drc_audio", FALSE, true); + public static final BooleanSetting DISABLE_MUSIC_VIDEO_IN_ALBUM = new BooleanSetting("revanced_disable_music_video_in_album", FALSE, true); public static final BooleanSetting ENABLE_OPUS_CODEC = new BooleanSetting("revanced_enable_opus_codec", FALSE, true); public static final BooleanSetting SETTINGS_IMPORT_EXPORT = new BooleanSetting("revanced_extended_settings_import_export", FALSE, false); public static final BooleanSetting SPOOF_CLIENT = new BooleanSetting("revanced_spoof_client", FALSE, true); diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java b/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java index 059c311bd..9f2b6b22f 100644 --- a/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java +++ b/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java @@ -71,6 +71,13 @@ public class VideoUtils extends IntentUtils { launchView(url, context.getPackageName()); } + /** + * Rest of the implementation added by patch. + */ + public static void dismissQueue() { + Log.d("Extended: VideoUtils", "Queue dismissed"); + } + /** * Rest of the implementation added by patch. */ diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/album/AlbumMusicVideoPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/album/AlbumMusicVideoPatch.kt new file mode 100644 index 000000000..900ec0f75 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/album/AlbumMusicVideoPatch.kt @@ -0,0 +1,85 @@ +package app.revanced.patches.music.misc.album + +import app.revanced.patcher.extensions.InstructionExtensions.addInstruction +import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE +import app.revanced.patches.music.utils.dismiss.dismissQueueHookPatch +import app.revanced.patches.music.utils.extension.Constants.MISC_PATH +import app.revanced.patches.music.utils.patch.PatchList.DISABLE_MUSIC_VIDEO_IN_ALBUM +import app.revanced.patches.music.utils.playservice.is_7_03_or_greater +import app.revanced.patches.music.utils.playservice.versionCheckPatch +import app.revanced.patches.music.utils.settings.CategoryType +import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus +import app.revanced.patches.music.utils.settings.addSwitchPreference +import app.revanced.patches.music.utils.settings.settingsPatch +import app.revanced.patches.music.video.information.videoIdHook +import app.revanced.patches.music.video.information.videoInformationPatch +import app.revanced.util.fingerprint.methodOrThrow + +private const val EXTENSION_CLASS_DESCRIPTOR = + "$MISC_PATH/AlbumMusicVideoPatch;" + +@Suppress("unused") +val albumMusicVideoPatch = bytecodePatch( + DISABLE_MUSIC_VIDEO_IN_ALBUM.title, + DISABLE_MUSIC_VIDEO_IN_ALBUM.summary, + false, +) { + compatibleWith(COMPATIBLE_PACKAGE) + + dependsOn( + settingsPatch, + dismissQueueHookPatch, + videoInformationPatch, + versionCheckPatch, + ) + + execute { + + // region hook player response + + val fingerprint = if (is_7_03_or_greater) { + playerParameterBuilderFingerprint + } else { + playerParameterBuilderLegacyFingerprint + } + + fingerprint.methodOrThrow().addInstruction( + 0, + "invoke-static {p1, p4, p5}, $EXTENSION_CLASS_DESCRIPTOR->newPlayerResponse(Ljava/lang/String;Ljava/lang/String;I)V" + ) + + // endregion + + // region hook video id + + videoIdHook("$EXTENSION_CLASS_DESCRIPTOR->newVideoLoaded(Ljava/lang/String;)V") + + // endregion + + // region patch for hide snack bar + + snackBarParentFingerprint.methodOrThrow().addInstructionsWithLabels( + 0, """ + invoke-static {}, $EXTENSION_CLASS_DESCRIPTOR->hideSnackBar()Z + move-result v0 + if-eqz v0, :hide + return-void + :hide + nop + """ + ) + + // endregion + + addSwitchPreference( + CategoryType.MISC, + "revanced_disable_music_video_in_album", + "false" + ) + + updatePatchStatus(DISABLE_MUSIC_VIDEO_IN_ALBUM) + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/misc/album/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/misc/album/Fingerprints.kt new file mode 100644 index 000000000..e77dc2012 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/misc/album/Fingerprints.kt @@ -0,0 +1,69 @@ +package app.revanced.patches.music.misc.album + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.or +import com.android.tools.smali.dexlib2.AccessFlags +import com.android.tools.smali.dexlib2.Opcode + +/** + * For targets 7.03 and later. + */ +internal val playerParameterBuilderFingerprint = legacyFingerprint( + name = "playerParameterBuilderFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "L", + parameters = listOf( + "Ljava/lang/String;", // VideoId. + "[B", + "Ljava/lang/String;", // Player parameters proto buffer. + "Ljava/lang/String;", // PlaylistId. + "I", // PlaylistIndex. + "I", + "L", + "Ljava/util/Set;", + "Ljava/lang/String;", + "Ljava/lang/String;", + "L", + "Z", + "Z", + "Z", // Appears to indicate if the video id is being opened or is currently playing. + ), + strings = listOf("psps") +) + +/** + * For targets 7.02 and earlier. + */ +internal val playerParameterBuilderLegacyFingerprint = legacyFingerprint( + name = "playerParameterBuilderLegacyFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "L", + parameters = listOf( + "Ljava/lang/String;", // VideoId. + "[B", + "Ljava/lang/String;", // Player parameters proto buffer. + "Ljava/lang/String;", // PlaylistId. + "I", // PlaylistIndex. + "I", + "Ljava/util/Set;", + "Ljava/lang/String;", + "Ljava/lang/String;", + "L", + "Z", + "Z", // Appears to indicate if the video id is being opened or is currently playing. + ), + opcodes = listOf( + Opcode.INVOKE_INTERFACE, + Opcode.MOVE_RESULT_OBJECT, + Opcode.CHECK_CAST, + Opcode.INVOKE_INTERFACE + ) +) + +internal val snackBarParentFingerprint = legacyFingerprint( + name = "snackBarParentFingerprint", + accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL, + returnType = "V", + parameters = listOf("L"), + strings = listOf("No suitable parent found from the given view. Please provide a valid view.") +) diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/dismiss/DismissQueueHookPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/dismiss/DismissQueueHookPatch.kt new file mode 100644 index 000000000..7cb76868f --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/dismiss/DismissQueueHookPatch.kt @@ -0,0 +1,42 @@ +package app.revanced.patches.music.utils.dismiss + +import app.revanced.patcher.patch.bytecodePatch +import app.revanced.patches.music.utils.extension.Constants.EXTENSION_PATH +import app.revanced.util.addStaticFieldToExtension +import app.revanced.util.fingerprint.methodOrThrow +import app.revanced.util.getWalkerMethod + +private const val EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR = + "$EXTENSION_PATH/utils/VideoUtils;" + +@Suppress("unused") +val dismissQueueHookPatch = bytecodePatch( + description = "dismissQueueHookPatch" +) { + + execute { + + dismissQueueFingerprint.methodOrThrow().apply { + val dismissQueueIndex = indexOfDismissQueueInstruction(this) + + getWalkerMethod(dismissQueueIndex).apply { + val smaliInstructions = + """ + if-eqz v0, :ignore + invoke-virtual {v0}, $definingClass->$name()V + :ignore + return-void + """ + + addStaticFieldToExtension( + EXTENSION_VIDEO_UTILS_CLASS_DESCRIPTOR, + "dismissQueue", + "dismissQueueClass", + definingClass, + smaliInstructions + ) + } + } + + } +} diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/dismiss/Fingerprints.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/dismiss/Fingerprints.kt new file mode 100644 index 000000000..49c00a3b7 --- /dev/null +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/dismiss/Fingerprints.kt @@ -0,0 +1,24 @@ +package app.revanced.patches.music.utils.dismiss + +import app.revanced.util.fingerprint.legacyFingerprint +import app.revanced.util.getReference +import app.revanced.util.indexOfFirstInstruction +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.reference.MethodReference + +internal val dismissQueueFingerprint = legacyFingerprint( + name = "dismissQueueFingerprint", + returnType = "V", + parameters = listOf("L"), + customFingerprint = { method, _ -> + method.name == "handleDismissWatchEvent" && + indexOfDismissQueueInstruction(method) >= 0 + } +) + +internal fun indexOfDismissQueueInstruction(method: Method) = + method.indexOfFirstInstruction { + opcode == Opcode.INVOKE_VIRTUAL && + getReference()?.definingClass?.endsWith("/MppWatchWhileLayout;") == true + } diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/patch/PatchList.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/patch/PatchList.kt index 5a9c71bbc..2ec3454ce 100644 --- a/patches/src/main/kotlin/app/revanced/patches/music/utils/patch/PatchList.kt +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/patch/PatchList.kt @@ -57,6 +57,10 @@ internal enum class PatchList( "Disable dislike redirection", "Adds an option to disable redirection to the next track when clicking the Dislike button." ), + DISABLE_MUSIC_VIDEO_IN_ALBUM( + "Disable music video in album", + "Adds option to redirect music videos from albums." + ), ENABLE_OPUS_CODEC( "Enable OPUS codec", "Adds an options to enable the OPUS audio codec if the player response includes." diff --git a/patches/src/main/kotlin/app/revanced/patches/music/utils/playservice/VersionCheckPatch.kt b/patches/src/main/kotlin/app/revanced/patches/music/utils/playservice/VersionCheckPatch.kt index 372251b2b..6344ff17a 100644 --- a/patches/src/main/kotlin/app/revanced/patches/music/utils/playservice/VersionCheckPatch.kt +++ b/patches/src/main/kotlin/app/revanced/patches/music/utils/playservice/VersionCheckPatch.kt @@ -11,6 +11,8 @@ var is_6_36_or_greater = false private set var is_6_42_or_greater = false private set +var is_7_03_or_greater = false + private set var is_7_06_or_greater = false private set var is_7_13_or_greater = false @@ -43,6 +45,7 @@ val versionCheckPatch = resourcePatch( is_6_27_or_greater = 234412000 <= playStoreServicesVersion is_6_36_or_greater = 240399000 <= playStoreServicesVersion is_6_42_or_greater = 240999000 <= playStoreServicesVersion + is_7_03_or_greater = 242199000 <= playStoreServicesVersion is_7_06_or_greater = 242499000 <= playStoreServicesVersion is_7_13_or_greater = 243199000 <= playStoreServicesVersion is_7_17_or_greater = 243530000 <= playStoreServicesVersion diff --git a/patches/src/main/resources/music/settings/host/values/strings.xml b/patches/src/main/resources/music/settings/host/values/strings.xml index 7d62c7fd5..ef3c97f92 100644 --- a/patches/src/main/resources/music/settings/host/values/strings.xml +++ b/patches/src/main/resources/music/settings/host/values/strings.xml @@ -420,6 +420,12 @@ Click to see how to issue a API key." Disables Cairo splash animation when the app starts up. Disable DRC audio Disables DRC (Dynamic Range Compression) applied to audio. + Disable music video in album + "When a non-premium user plays a song included in an album, the music video is sometimes played instead of the official song. + +If such a music video is detected playing, it is redirected to the official song. + +A piped instance is used, but the API may not be available in some regions." Enable debug logging Prints the debug log. Enable debug buffer logging