-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(YouTube Music): Add
Disable music video in album
patch
- Loading branch information
Showing
19 changed files
with
859 additions
and
28 deletions.
There are no files selected for viewing
205 changes: 205 additions & 0 deletions
205
.../shared/src/main/java/app/revanced/extension/music/patches/misc/AlbumMusicVideoPatch.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,205 @@ | ||
package app.revanced.extension.music.patches.misc; | ||
|
||
import android.view.View; | ||
|
||
import androidx.annotation.GuardedBy; | ||
import androidx.annotation.NonNull; | ||
|
||
import java.util.LinkedHashMap; | ||
import java.util.Map; | ||
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.shared.VideoInformation; | ||
import app.revanced.extension.music.utils.VideoUtils; | ||
import app.revanced.extension.shared.utils.Logger; | ||
|
||
@SuppressWarnings("unused") | ||
public class AlbumMusicVideoPatch { | ||
|
||
public enum RedirectType { | ||
REDIRECT_DISMISS(true), | ||
REDIRECT(false), | ||
ON_CLICK_DISMISS(true), | ||
ON_CLICK(false), | ||
ON_LONG_CLICK_DISMISS(true), | ||
ON_LONG_CLICK(false); | ||
|
||
public final boolean dismissQueue; | ||
|
||
RedirectType(boolean dismissQueue) { | ||
this.dismissQueue = dismissQueue; | ||
} | ||
} | ||
|
||
private static final RedirectType REDIRECT_TYPE = | ||
Settings.DISABLE_MUSIC_VIDEO_IN_ALBUM_REDIRECT_TYPE.get(); | ||
|
||
private static final boolean DISABLE_MUSIC_VIDEO_IN_ALBUM = | ||
Settings.DISABLE_MUSIC_VIDEO_IN_ALBUM.get(); | ||
|
||
private static final boolean DISMISS_QUEUE = | ||
DISABLE_MUSIC_VIDEO_IN_ALBUM && REDIRECT_TYPE.dismissQueue; | ||
|
||
private static final boolean REDIRECT = | ||
REDIRECT_TYPE == RedirectType.REDIRECT || REDIRECT_TYPE == RedirectType.REDIRECT_DISMISS; | ||
|
||
private static final boolean ON_CLICK = | ||
REDIRECT_TYPE == RedirectType.ON_CLICK || REDIRECT_TYPE == RedirectType.ON_CLICK_DISMISS; | ||
|
||
private static final boolean ON_LONG_CLICK = | ||
REDIRECT_TYPE == RedirectType.ON_LONG_CLICK || REDIRECT_TYPE == RedirectType.ON_LONG_CLICK_DISMISS; | ||
|
||
private static final String YOUTUBE_MUSIC_ALBUM_PREFIX = "OLAK"; | ||
|
||
private static final AtomicBoolean isVideoLaunched = new AtomicBoolean(false); | ||
|
||
@NonNull | ||
private static volatile String playerResponseVideoId = ""; | ||
|
||
@NonNull | ||
private static volatile String currentVideoId = ""; | ||
|
||
@GuardedBy("itself") | ||
private static final Map<String, String> lastVideoIds = new LinkedHashMap<>() { | ||
private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 5; | ||
|
||
@Override | ||
protected boolean removeEldestEntry(Map.Entry eldest) { | ||
return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK; | ||
} | ||
}; | ||
|
||
/** | ||
* 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 Piped instance. | ||
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; | ||
checkVideo(videoId); | ||
} | ||
|
||
private static void checkVideo(@NonNull String videoId) { | ||
try { | ||
PipedRequester request = PipedRequester.getRequestForVideoId(videoId); | ||
if (request == null) { | ||
return; | ||
} | ||
String songId = request.getStream(); | ||
if (songId == null) { | ||
return; | ||
} | ||
synchronized (lastVideoIds) { | ||
if (lastVideoIds.put(videoId, songId) == null) { | ||
Logger.printDebug(() -> "Official song found, videoId: " + videoId + ", songId: " + songId); | ||
if (REDIRECT) { | ||
openMusic(songId); | ||
} | ||
} | ||
} | ||
} catch (Exception ex) { | ||
Logger.printException(() -> "check failure", ex); | ||
} | ||
} | ||
|
||
/** | ||
* Injection point. | ||
*/ | ||
public static boolean openMusic() { | ||
if (DISABLE_MUSIC_VIDEO_IN_ALBUM && ON_CLICK) { | ||
try { | ||
String videoId = VideoInformation.getVideoId(); | ||
synchronized (lastVideoIds) { | ||
String songId = lastVideoIds.get(videoId); | ||
if (songId != null) { | ||
openMusic(songId); | ||
return true; | ||
} | ||
} | ||
} catch (Exception ex) { | ||
Logger.printException(() -> "openMusic failure", ex); | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
private static void openMusic(@NonNull String songId) { | ||
try { | ||
if (DISMISS_QUEUE) { | ||
VideoUtils.dismissQueue(); | ||
} | ||
|
||
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); | ||
}, 500); | ||
|
||
VideoUtils.runOnMainThreadDelayed(() -> isVideoLaunched.compareAndSet(true, false), 1500); | ||
} catch (Exception ex) { | ||
Logger.printException(() -> "openMusic failure", ex); | ||
} | ||
} | ||
|
||
/** | ||
* Injection point. | ||
*/ | ||
public static void setAudioVideoSwitchToggleOnLongClickListener(View view) { | ||
if (DISABLE_MUSIC_VIDEO_IN_ALBUM && ON_LONG_CLICK) { | ||
view.setOnLongClickListener(v -> { | ||
try { | ||
String videoId = VideoInformation.getVideoId(); | ||
synchronized (lastVideoIds) { | ||
String songId = lastVideoIds.get(videoId); | ||
if (songId != null) { | ||
openMusic(songId); | ||
} | ||
} | ||
} catch (Exception ex) { | ||
Logger.printException(() -> "onLongClickListener failure", ex); | ||
} | ||
return true; | ||
}); | ||
} | ||
} | ||
|
||
/** | ||
* Injection point. | ||
*/ | ||
public static boolean hideSnackBar() { | ||
return DISABLE_MUSIC_VIDEO_IN_ALBUM && isVideoLaunched.get(); | ||
} | ||
|
||
} |
177 changes: 177 additions & 0 deletions
177
...ared/src/main/java/app/revanced/extension/music/patches/misc/requests/PipedRequester.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, PipedRequester> 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<String> 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; | ||
} | ||
} |
Oops, something went wrong.