feat(YouTube - Voice Over Translation): Add Yandex voice-over translation (#1370)
This commit is contained in:
parent
374c2bec4f
commit
889118eb63
26 changed files with 1858 additions and 12 deletions
|
|
@ -166,6 +166,17 @@ public class StreamingDataOuterClassUtils {
|
|||
}
|
||||
}
|
||||
|
||||
/** Returns the URL of the format, or null. For debug logging. */
|
||||
public static String getFormatUrl(Object adaptiveFormat) {
|
||||
if (adaptiveFormat == null) return null;
|
||||
try {
|
||||
Field field = adaptiveFormat.getClass().getField(FormatFields.url);
|
||||
field.setAccessible(true);
|
||||
if (field.get(adaptiveFormat) instanceof String url) return url;
|
||||
} catch (Exception ignored) { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int getHeight(Object adaptiveFormat) {
|
||||
if (adaptiveFormat != null) {
|
||||
try {
|
||||
|
|
@ -211,6 +222,34 @@ public class StreamingDataOuterClassUtils {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the format is audio-only (no video). Used e.g. to replace audio with translation URL.
|
||||
*/
|
||||
public static boolean isAudioOnlyFormat(Object adaptiveFormat) {
|
||||
if (adaptiveFormat == null) return false;
|
||||
String mime = getMimeType(adaptiveFormat);
|
||||
if (mime != null && mime.startsWith("audio")) return true;
|
||||
return getHeight(adaptiveFormat) <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets approx duration in ms from the first adaptive format. Returns 0 if not available.
|
||||
*/
|
||||
public static long getApproxDurationMsFromFirstFormat(StreamingDataOuterClass.StreamingData streamingData) {
|
||||
List<?> list = getAdaptiveFormats(streamingData);
|
||||
if (list == null || list.isEmpty()) return 0L;
|
||||
try {
|
||||
Object first = list.get(0);
|
||||
Field field = first.getClass().getField(FormatFields.approxDurationMs);
|
||||
field.setAccessible(true);
|
||||
Object val = field.get(first);
|
||||
if (val instanceof Long l) return l;
|
||||
if (val instanceof Number n) return n.longValue();
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "getApproxDurationMsFromFirstFormat failed", ex);
|
||||
}
|
||||
return 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field access via reflection will be replaced by Protobuf.MessageParser in the future.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,22 @@ import app.revanced.extension.youtube.shared.VideoInformation;
|
|||
|
||||
@SuppressWarnings("unused")
|
||||
public class SpoofStreamingDataPatch {
|
||||
|
||||
/**
|
||||
* Optional post-processor for the stream (e.g. replace audio with translation when VOT is on).
|
||||
* Set by the YouTube extension so playback uses one player and native speed.
|
||||
*/
|
||||
public interface StreamPostProcessor {
|
||||
@Nullable
|
||||
StreamingData process(@NonNull StreamingData stream, @NonNull String videoId);
|
||||
}
|
||||
|
||||
private static volatile StreamPostProcessor streamPostProcessor;
|
||||
|
||||
public static void setStreamPostProcessor(@Nullable StreamPostProcessor processor) {
|
||||
streamPostProcessor = processor;
|
||||
}
|
||||
|
||||
public static final boolean SPOOF_STREAMING_DATA =
|
||||
BaseSettings.SPOOF_STREAMING_DATA.get() && PatchStatus.SpoofStreamingData();
|
||||
private static final boolean J2V8_LIBRARY_AVAILABILITY = supportJ2V8();
|
||||
|
|
@ -250,24 +266,27 @@ public class SpoofStreamingDataPatch {
|
|||
if (SPOOF_STREAMING_DATA && isValidVideoId(videoId)) {
|
||||
try {
|
||||
StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
|
||||
Logger.printInfo(() -> "getStreamingData videoId=" + videoId + " request=" + (request != null ? "ok" : "null") + " postProcessor=" + (streamPostProcessor != null));
|
||||
if (request != null) {
|
||||
// This hook is always called off the main thread,
|
||||
// but this can later be called for the same video id from the main thread.
|
||||
// This is not a concern, since the fetch will always be finished
|
||||
// and never block the main thread.
|
||||
// But if debugging, then still verify this is the situation.
|
||||
if (BaseSettings.DEBUG.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
|
||||
Logger.printException(() -> "Error: Blocking main thread");
|
||||
}
|
||||
|
||||
var stream = request.getStream();
|
||||
final boolean hasStream = (stream != null);
|
||||
Logger.printInfo(() -> "getStreamingData videoId=" + videoId + " stream=" + (hasStream ? "ok" : "null"));
|
||||
if (stream != null) {
|
||||
Logger.printDebug(() -> "Overriding video stream: " + videoId);
|
||||
return stream;
|
||||
if (streamPostProcessor != null) {
|
||||
stream = streamPostProcessor.process(stream, videoId);
|
||||
}
|
||||
if (stream != null) {
|
||||
Logger.printDebug(() -> "Overriding video stream: " + videoId);
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Not overriding streaming data (video stream is null, it may be video ads): " + videoId);
|
||||
Logger.printInfo(() -> "getStreamingData videoId=" + videoId + " return null (request/stream null or postProcessor returned null)");
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "getStreamingData failure", ex);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,12 @@ class StreamingDataRequest private constructor(
|
|||
return cache[videoId]
|
||||
}
|
||||
|
||||
/** Invalidates the cached request for a videoId. The next time this video is loaded, a new request will be created (if the app makes a new player request). Used by VOT when enabling translation before reloading. */
|
||||
@JvmStatic
|
||||
fun invalidateCacheForVideoId(videoId: String) {
|
||||
cache.remove(videoId)
|
||||
}
|
||||
|
||||
private fun handleConnectionError(
|
||||
toastMessage: String,
|
||||
ex: Exception?,
|
||||
|
|
|
|||
|
|
@ -40,14 +40,15 @@ public class BaseSettings {
|
|||
public static final BooleanSetting SPOOF_STREAMING_DATA_ANDROID_VR_ENABLE_AV1_CODEC = new BooleanSetting("revanced_spoof_streaming_data_android_vr_enable_av1_codec", FALSE, true,
|
||||
"revanced_spoof_streaming_data_android_vr_enable_av1_codec_user_dialog_message", new ClientAndroidVRAvailability());
|
||||
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_USE_JS = new BooleanSetting("revanced_spoof_streaming_data_use_js", !IS_YOUTUBE, true,
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_USE_JS = new BooleanSetting("revanced_spoof_streaming_data_use_js", TRUE, true,
|
||||
"revanced_spoof_streaming_data_use_js_user_dialog_message", new J2V8Availability());
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_USE_JS_ALL = new BooleanSetting("revanced_spoof_streaming_data_use_js_all", FALSE, true, new ClientJSAvailability());
|
||||
public static final BooleanSetting SPOOF_STREAMING_DATA_USE_JS_BYPASS_FAKE_BUFFERING = new BooleanSetting("revanced_spoof_streaming_data_use_js_bypass_fake_buffering", FALSE, true, new ClientJSAvailability());
|
||||
|
||||
// Client type must be last spoof setting due to cyclic references.
|
||||
// TV_SIMPLY for YouTube so 4K/HDR formats are returned when spoofing (e.g. devices without GMS).
|
||||
public static final EnumSetting<ClientType> SPOOF_STREAMING_DATA_DEFAULT_CLIENT = new EnumSetting<>("revanced_spoof_streaming_data_default_client",
|
||||
IS_YOUTUBE ? ClientType.ANDROID_NO_SDK : ClientType.ANDROID_MUSIC_NO_SDK, true, parent(SPOOF_STREAMING_DATA));
|
||||
IS_YOUTUBE ? ClientType.TV : ClientType.ANDROID_MUSIC_NO_SDK, true, parent(SPOOF_STREAMING_DATA));
|
||||
|
||||
public static final BooleanSetting ENABLE_COMMENTS_SCROLL_TOP = new BooleanSetting("revanced_enable_comments_scroll_top", FALSE, true);
|
||||
public static final BooleanSetting DISABLE_AUTO_AUDIO_TRACKS = new BooleanSetting("revanced_disable_auto_audio_tracks", TRUE);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import android.widget.TextView;
|
|||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
package app.revanced.extension.youtube.patches.overlaybutton
|
||||
|
||||
import android.view.View
|
||||
import app.revanced.extension.shared.utils.Logger
|
||||
import app.revanced.extension.youtube.patches.voiceovertranslation.VoiceOverTranslationPatch
|
||||
import app.revanced.extension.youtube.settings.Settings
|
||||
import app.revanced.extension.youtube.shared.PlayerControlButton
|
||||
import app.revanced.extension.youtube.shared.RootView.isAdProgressTextVisible
|
||||
|
||||
@Suppress("DEPRECATION", "unused")
|
||||
object VoiceOverTranslationButton {
|
||||
private var instance: PlayerControlButton? = null
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun initializeButton(controlsView: View) {
|
||||
try {
|
||||
instance = PlayerControlButton(
|
||||
controlsViewGroup = controlsView,
|
||||
imageViewButtonId = "revanced_vot_button",
|
||||
buttonVisibility = { isButtonEnabled() },
|
||||
onClickListener = { view: View -> onClick(view) },
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Logger.printException({ "VoiceOverTranslationButton initializeButton failure" }, ex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun setVisibilityNegatedImmediate() {
|
||||
instance?.setVisibilityNegatedImmediate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun setVisibilityImmediate(visible: Boolean) {
|
||||
instance?.setActivated()
|
||||
instance?.setVisibilityImmediate(visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun setVisibility(visible: Boolean, animated: Boolean) {
|
||||
instance?.setActivated()
|
||||
instance?.setVisibility(visible, animated)
|
||||
}
|
||||
|
||||
private fun isButtonEnabled(): Boolean {
|
||||
return Settings.VOT_ENABLED.get()
|
||||
&& !isAdProgressTextVisible()
|
||||
}
|
||||
|
||||
private fun onClick(view: View) {
|
||||
VoiceOverTranslationPatch.toggleTranslation()
|
||||
instance?.imageView()?.isActivated = VoiceOverTranslationPatch.isTranslationActive()
|
||||
}
|
||||
|
||||
private fun PlayerControlButton.setActivated() {
|
||||
imageView()?.isActivated = VoiceOverTranslationPatch.isTranslationActive()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,393 @@
|
|||
package app.revanced.extension.youtube.patches.voiceovertranslation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.AudioFocusRequest;
|
||||
import android.media.AudioManager;
|
||||
import android.media.MediaPlayer;
|
||||
import android.media.PlaybackParams;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
import app.revanced.extension.youtube.shared.RootView;
|
||||
import app.revanced.extension.youtube.shared.VideoInformation;
|
||||
import app.revanced.extension.youtube.shared.VideoState;
|
||||
|
||||
import static app.revanced.extension.shared.utils.StringRef.str;
|
||||
import static app.revanced.extension.shared.utils.Utils.showToastShort;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class VoiceOverTranslationPatch {
|
||||
|
||||
private static final String TAG = "VOT";
|
||||
|
||||
private static final int STATUS_FAILED = 0;
|
||||
private static final int STATUS_FINISHED = 1;
|
||||
private static final int STATUS_WAITING = 2;
|
||||
private static final int STATUS_LONG_WAITING = 3;
|
||||
private static final int STATUS_PART_CONTENT = 5;
|
||||
private static final int STATUS_AUDIO_REQUESTED = 6;
|
||||
private static final long PAUSE_DETECTION_TIMEOUT_MS = 1500;
|
||||
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private static final AtomicReference<MediaPlayer> mediaPlayer = new AtomicReference<>(null);
|
||||
private static final AtomicBoolean isTranslating = new AtomicBoolean(false);
|
||||
private static final AtomicReference<String> currentTranslatedVideoId = new AtomicReference<>("");
|
||||
private static volatile boolean isPaused = false;
|
||||
private static float lastAppliedPlaybackSpeed = 1.0f;
|
||||
private static Object audioFocusRequest;
|
||||
private static volatile long lastVideoTimeMs = -1;
|
||||
private static final long SEEK_DRIFT_THRESHOLD_MS = 20000;
|
||||
private static final long USER_SEEK_JUMP_MS = 3000;
|
||||
|
||||
private static final Runnable pauseCheckRunnable = () -> {
|
||||
if (!isPaused) {
|
||||
|
||||
pauseAudio();
|
||||
}
|
||||
};
|
||||
|
||||
private static volatile String pendingVideoId = "";
|
||||
private static volatile String pendingVideoTitle = "";
|
||||
private static volatile long pendingVideoLength = 0L;
|
||||
private static volatile boolean pendingIsLive = false;
|
||||
|
||||
public static void initialize() {
|
||||
VideoState.addOnPlayingListener(() -> mainHandler.post(() -> {
|
||||
if (VideoState.getCurrent() != VideoState.PLAYING) return;
|
||||
resumeAudio(-1);
|
||||
}));
|
||||
VideoInformation.addOnPlaybackSpeedChangeListener(() -> mainHandler.post(() -> {
|
||||
if (VideoState.getCurrent() != VideoState.PLAYING) return;
|
||||
MediaPlayer p = mediaPlayer.get();
|
||||
if (p != null) applyPlaybackSpeedToPlayer(p);
|
||||
}));
|
||||
}
|
||||
|
||||
public static void newVideoStarted(
|
||||
String channelId, String channelName,
|
||||
String videoId, String videoTitle,
|
||||
long videoLength, boolean isLive
|
||||
) {
|
||||
if (!Settings.VOT_ENABLED.get()) return;
|
||||
String newId = videoId != null ? videoId : "";
|
||||
if (!newId.equals(currentTranslatedVideoId.get())) {
|
||||
stopAudioPlayback();
|
||||
}
|
||||
pendingVideoId = newId;
|
||||
pendingVideoTitle = videoTitle != null ? videoTitle : "";
|
||||
pendingVideoLength = videoLength;
|
||||
pendingIsLive = isLive;
|
||||
|
||||
}
|
||||
|
||||
public static void toggleTranslation() {
|
||||
if (!Settings.VOT_ENABLED.get()) return;
|
||||
|
||||
if (isTranslationActive()) {
|
||||
stopAudioPlayback();
|
||||
showToastShort(str("revanced_vot_stopped"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingIsLive) {
|
||||
showToastShort(str("revanced_vot_unavailable_live"));
|
||||
return;
|
||||
}
|
||||
if (pendingVideoLength > 4 * 60 * 60 * 1000L) {
|
||||
showToastShort(str("revanced_vot_unavailable_too_long"));
|
||||
return;
|
||||
}
|
||||
String sourceLang = Settings.VOT_SOURCE_LANGUAGE.get();
|
||||
String targetLang = Settings.VOT_TARGET_LANGUAGE.get();
|
||||
if (sourceLang != null && !sourceLang.isEmpty() && !"auto".equalsIgnoreCase(sourceLang)
|
||||
&& sourceLang.equals(targetLang)) {
|
||||
showToastShort(str("revanced_vot_unavailable_same_language"));
|
||||
return;
|
||||
}
|
||||
if (pendingVideoId == null || pendingVideoId.isEmpty()) return;
|
||||
|
||||
double durationSeconds = pendingVideoLength / 1000.0;
|
||||
showToastShort(str("revanced_vot_started"));
|
||||
Utils.runOnBackgroundThread(() -> requestTranslation(
|
||||
pendingVideoId, pendingVideoTitle,
|
||||
sourceLang, targetLang,
|
||||
durationSeconds
|
||||
));
|
||||
}
|
||||
|
||||
public static boolean isTranslationActive() {
|
||||
MediaPlayer mp = mediaPlayer.get();
|
||||
if (mp == null) return false;
|
||||
return currentTranslatedVideoId.get() != null && !currentTranslatedVideoId.get().isEmpty();
|
||||
}
|
||||
|
||||
public static void setVideoTime(long videoTimeMillis) {
|
||||
if (!Settings.VOT_ENABLED.get()) return;
|
||||
if (isPaused) {
|
||||
final long time = videoTimeMillis;
|
||||
mainHandler.postDelayed(() -> resumeAudio(time), 80);
|
||||
}
|
||||
mainHandler.removeCallbacks(pauseCheckRunnable);
|
||||
mainHandler.postDelayed(pauseCheckRunnable, PAUSE_DETECTION_TIMEOUT_MS);
|
||||
MediaPlayer mp = mediaPlayer.get();
|
||||
if (mp == null || !mp.isPlaying()) return;
|
||||
final long time = videoTimeMillis;
|
||||
mainHandler.post(() -> {
|
||||
MediaPlayer p = mediaPlayer.get();
|
||||
if (p == null || !p.isPlaying()) return;
|
||||
applyPlaybackSpeedToPlayer(p);
|
||||
try {
|
||||
int audioPos = p.getCurrentPosition();
|
||||
long drift = Math.abs(audioPos - time);
|
||||
long prev = lastVideoTimeMs;
|
||||
lastVideoTimeMs = time;
|
||||
boolean userSeeked = prev >= 0 && (time < prev - 500 || time > prev + USER_SEEK_JUMP_MS);
|
||||
if (userSeeked || drift > SEEK_DRIFT_THRESHOLD_MS) {
|
||||
p.seekTo((int) time);
|
||||
applyPlaybackSpeedToPlayer(p);
|
||||
}
|
||||
} catch (IllegalStateException ignored) { }
|
||||
});
|
||||
}
|
||||
|
||||
private static void requestTranslation(
|
||||
String videoId, String videoTitle,
|
||||
String sourceLang, String targetLang,
|
||||
double durationSeconds
|
||||
) {
|
||||
if (isTranslating.getAndSet(true)) return;
|
||||
try {
|
||||
String youtubeUrl = "https://youtu.be/" + videoId;
|
||||
VotApiClient.TranslationResult result = VotApiClient.requestTranslation(
|
||||
youtubeUrl, durationSeconds, sourceLang, targetLang, videoTitle);
|
||||
if (result == null) return;
|
||||
switch (result.status) {
|
||||
case STATUS_FINISHED:
|
||||
case STATUS_PART_CONTENT:
|
||||
if (result.audioUrl != null && !result.audioUrl.isEmpty()) {
|
||||
final String url = result.audioUrl;
|
||||
Utils.runOnMainThread(() -> startAudioPlayback(videoId, url));
|
||||
}
|
||||
break;
|
||||
case STATUS_WAITING:
|
||||
case STATUS_LONG_WAITING:
|
||||
Utils.runOnMainThread(() -> showToastShort(str("revanced_vot_stream_waiting")));
|
||||
int waitTime = result.remainingTime > 0 ? result.remainingTime : 5;
|
||||
pollTranslation(videoId, videoTitle, youtubeUrl, durationSeconds, sourceLang, targetLang, waitTime);
|
||||
break;
|
||||
case STATUS_FAILED:
|
||||
break;
|
||||
case STATUS_AUDIO_REQUESTED:
|
||||
handleAudioRequested(videoId, youtubeUrl, result.translationId, durationSeconds, sourceLang, targetLang, videoTitle);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "requestTranslation failed", e);
|
||||
} finally {
|
||||
isTranslating.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void pollTranslation(
|
||||
String videoId, String videoTitle,
|
||||
String url, double duration,
|
||||
String sourceLang, String targetLang,
|
||||
int waitSeconds
|
||||
) {
|
||||
int maxRetries = 30;
|
||||
for (int retryCount = 0; retryCount < maxRetries; retryCount++) {
|
||||
try {
|
||||
Thread.sleep(waitSeconds * 1000L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
String currentVideoId = VideoInformation.getVideoId();
|
||||
if (!videoId.equals(currentVideoId)) return;
|
||||
try {
|
||||
VotApiClient.TranslationResult result = VotApiClient.requestTranslation(
|
||||
url, duration, sourceLang, targetLang, videoTitle);
|
||||
if (result == null) continue;
|
||||
if (result.status == STATUS_FINISHED || result.status == STATUS_PART_CONTENT) {
|
||||
if (result.audioUrl != null && !result.audioUrl.isEmpty()) {
|
||||
Utils.runOnMainThread(() -> startAudioPlayback(videoId, result.audioUrl));
|
||||
return;
|
||||
}
|
||||
} else if (result.status == STATUS_FAILED) {
|
||||
return;
|
||||
}
|
||||
waitSeconds = result.remainingTime > 0 ? result.remainingTime : 5;
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "pollTranslation failure", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleAudioRequested(
|
||||
String videoId, String url, String translationId,
|
||||
double duration, String sourceLang, String targetLang,
|
||||
String videoTitle
|
||||
) {
|
||||
try {
|
||||
VotApiClient.sendFailedAudio(url);
|
||||
VotApiClient.sendEmptyAudio(url, translationId);
|
||||
Thread.sleep(3000);
|
||||
VotApiClient.TranslationResult result = VotApiClient.requestTranslation(
|
||||
url, duration, sourceLang, targetLang, videoTitle);
|
||||
if (result != null && (result.status == STATUS_WAITING || result.status == STATUS_LONG_WAITING)) {
|
||||
Utils.runOnMainThread(() -> showToastShort(str("revanced_vot_stream_waiting")));
|
||||
int waitTime = result.remainingTime > 0 ? result.remainingTime : 10;
|
||||
pollTranslation(videoId, videoTitle, url, duration, sourceLang, targetLang, waitTime);
|
||||
} else if (result != null && (result.status == STATUS_FINISHED || result.status == STATUS_PART_CONTENT) && result.audioUrl != null) {
|
||||
Utils.runOnMainThread(() -> startAudioPlayback(videoId, result.audioUrl));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "handleAudioRequested failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void startAudioPlayback(String videoId, String audioUrl) {
|
||||
stopAudioPlayback();
|
||||
try {
|
||||
MediaPlayer mp = new MediaPlayer();
|
||||
mp.setAudioAttributes(
|
||||
new AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build());
|
||||
mp.setDataSource(audioUrl);
|
||||
mp.setOnPreparedListener(player -> {
|
||||
Utils.runOnMainThread(() -> requestAudioFocusForTranslation());
|
||||
float vol = Settings.VOT_TRANSLATION_VOLUME.get() / 100.0f;
|
||||
player.setVolume(vol, vol);
|
||||
long videoTime = VideoInformation.getVideoTime();
|
||||
if (videoTime > 0) player.seekTo((int) videoTime);
|
||||
applyPlaybackSpeedToPlayer(player);
|
||||
player.start();
|
||||
});
|
||||
mp.setOnErrorListener((p, what, extra) -> true);
|
||||
mediaPlayer.set(mp);
|
||||
currentTranslatedVideoId.set(videoId != null ? videoId : "");
|
||||
mp.prepareAsync();
|
||||
} catch (IOException e) {
|
||||
Logger.printException(() -> "startAudioPlayback failed for videoId: " + videoId, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void stopAudioPlayback() {
|
||||
mainHandler.removeCallbacks(pauseCheckRunnable);
|
||||
MediaPlayer mp = mediaPlayer.getAndSet(null);
|
||||
if (mp != null) {
|
||||
try {
|
||||
if (mp.isPlaying()) mp.stop();
|
||||
mp.release();
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
currentTranslatedVideoId.set("");
|
||||
isPaused = false;
|
||||
lastAppliedPlaybackSpeed = 1.0f;
|
||||
lastVideoTimeMs = -1;
|
||||
abandonAudioFocus();
|
||||
}
|
||||
|
||||
private static void requestAudioFocusForTranslation() {
|
||||
try {
|
||||
Context ctx = RootView.getContext();
|
||||
if (ctx == null) return;
|
||||
AudioManager am = (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE);
|
||||
if (am == null) return;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
AudioFocusRequest req = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
|
||||
.setAudioAttributes(new AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build())
|
||||
.build();
|
||||
int result = am.requestAudioFocus(req);
|
||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
audioFocusRequest = req;
|
||||
} else {
|
||||
Logger.printDebug(() -> "requestAudioFocus failed: " + result);
|
||||
}
|
||||
} else {
|
||||
int result = am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK);
|
||||
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
Logger.printDebug(() -> "requestAudioFocus failed: " + result);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "requestAudioFocusForTranslation failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void abandonAudioFocus() {
|
||||
try {
|
||||
Context ctx = RootView.getContext();
|
||||
if (ctx == null) return;
|
||||
AudioManager am = (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE);
|
||||
if (am == null) return;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && audioFocusRequest != null) {
|
||||
am.abandonAudioFocusRequest((AudioFocusRequest) audioFocusRequest);
|
||||
audioFocusRequest = null;
|
||||
} else {
|
||||
am.abandonAudioFocus(null);
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
|
||||
public static void pauseAudio() {
|
||||
MediaPlayer mp = mediaPlayer.get();
|
||||
if (mp != null) {
|
||||
try {
|
||||
if (mp.isPlaying()) {
|
||||
mp.pause();
|
||||
isPaused = true;
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
public static void resumeAudio(long videoTimeMillis) {
|
||||
if (VideoState.getCurrent() != VideoState.PLAYING) return;
|
||||
MediaPlayer mp = mediaPlayer.get();
|
||||
if (mp == null || !isPaused) return;
|
||||
try {
|
||||
long position = videoTimeMillis >= 0 ? videoTimeMillis : VideoInformation.getVideoTime();
|
||||
mp.seekTo((int) position);
|
||||
applyPlaybackSpeedToPlayer(mp);
|
||||
mp.start();
|
||||
isPaused = false;
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
|
||||
private static void applyPlaybackSpeedToPlayer(MediaPlayer mp) {
|
||||
if (mp == null) return;
|
||||
float speed = VideoInformation.getPlaybackSpeedFromPlayer();
|
||||
if (speed > 0f) {
|
||||
VideoInformation.setPlaybackSpeed(speed);
|
||||
} else {
|
||||
speed = VideoInformation.getPlaybackSpeed();
|
||||
}
|
||||
if (speed <= 0f || speed == -2.0f) speed = 1.0f;
|
||||
if (speed < 0.25f) speed = 0.25f;
|
||||
if (speed > 2.5f) speed = 2.5f;
|
||||
try {
|
||||
PlaybackParams params = new PlaybackParams();
|
||||
params.setSpeed(speed);
|
||||
mp.setPlaybackParams(params);
|
||||
lastAppliedPlaybackSpeed = speed;
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
package app.revanced.extension.youtube.patches.voiceovertranslation;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Locale;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
|
||||
public class VotApiClient {
|
||||
|
||||
private static final String TAG = "VOT-API";
|
||||
|
||||
private static final String DEFAULT_WORKER_HOST = "vot-worker.toil.cc";
|
||||
|
||||
private static final String HMAC_KEY = "bt8xH3VOlb4mqf0nqAibnDOoiPlXsisf";
|
||||
private static final String COMPONENT_VERSION = "25.6.0.2259";
|
||||
private static final double DEFAULT_DURATION = 343.0;
|
||||
|
||||
private static final String USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/134.0.0.0 YaBrowser/25.4.0.0 Safari/537.36";
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 15000;
|
||||
private static final int READ_TIMEOUT_MS = 30000;
|
||||
|
||||
private static String sessionUuid = null;
|
||||
private static String sessionSecretKey = null;
|
||||
private static long sessionExpires = 0;
|
||||
private static final ReentrantLock sessionLock = new ReentrantLock();
|
||||
|
||||
public static class TranslationResult {
|
||||
public final int status;
|
||||
public final String audioUrl;
|
||||
public final int remainingTime;
|
||||
public final String translationId;
|
||||
public final String message;
|
||||
|
||||
public TranslationResult(int status, String audioUrl, int remainingTime,
|
||||
String translationId, String message) {
|
||||
this.status = status;
|
||||
this.audioUrl = audioUrl;
|
||||
this.remainingTime = remainingTime;
|
||||
this.translationId = translationId;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
public static TranslationResult requestTranslation(
|
||||
String videoUrl, double duration,
|
||||
String sourceLang, String targetLang,
|
||||
String videoTitle
|
||||
) {
|
||||
try {
|
||||
ensureSession();
|
||||
|
||||
if (duration <= 0) {
|
||||
duration = DEFAULT_DURATION;
|
||||
}
|
||||
|
||||
String apiSourceLang = (sourceLang == null || sourceLang.isEmpty() || "auto".equalsIgnoreCase(sourceLang))
|
||||
? "" : sourceLang;
|
||||
|
||||
byte[] body = VotProtobuf.encodeTranslationRequest(
|
||||
videoUrl, true, duration,
|
||||
apiSourceLang, targetLang, videoTitle
|
||||
);
|
||||
|
||||
String path = "/video-translation/translate";
|
||||
String bodySignature = computeHmacHex(body);
|
||||
|
||||
String token = sessionUuid + ":" + path + ":" + COMPONENT_VERSION;
|
||||
String tokenSignature = computeHmacHex(token.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
byte[] responseBytes = sendWorkerRequest(path, body, bodySignature,
|
||||
sessionSecretKey, tokenSignature + ":" + token, "POST");
|
||||
|
||||
if (responseBytes == null || responseBytes.length == 0) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
VotProtobuf.TranslationResponse response = VotProtobuf.decodeTranslationResponse(responseBytes);
|
||||
|
||||
return new TranslationResult(
|
||||
response.status,
|
||||
response.url,
|
||||
response.remainingTime,
|
||||
response.translationId,
|
||||
response.message
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "VotApiClient.requestTranslation failed for " + videoUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void sendFailedAudio(String videoUrl) {
|
||||
try {
|
||||
ensureSession();
|
||||
|
||||
String path = "/video-translation/fail-audio-js";
|
||||
String jsonBody = "{\"video_url\":\"" + videoUrl + "\"}";
|
||||
|
||||
sendWorkerJsonRequest(path, jsonBody, "PUT");
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "VotApiClient.sendFailedAudio failed for " + videoUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void sendEmptyAudio(String videoUrl, String translationId) {
|
||||
try {
|
||||
ensureSession();
|
||||
|
||||
byte[] body = VotProtobuf.encodeEmptyAudioRequest(translationId, videoUrl);
|
||||
|
||||
String path = "/video-translation/audio";
|
||||
String bodySignature = computeHmacHex(body);
|
||||
|
||||
String token = sessionUuid + ":" + path + ":" + COMPONENT_VERSION;
|
||||
String tokenSignature = computeHmacHex(token.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
sendWorkerRequest(path, body, bodySignature,
|
||||
sessionSecretKey, tokenSignature + ":" + token, "PUT");
|
||||
|
||||
} catch (Exception e) {
|
||||
Logger.printException(() -> "VotApiClient.sendEmptyAudio failed for " + videoUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureSession() throws Exception {
|
||||
sessionLock.lock();
|
||||
try {
|
||||
long now = System.currentTimeMillis() / 1000;
|
||||
if (sessionSecretKey != null && now < sessionExpires) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionUuid = generateUuid();
|
||||
|
||||
byte[] body = VotProtobuf.encodeSessionRequest(sessionUuid, "video-translation");
|
||||
String signature = computeHmacHex(body);
|
||||
|
||||
byte[] responseBytes = sendWorkerRequest("/session/create", body, signature,
|
||||
null, null, "POST");
|
||||
|
||||
if (responseBytes == null || responseBytes.length == 0) {
|
||||
throw new IOException("Empty session response");
|
||||
}
|
||||
|
||||
VotProtobuf.SessionResponse sessionResponse = VotProtobuf.decodeSessionResponse(responseBytes);
|
||||
|
||||
sessionSecretKey = sessionResponse.secretKey;
|
||||
sessionExpires = now + sessionResponse.expires - 60;
|
||||
|
||||
} finally {
|
||||
sessionLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] sendWorkerRequest(
|
||||
String path, byte[] body,
|
||||
String vtransSignature, String secretKey, String vtransToken,
|
||||
String method
|
||||
) throws IOException {
|
||||
String workerHost = Settings.VOT_PROXY_URL.get();
|
||||
if (workerHost == null || workerHost.isEmpty()) {
|
||||
workerHost = DEFAULT_WORKER_HOST;
|
||||
}
|
||||
|
||||
String workerUrl = "https://" + workerHost + path;
|
||||
|
||||
StringBuilder headersJson = new StringBuilder();
|
||||
headersJson.append("{");
|
||||
headersJson.append("\"User-Agent\":\"").append(USER_AGENT).append("\",");
|
||||
headersJson.append("\"Accept\":\"application/x-protobuf\",");
|
||||
headersJson.append("\"Accept-Language\":\"en\",");
|
||||
headersJson.append("\"Content-Type\":\"application/x-protobuf\",");
|
||||
headersJson.append("\"Pragma\":\"no-cache\",");
|
||||
headersJson.append("\"Cache-Control\":\"no-cache\"");
|
||||
|
||||
if (vtransSignature != null) {
|
||||
headersJson.append(",\"Vtrans-Signature\":\"").append(vtransSignature).append("\"");
|
||||
}
|
||||
if (secretKey != null) {
|
||||
headersJson.append(",\"Sec-Vtrans-Sk\":\"").append(secretKey).append("\"");
|
||||
}
|
||||
if (vtransToken != null) {
|
||||
headersJson.append(",\"Sec-Vtrans-Token\":\"").append(vtransToken).append("\"");
|
||||
}
|
||||
|
||||
headersJson.append("}");
|
||||
|
||||
StringBuilder bodyArrayJson = new StringBuilder("[");
|
||||
for (int i = 0; i < body.length; i++) {
|
||||
if (i > 0) bodyArrayJson.append(",");
|
||||
bodyArrayJson.append(body[i] & 0xFF);
|
||||
}
|
||||
bodyArrayJson.append("]");
|
||||
String jsonPayload = "{\"headers\":" + headersJson + ",\"body\":" + bodyArrayJson + "}";
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(workerUrl).openConnection();
|
||||
try {
|
||||
connection.setRequestMethod(method);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||
connection.setDoOutput(true);
|
||||
|
||||
byte[] payloadBytes = jsonPayload.getBytes(StandardCharsets.UTF_8);
|
||||
connection.setFixedLengthStreamingMode(payloadBytes.length);
|
||||
|
||||
try (OutputStream os = connection.getOutputStream()) {
|
||||
os.write(payloadBytes);
|
||||
}
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode != 200) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return readBytes(connection.getInputStream());
|
||||
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendWorkerJsonRequest(String path, String jsonBody, String method) throws IOException {
|
||||
String workerHost = Settings.VOT_PROXY_URL.get();
|
||||
if (workerHost == null || workerHost.isEmpty()) {
|
||||
workerHost = DEFAULT_WORKER_HOST;
|
||||
}
|
||||
|
||||
String workerUrl = "https://" + workerHost + path;
|
||||
|
||||
StringBuilder headersJson = new StringBuilder();
|
||||
headersJson.append("{");
|
||||
headersJson.append("\"User-Agent\":\"").append(USER_AGENT).append("\",");
|
||||
headersJson.append("\"Content-Type\":\"application/json\",");
|
||||
headersJson.append("\"Accept\":\"application/json\"");
|
||||
headersJson.append("}");
|
||||
|
||||
String payload = "{\"headers\":" + headersJson + ",\"body\":" + jsonBody + "}";
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(workerUrl).openConnection();
|
||||
try {
|
||||
connection.setRequestMethod(method);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||
connection.setDoOutput(true);
|
||||
|
||||
byte[] payloadBytes = payload.getBytes(StandardCharsets.UTF_8);
|
||||
connection.setFixedLengthStreamingMode(payloadBytes.length);
|
||||
|
||||
try (OutputStream os = connection.getOutputStream()) {
|
||||
os.write(payloadBytes);
|
||||
}
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
|
||||
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String computeHmacHex(byte[] data) {
|
||||
try {
|
||||
Mac hmac = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec keySpec = new SecretKeySpec(
|
||||
HMAC_KEY.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
hmac.init(keySpec);
|
||||
byte[] result = hmac.doFinal(data);
|
||||
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : result) {
|
||||
hex.append(String.format(Locale.US, "%02x", b & 0xFF));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static String generateUuid() {
|
||||
String hexDigits = "0123456789ABCDEF";
|
||||
Random random = new Random();
|
||||
StringBuilder uuid = new StringBuilder(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
uuid.append(hexDigits.charAt(random.nextInt(16)));
|
||||
}
|
||||
return uuid.toString();
|
||||
}
|
||||
|
||||
private static byte[] readBytes(InputStream is) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = is.read(chunk)) != -1) {
|
||||
buffer.write(chunk, 0, bytesRead);
|
||||
}
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
package app.revanced.extension.youtube.patches.voiceovertranslation;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Manual protobuf encoder/decoder that avoids conflicts with YouTube's bundled protobuf version.
|
||||
* Implements only the subset of protobuf needed for VOT API communication.
|
||||
*/
|
||||
public class VotProtobuf {
|
||||
|
||||
// Wire types
|
||||
private static final int WIRETYPE_VARINT = 0;
|
||||
private static final int WIRETYPE_64BIT = 1;
|
||||
private static final int WIRETYPE_LENGTH_DELIMITED = 2;
|
||||
|
||||
// ==================== ENCODER ====================
|
||||
|
||||
/**
|
||||
* Encode a YandexSessionRequest: { uuid = 1 (string), module = 2 (string) }
|
||||
*/
|
||||
public static byte[] encodeSessionRequest(String uuid, String module) {
|
||||
try {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
writeString(out, 1, uuid);
|
||||
writeString(out, 2, module);
|
||||
return out.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to encode session request", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a VideoTranslationRequest.
|
||||
* Field numbers from yandex.proto:
|
||||
* url = 3 (string)
|
||||
* firstRequest = 5 (bool)
|
||||
* duration = 6 (double)
|
||||
* unknown0 = 7 (int32)
|
||||
* language = 8 (string)
|
||||
* forceSourceLang = 9 (bool)
|
||||
* unknown1 = 10 (int32)
|
||||
* responseLanguage = 14 (string)
|
||||
* unknown2 = 15 (int32)
|
||||
* unknown3 = 16 (int32)
|
||||
* videoTitle = 19 (string)
|
||||
*/
|
||||
public static byte[] encodeTranslationRequest(
|
||||
String url, boolean firstRequest, double duration,
|
||||
String language, String responseLanguage, String videoTitle
|
||||
) {
|
||||
try {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
writeString(out, 3, url);
|
||||
writeBool(out, 5, firstRequest);
|
||||
writeDouble(out, 6, duration);
|
||||
writeInt32(out, 7, 1); // unknown0
|
||||
writeString(out, 8, language);
|
||||
writeBool(out, 9, false); // forceSourceLang
|
||||
writeInt32(out, 10, 0); // unknown1
|
||||
writeString(out, 14, responseLanguage);
|
||||
writeInt32(out, 15, 1); // unknown2
|
||||
writeInt32(out, 16, 2); // unknown3
|
||||
if (videoTitle != null && !videoTitle.isEmpty()) {
|
||||
writeString(out, 19, videoTitle);
|
||||
}
|
||||
return out.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to encode translation request", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a VideoTranslationAudioRequest with empty audio.
|
||||
* translationId = 1 (string)
|
||||
* url = 2 (string)
|
||||
* audioInfo = 6 (message): { fileId = 1 (string), audioFile = 2 (bytes) }
|
||||
*/
|
||||
public static byte[] encodeEmptyAudioRequest(String translationId, String url) {
|
||||
try {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
writeString(out, 1, translationId);
|
||||
writeString(out, 2, url);
|
||||
|
||||
// audioInfo (field 6) is a nested message: AudioBufferObject { fileId=1, audioFile=2 }
|
||||
ByteArrayOutputStream audioInfoOut = new ByteArrayOutputStream();
|
||||
writeString(audioInfoOut, 1, "web_api_get_all_generating_urls_data_from_iframe");
|
||||
// audioFile (field 2) is empty bytes - we skip it
|
||||
|
||||
byte[] audioInfoBytes = audioInfoOut.toByteArray();
|
||||
writeBytes(out, 6, audioInfoBytes);
|
||||
|
||||
return out.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to encode audio request", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DECODER ====================
|
||||
|
||||
/**
|
||||
* Decoded translation response fields.
|
||||
*/
|
||||
public static class TranslationResponse {
|
||||
public String url;
|
||||
public double duration;
|
||||
public int status;
|
||||
public int remainingTime = -1;
|
||||
public String translationId;
|
||||
public String language;
|
||||
public String message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded session response fields.
|
||||
*/
|
||||
public static class SessionResponse {
|
||||
public String secretKey;
|
||||
public int expires;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a YandexSessionResponse: { secretKey = 1 (string), expires = 2 (int32) }
|
||||
*/
|
||||
public static SessionResponse decodeSessionResponse(byte[] data) {
|
||||
SessionResponse response = new SessionResponse();
|
||||
int pos = 0;
|
||||
|
||||
while (pos < data.length) {
|
||||
int[] tagResult = readVarint(data, pos);
|
||||
int tag = tagResult[0];
|
||||
pos = tagResult[1];
|
||||
|
||||
int fieldNumber = tag >>> 3;
|
||||
int wireType = tag & 0x7;
|
||||
|
||||
switch (fieldNumber) {
|
||||
case 1: // secretKey (string)
|
||||
if (wireType == WIRETYPE_LENGTH_DELIMITED) {
|
||||
int[] lenResult = readVarint(data, pos);
|
||||
int len = lenResult[0];
|
||||
pos = lenResult[1];
|
||||
response.secretKey = new String(data, pos, len, StandardCharsets.UTF_8);
|
||||
pos += len;
|
||||
}
|
||||
break;
|
||||
case 2: // expires (int32)
|
||||
if (wireType == WIRETYPE_VARINT) {
|
||||
int[] valResult = readVarint(data, pos);
|
||||
response.expires = valResult[0];
|
||||
pos = valResult[1];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
pos = skipField(data, pos, wireType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a VideoTranslationResponse:
|
||||
* url = 1 (string), duration = 2 (double), status = 4 (int32),
|
||||
* remainingTime = 5 (int32), translationId = 7 (string),
|
||||
* language = 8 (string), message = 9 (string)
|
||||
*/
|
||||
public static TranslationResponse decodeTranslationResponse(byte[] data) {
|
||||
TranslationResponse response = new TranslationResponse();
|
||||
int pos = 0;
|
||||
|
||||
while (pos < data.length) {
|
||||
int[] tagResult = readVarint(data, pos);
|
||||
int tag = tagResult[0];
|
||||
pos = tagResult[1];
|
||||
|
||||
int fieldNumber = tag >>> 3;
|
||||
int wireType = tag & 0x7;
|
||||
|
||||
switch (fieldNumber) {
|
||||
case 1: // url (string)
|
||||
if (wireType == WIRETYPE_LENGTH_DELIMITED) {
|
||||
int[] lenResult = readVarint(data, pos);
|
||||
int len = lenResult[0];
|
||||
pos = lenResult[1];
|
||||
response.url = new String(data, pos, len, StandardCharsets.UTF_8);
|
||||
pos += len;
|
||||
}
|
||||
break;
|
||||
case 2: // duration (double)
|
||||
if (wireType == WIRETYPE_64BIT) {
|
||||
response.duration = readDouble(data, pos);
|
||||
pos += 8;
|
||||
}
|
||||
break;
|
||||
case 4: // status (int32)
|
||||
if (wireType == WIRETYPE_VARINT) {
|
||||
int[] valResult = readVarint(data, pos);
|
||||
response.status = valResult[0];
|
||||
pos = valResult[1];
|
||||
}
|
||||
break;
|
||||
case 5: // remainingTime (int32)
|
||||
if (wireType == WIRETYPE_VARINT) {
|
||||
int[] valResult = readVarint(data, pos);
|
||||
response.remainingTime = valResult[0];
|
||||
pos = valResult[1];
|
||||
}
|
||||
break;
|
||||
case 7: // translationId (string)
|
||||
if (wireType == WIRETYPE_LENGTH_DELIMITED) {
|
||||
int[] lenResult = readVarint(data, pos);
|
||||
int len = lenResult[0];
|
||||
pos = lenResult[1];
|
||||
response.translationId = new String(data, pos, len, StandardCharsets.UTF_8);
|
||||
pos += len;
|
||||
}
|
||||
break;
|
||||
case 8: // language (string)
|
||||
if (wireType == WIRETYPE_LENGTH_DELIMITED) {
|
||||
int[] lenResult = readVarint(data, pos);
|
||||
int len = lenResult[0];
|
||||
pos = lenResult[1];
|
||||
response.language = new String(data, pos, len, StandardCharsets.UTF_8);
|
||||
pos += len;
|
||||
}
|
||||
break;
|
||||
case 9: // message (string)
|
||||
if (wireType == WIRETYPE_LENGTH_DELIMITED) {
|
||||
int[] lenResult = readVarint(data, pos);
|
||||
int len = lenResult[0];
|
||||
pos = lenResult[1];
|
||||
response.message = new String(data, pos, len, StandardCharsets.UTF_8);
|
||||
pos += len;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
pos = skipField(data, pos, wireType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL ENCODING ====================
|
||||
|
||||
private static void writeTag(ByteArrayOutputStream out, int fieldNumber, int wireType) throws IOException {
|
||||
writeRawVarint(out, (fieldNumber << 3) | wireType);
|
||||
}
|
||||
|
||||
private static void writeString(ByteArrayOutputStream out, int fieldNumber, String value) throws IOException {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
writeTag(out, fieldNumber, WIRETYPE_LENGTH_DELIMITED);
|
||||
writeRawVarint(out, bytes.length);
|
||||
out.write(bytes);
|
||||
}
|
||||
|
||||
private static void writeBytes(ByteArrayOutputStream out, int fieldNumber, byte[] value) throws IOException {
|
||||
writeTag(out, fieldNumber, WIRETYPE_LENGTH_DELIMITED);
|
||||
writeRawVarint(out, value.length);
|
||||
out.write(value);
|
||||
}
|
||||
|
||||
private static void writeInt32(ByteArrayOutputStream out, int fieldNumber, int value) throws IOException {
|
||||
writeTag(out, fieldNumber, WIRETYPE_VARINT);
|
||||
writeRawVarint(out, value);
|
||||
}
|
||||
|
||||
private static void writeBool(ByteArrayOutputStream out, int fieldNumber, boolean value) throws IOException {
|
||||
writeTag(out, fieldNumber, WIRETYPE_VARINT);
|
||||
out.write(value ? 1 : 0);
|
||||
}
|
||||
|
||||
private static void writeDouble(ByteArrayOutputStream out, int fieldNumber, double value) throws IOException {
|
||||
writeTag(out, fieldNumber, WIRETYPE_64BIT);
|
||||
ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
|
||||
buf.putDouble(value);
|
||||
out.write(buf.array());
|
||||
}
|
||||
|
||||
private static void writeRawVarint(ByteArrayOutputStream out, int value) throws IOException {
|
||||
// Handle unsigned encoding for potentially large values
|
||||
long unsigned = value & 0xFFFFFFFFL;
|
||||
while (unsigned > 0x7F) {
|
||||
out.write((int) ((unsigned & 0x7F) | 0x80));
|
||||
unsigned >>>= 7;
|
||||
}
|
||||
out.write((int) unsigned);
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL DECODING ====================
|
||||
|
||||
/**
|
||||
* Read a varint from data at the given position.
|
||||
* Returns [value, newPosition].
|
||||
*/
|
||||
private static int[] readVarint(byte[] data, int pos) {
|
||||
int result = 0;
|
||||
int shift = 0;
|
||||
while (pos < data.length) {
|
||||
int b = data[pos] & 0xFF;
|
||||
pos++;
|
||||
result |= (b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) {
|
||||
break;
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
return new int[]{result, pos};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a double (8 bytes, little-endian) from data at the given position.
|
||||
*/
|
||||
private static double readDouble(byte[] data, int pos) {
|
||||
ByteBuffer buf = ByteBuffer.wrap(data, pos, 8).order(ByteOrder.LITTLE_ENDIAN);
|
||||
return buf.getDouble();
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a field of the given wire type. Returns new position.
|
||||
*/
|
||||
private static int skipField(byte[] data, int pos, int wireType) {
|
||||
switch (wireType) {
|
||||
case WIRETYPE_VARINT:
|
||||
while (pos < data.length && (data[pos] & 0x80) != 0) {
|
||||
pos++;
|
||||
}
|
||||
return pos + 1; // skip the last byte of varint
|
||||
case WIRETYPE_64BIT:
|
||||
return pos + 8;
|
||||
case WIRETYPE_LENGTH_DELIMITED:
|
||||
int[] lenResult = readVarint(data, pos);
|
||||
return lenResult[1] + lenResult[0];
|
||||
case 5: // 32-bit
|
||||
return pos + 4;
|
||||
default:
|
||||
return data.length; // unknown wire type, skip to end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package app.revanced.extension.youtube.patches.voiceovertranslation;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.protos.youtube.api.innertube.StreamingDataOuterClass.StreamingData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import app.revanced.extension.shared.innertube.utils.StreamingDataOuterClassUtils;
|
||||
import app.revanced.extension.shared.patches.spoof.SpoofStreamingDataPatch;
|
||||
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
|
||||
import static app.revanced.extension.shared.utils.StringRef.str;
|
||||
import app.revanced.extension.youtube.shared.VideoInformation;
|
||||
|
||||
public final class VotStreamReplacer {
|
||||
|
||||
private static volatile String lastReplacedVideoId = null;
|
||||
private static volatile String skipReplacementForVideoId = null;
|
||||
private static volatile String replaceOnlyForVideoId = null;
|
||||
private static final int TRANSLATION_TIMEOUT_SEC = 60;
|
||||
private static final int STATUS_FAILED = 0;
|
||||
private static final int STATUS_FINISHED = 1;
|
||||
private static final int STATUS_WAITING = 2;
|
||||
private static final int STATUS_LONG_WAITING = 3;
|
||||
private static final int STATUS_PART_CONTENT = 5;
|
||||
private static final int MAX_POLL_RETRIES = 30;
|
||||
private static final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "vot-stream-replacer");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
@Nullable
|
||||
public static StreamingData process(@NonNull StreamingData stream, @NonNull String videoId) {
|
||||
if (!Settings.VOT_ENABLED.get()) {
|
||||
return stream;
|
||||
}
|
||||
if (skipReplacementForVideoId != null && videoId.equals(skipReplacementForVideoId)) {
|
||||
skipReplacementForVideoId = null;
|
||||
return stream;
|
||||
}
|
||||
if (replaceOnlyForVideoId == null || !videoId.equals(replaceOnlyForVideoId)) {
|
||||
return stream;
|
||||
}
|
||||
Utils.runOnMainThread(() -> Utils.showToastShort(str("revanced_vot_stream_requesting")));
|
||||
String sourceLang = Settings.VOT_SOURCE_LANGUAGE.get();
|
||||
String targetLang = Settings.VOT_TARGET_LANGUAGE.get();
|
||||
if (sourceLang != null && !sourceLang.isEmpty() && !"auto".equalsIgnoreCase(sourceLang)
|
||||
&& sourceLang.equals(targetLang)) {
|
||||
return stream;
|
||||
}
|
||||
long durationMs = StreamingDataOuterClassUtils.getApproxDurationMsFromFirstFormat(stream);
|
||||
double durationSec = durationMs / 1000.0;
|
||||
if (durationSec <= 0) durationSec = 60.0;
|
||||
if (durationSec > 4 * 3600) {
|
||||
return stream;
|
||||
}
|
||||
List<?> formats = StreamingDataOuterClassUtils.getAdaptiveFormats(stream);
|
||||
int audioCount = 0;
|
||||
if (formats != null) {
|
||||
for (Object f : formats) {
|
||||
if (StreamingDataOuterClassUtils.isAudioOnlyFormat(f)) audioCount++;
|
||||
}
|
||||
}
|
||||
String title = VideoInformation.getVideoTitle();
|
||||
if (title == null) title = "";
|
||||
final double durationSecFinal = durationSec;
|
||||
final String titleFinal = title;
|
||||
final String youtubeUrlFinal = "https://youtu.be/" + videoId;
|
||||
|
||||
Callable<StreamingData> task = () -> {
|
||||
long deadline = System.currentTimeMillis() + TRANSLATION_TIMEOUT_SEC * 1000L;
|
||||
int waitSeconds = 5;
|
||||
VotApiClient.TranslationResult result = null;
|
||||
boolean hadWaiting = false;
|
||||
|
||||
for (int retry = 0; retry < MAX_POLL_RETRIES && System.currentTimeMillis() < deadline; retry++) {
|
||||
result = VotApiClient.requestTranslation(
|
||||
youtubeUrlFinal, durationSecFinal, sourceLang, targetLang, titleFinal);
|
||||
if (result == null) {
|
||||
waitSeconds = 5;
|
||||
try { Thread.sleep(1000L * Math.min(waitSeconds, 10)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return stream; }
|
||||
continue;
|
||||
}
|
||||
if (result.status == STATUS_FINISHED || result.status == STATUS_PART_CONTENT) {
|
||||
break;
|
||||
}
|
||||
if (result.status == STATUS_FAILED) {
|
||||
if (hadWaiting) {
|
||||
Utils.runOnMainThread(() -> Utils.showToastShort(str("revanced_vot_stream_not_ready")));
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
if (result.status == STATUS_WAITING || result.status == STATUS_LONG_WAITING) {
|
||||
hadWaiting = true;
|
||||
if (retry == 0) {
|
||||
Utils.runOnMainThread(() -> Utils.showToastShort(str("revanced_vot_stream_waiting")));
|
||||
}
|
||||
waitSeconds = result.remainingTime > 0 ? result.remainingTime : 5;
|
||||
waitSeconds = Math.min(waitSeconds, (int) ((deadline - System.currentTimeMillis()) / 1000));
|
||||
if (waitSeconds <= 0) break;
|
||||
try {
|
||||
Thread.sleep(1000L * waitSeconds);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result == null || result.audioUrl == null || result.audioUrl.isEmpty()) {
|
||||
if (hadWaiting) {
|
||||
Utils.runOnMainThread(() -> Utils.showToastShort(str("revanced_vot_stream_not_ready")));
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
if (result.status != STATUS_FINISHED && result.status != STATUS_PART_CONTENT) {
|
||||
if (hadWaiting) {
|
||||
Utils.runOnMainThread(() -> Utils.showToastShort(str("revanced_vot_stream_not_ready")));
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
List<?> formatList = StreamingDataOuterClassUtils.getAdaptiveFormats(stream);
|
||||
if (formatList == null || formatList.isEmpty()) {
|
||||
return stream;
|
||||
}
|
||||
int replaced = 0;
|
||||
for (Object format : formatList) {
|
||||
if (StreamingDataOuterClassUtils.isAudioOnlyFormat(format)) {
|
||||
StreamingDataOuterClassUtils.setUrl(format, result.audioUrl);
|
||||
replaced++;
|
||||
}
|
||||
}
|
||||
if (replaced > 0) {
|
||||
lastReplacedVideoId = videoId;
|
||||
replaceOnlyForVideoId = null;
|
||||
Utils.runOnMainThread(() -> Utils.showToastShort(str("revanced_vot_stream_ready")));
|
||||
}
|
||||
return stream;
|
||||
};
|
||||
|
||||
try {
|
||||
Future<StreamingData> future = executor.submit(task);
|
||||
return future.get(TRANSLATION_TIMEOUT_SEC, TimeUnit.SECONDS);
|
||||
} catch (TimeoutException e) {
|
||||
Utils.runOnMainThread(() -> Utils.showToastShort(str("revanced_vot_stream_not_ready")));
|
||||
return stream;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return stream;
|
||||
} catch (ExecutionException e) {
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -706,6 +706,13 @@ public class Settings extends BaseSettings {
|
|||
public static final BooleanSetting RYD_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("ryd_toast_on_connection_error", TRUE, parent(RYD_ENABLED));
|
||||
|
||||
|
||||
// PreferenceScreen: Voice Over Translation
|
||||
public static final BooleanSetting VOT_ENABLED = new BooleanSetting("vot_enabled", TRUE);
|
||||
public static final StringSetting VOT_SOURCE_LANGUAGE = new StringSetting("vot_source_language", "auto", parent(VOT_ENABLED));
|
||||
public static final StringSetting VOT_TARGET_LANGUAGE = new StringSetting("vot_target_language", "ru", parent(VOT_ENABLED));
|
||||
public static final IntegerSetting VOT_TRANSLATION_VOLUME = new IntegerSetting("vot_translation_volume", 100, parent(VOT_ENABLED));
|
||||
public static final StringSetting VOT_PROXY_URL = new StringSetting("vot_proxy_url", "vot-worker.toil.cc", parent(VOT_ENABLED));
|
||||
|
||||
// PreferenceScreen: SponsorBlock
|
||||
public static final BooleanSetting SB_ENABLED = new BooleanSetting("sb_enabled", TRUE);
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import static app.revanced.extension.shared.utils.Utils.getFormattedTimeStamp;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.utils.AlwaysRepeatPatch;
|
||||
|
|
@ -58,6 +61,16 @@ public final class VideoInformation {
|
|||
*/
|
||||
private static float playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
|
||||
|
||||
private static final List<Runnable> playbackSpeedChangeListeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
/**
|
||||
* Add a listener that is run when playback speed changes (setPlaybackSpeed or overridePlaybackSpeed).
|
||||
* Used by VOT to apply the new speed to the translation player.
|
||||
*/
|
||||
public static void addOnPlaybackSpeedChangeListener(Runnable listener) {
|
||||
if (listener != null) playbackSpeedChangeListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
|
|
@ -382,6 +395,83 @@ public final class VideoInformation {
|
|||
return playbackSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to read the current playback speed from the app's player (playbackSpeedClass / timeUpdateReceiver).
|
||||
* Used by VOT to sync translation speed when the user changes speed via any UI (not only the menu we hook).
|
||||
*
|
||||
* @return Speed > 0 if found, otherwise -1f (use {@link #getPlaybackSpeed()} as fallback).
|
||||
*/
|
||||
public static float getPlaybackSpeedFromPlayer() {
|
||||
float v = tryGetSpeedFromObject(getPlaybackSpeedClassRef());
|
||||
if (v > 0f) return v;
|
||||
Object receiver = getTimeUpdateReceiverRef();
|
||||
v = tryGetSpeedFromObject(receiver);
|
||||
if (v > 0f) return v;
|
||||
if (receiver != null) {
|
||||
for (String getterName : new String[]{"getPlayer", "getExoPlayer", "getPlayback", "getWrappedPlayer", "getInnerPlayer", "getAudioComponent", "getController", "getPlaybackController"}) {
|
||||
try {
|
||||
java.lang.reflect.Method m = receiver.getClass().getMethod(getterName);
|
||||
if (m.getParameterCount() == 0 && !m.getReturnType().isPrimitive()) {
|
||||
Object child = m.invoke(receiver);
|
||||
v = tryGetSpeedFromObject(child);
|
||||
if (v > 0f) return v;
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
return -1f;
|
||||
}
|
||||
|
||||
private static float tryGetSpeedFromObject(Object obj) {
|
||||
if (obj == null) return -1f;
|
||||
try {
|
||||
for (String methodName : new String[]{"getPlaybackSpeed", "getSpeed", "getPlaybackRate", "getCurrentSpeed"}) {
|
||||
java.lang.reflect.Method m = findMethod(obj.getClass(), methodName);
|
||||
if (m != null && m.getParameterCount() == 0) {
|
||||
Class<?> ret = m.getReturnType();
|
||||
if (ret == float.class || ret == double.class || ret == Float.class || ret == Double.class) {
|
||||
Object result = m.invoke(obj);
|
||||
if (result != null) {
|
||||
float f = ((Number) result).floatValue();
|
||||
if (f > 0f && f <= 10f) return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ExoPlayer/Media3: getPlaybackParameters().getSpeed()
|
||||
java.lang.reflect.Method getParams = findMethod(obj.getClass(), "getPlaybackParameters");
|
||||
if (getParams != null && getParams.getParameterCount() == 0) {
|
||||
Object params = getParams.invoke(obj);
|
||||
if (params != null) {
|
||||
float fromParams = tryGetSpeedFromObject(params);
|
||||
if (fromParams > 0f) return fromParams;
|
||||
}
|
||||
}
|
||||
for (String fieldName : new String[]{"playbackSpeed", "speed", "playbackRate", "mSpeed"}) {
|
||||
try {
|
||||
java.lang.reflect.Field f = obj.getClass().getDeclaredField(fieldName);
|
||||
f.setAccessible(true);
|
||||
Object val = f.get(obj);
|
||||
if (val instanceof Number) {
|
||||
float speedVal = ((Number) val).floatValue();
|
||||
if (speedVal > 0f && speedVal <= 10f) return speedVal;
|
||||
}
|
||||
} catch (NoSuchFieldException ignored) { }
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
return -1f;
|
||||
}
|
||||
|
||||
private static Object getTimeUpdateReceiverRef() {
|
||||
try {
|
||||
java.lang.reflect.Field f = VideoInformation.class.getDeclaredField("timeUpdateReceiver");
|
||||
f.setAccessible(true);
|
||||
return f.get(null);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*
|
||||
|
|
@ -391,6 +481,9 @@ public final class VideoInformation {
|
|||
if (playbackSpeed != newlyLoadedPlaybackSpeed) {
|
||||
Logger.printDebug(() -> "Video speed changed: " + newlyLoadedPlaybackSpeed);
|
||||
playbackSpeed = newlyLoadedPlaybackSpeed;
|
||||
for (Runnable r : playbackSpeedChangeListeners) {
|
||||
try { r.run(); } catch (Exception e) { Logger.printException(() -> "Playback speed listener", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +558,160 @@ public final class VideoInformation {
|
|||
*/
|
||||
public static void overridePlaybackSpeed(float speedOverride) {
|
||||
Logger.printDebug(() -> "Overriding playback speed to: " + speedOverride);
|
||||
if (playbackSpeed != speedOverride) {
|
||||
playbackSpeed = speedOverride;
|
||||
for (Runnable r : playbackSpeedChangeListeners) {
|
||||
try { r.run(); } catch (Exception e) { Logger.printException(() -> "Playback speed listener", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current ExoPlayer volume (0..1).
|
||||
* Uses reflection on playbackSpeedClass (set by patch). Used by VOT to restore volume when unmuting.
|
||||
*
|
||||
* @return current volume or 1.0f if player not available
|
||||
*/
|
||||
public static float getPlayerVolume() {
|
||||
Object target = getVolumeTarget();
|
||||
if (target == null) return 1.0f;
|
||||
try {
|
||||
java.lang.reflect.Method getVol = findMethod(target.getClass(), "getVolume");
|
||||
if (getVol != null) {
|
||||
Object result = getVol.invoke(target);
|
||||
if (result instanceof Number) {
|
||||
float v = ((Number) result).floatValue();
|
||||
return (v > 1.0f) ? (v / 100.0f) : v; // normalize 0-100 to 0-1
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.printDebug(() -> "getPlayerVolume: " + e.getMessage());
|
||||
}
|
||||
return 1.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ExoPlayer volume (0 = mute, 1 = full). Used by VOT to mute original when translation is playing.
|
||||
*
|
||||
* @param volume volume in 0..1
|
||||
*/
|
||||
public static void setPlayerVolume(float volume) {
|
||||
Object target = getVolumeTarget();
|
||||
if (target == null) {
|
||||
Logger.printInfo(() -> "setPlayerVolume: no target (playbackSpeedClass/timeUpdateReceiver or resolveVolumeTarget returned null). Mute may not work.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Class<?> c = target.getClass();
|
||||
java.lang.reflect.Method setVol = findMethod(c, "setVolume", float.class);
|
||||
if (setVol != null) {
|
||||
setVol.invoke(target, volume);
|
||||
Logger.printDebug(() -> "setPlayerVolume: set " + volume + " on " + c.getSimpleName());
|
||||
return;
|
||||
}
|
||||
setVol = findMethod(c, "setVolume", int.class);
|
||||
if (setVol != null) {
|
||||
setVol.invoke(target, Math.round(volume * 100));
|
||||
Logger.printDebug(() -> "setPlayerVolume: set (int) " + Math.round(volume * 100) + " on " + c.getSimpleName());
|
||||
return;
|
||||
}
|
||||
setVol = findMethod(c, "setVolume", double.class);
|
||||
if (setVol != null) {
|
||||
setVol.invoke(target, (double) volume);
|
||||
Logger.printDebug(() -> "setPlayerVolume: set (double) on " + c.getSimpleName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Logger.printInfo(() -> "setPlayerVolume failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Set by patch from the method that reports video time (p0). Used as fallback when playbackSpeedClass is null. */
|
||||
private static volatile Object timeUpdateReceiver;
|
||||
|
||||
public static void setTimeUpdateReceiver(Object receiver) {
|
||||
timeUpdateReceiver = receiver;
|
||||
}
|
||||
|
||||
private static Object getVolumeTarget() {
|
||||
// 1) Try playbackSpeedClass (set when speed menu / player is used)
|
||||
Object obj = getPlaybackSpeedClassRef();
|
||||
if (obj != null) {
|
||||
Object target = resolveVolumeTarget(obj);
|
||||
if (target != null) return target;
|
||||
}
|
||||
// 2) Fallback: object from the method that reports video time (set every ~100ms while playing)
|
||||
obj = timeUpdateReceiver;
|
||||
if (obj != null) {
|
||||
Object target = resolveVolumeTarget(obj);
|
||||
if (target != null) return target;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object getPlaybackSpeedClassRef() {
|
||||
try {
|
||||
java.lang.reflect.Field f = VideoInformation.class.getDeclaredField("playbackSpeedClass");
|
||||
f.setAccessible(true);
|
||||
return f.get(null);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object resolveVolumeTarget(Object obj) {
|
||||
if (obj == null) return null;
|
||||
Class<?> clazz = obj.getClass();
|
||||
if (hasVolumeMethods(clazz)) return obj;
|
||||
if (hasSetVolumeOnly(clazz)) return obj;
|
||||
String[] getterNames = {
|
||||
"getAudioComponent", "getPlayer", "getExoPlayer",
|
||||
"getWrappedPlayer", "getInnerPlayer", "getPlayback"
|
||||
};
|
||||
for (String name : getterNames) {
|
||||
try {
|
||||
for (java.lang.reflect.Method m : clazz.getMethods()) {
|
||||
if (!m.getName().equals(name) || m.getParameterCount() != 0) continue;
|
||||
Class<?> ret = m.getReturnType();
|
||||
if (ret.isPrimitive() || ret == String.class) continue;
|
||||
Object child = m.invoke(obj);
|
||||
if (child != null && (hasVolumeMethods(child.getClass()) || hasSetVolumeOnly(child.getClass())))
|
||||
return child;
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
for (java.lang.reflect.Method m : clazz.getMethods()) {
|
||||
if (m.getParameterCount() != 0 || m.getReturnType().isPrimitive()) continue;
|
||||
String name = m.getName();
|
||||
if (!name.startsWith("get") || name.length() < 4) continue;
|
||||
try {
|
||||
Object child = m.invoke(obj);
|
||||
if (child != null && child != obj && (hasVolumeMethods(child.getClass()) || hasSetVolumeOnly(child.getClass())))
|
||||
return child;
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean hasSetVolumeOnly(Class<?> c) {
|
||||
return findMethod(c, "setVolume", float.class) != null || findMethod(c, "setVolume", int.class) != null || findMethod(c, "setVolume", double.class) != null;
|
||||
}
|
||||
|
||||
private static boolean hasVolumeMethods(Class<?> c) {
|
||||
if (findMethod(c, "getVolume") == null) return false;
|
||||
return findMethod(c, "setVolume", float.class) != null
|
||||
|| findMethod(c, "setVolume", int.class) != null
|
||||
|| findMethod(c, "setVolume", double.class) != null;
|
||||
}
|
||||
|
||||
private static java.lang.reflect.Method findMethod(Class<?> c, String name, Class<?>... paramTypes) {
|
||||
for (; c != null; c = c.getSuperclass()) {
|
||||
try {
|
||||
java.lang.reflect.Method m = c.getDeclaredMethod(name, paramTypes);
|
||||
m.setAccessible(true);
|
||||
return m;
|
||||
} catch (NoSuchMethodException ignored) { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package app.revanced.extension.youtube.shared
|
||||
|
||||
import app.revanced.extension.shared.utils.Logger
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* VideoState playback state.
|
||||
|
|
@ -17,6 +18,14 @@ enum class VideoState {
|
|||
|
||||
private val nameToVideoState = entries.associateBy { it.name }
|
||||
|
||||
private val onPlayingListeners = CopyOnWriteArrayList<Runnable>()
|
||||
|
||||
/** Add a listener that is run when state changes to PLAYING. Used e.g. by VOT to resume translation. */
|
||||
@JvmStatic
|
||||
fun addOnPlayingListener(listener: Runnable) {
|
||||
onPlayingListeners.add(listener)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setFromString(enumName: String) {
|
||||
val state = nameToVideoState[enumName]
|
||||
|
|
@ -33,8 +42,16 @@ enum class VideoState {
|
|||
private set(type) {
|
||||
if (currentVideoState != type) {
|
||||
Logger.printDebug { "Changed to: $type" }
|
||||
|
||||
currentVideoState = type
|
||||
if (type == PLAYING) {
|
||||
for (listener in onPlayingListeners) {
|
||||
try {
|
||||
listener.run()
|
||||
} catch (e: Exception) {
|
||||
Logger.printException { "OnPlaying listener error: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1538,6 +1538,11 @@ public final class app/revanced/patches/youtube/video/videoid/VideoIdPatchKt {
|
|||
public static final fun getVideoIdPatch ()Lapp/revanced/patcher/patch/BytecodePatch;
|
||||
}
|
||||
|
||||
public final class app/revanced/patches/youtube/video/voiceovertranslation/VoiceOverTranslationPatchKt {
|
||||
public static final fun getVoiceOverTranslationBytecodePatch ()Lapp/revanced/patcher/patch/BytecodePatch;
|
||||
public static final fun getVoiceOverTranslationPatch ()Lapp/revanced/patcher/patch/ResourcePatch;
|
||||
}
|
||||
|
||||
public final class app/revanced/util/BytecodeUtilsKt {
|
||||
public static final field REGISTER_TEMPLATE_REPLACEMENT Ljava/lang/String;
|
||||
public static final fun addInstructionsAtControlFlowLabel (Lapp/revanced/patcher/util/proxy/mutableTypes/MutableMethod;ILjava/lang/String;)V
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ private var preferenceKey = mutableMapOf(
|
|||
"revanced_preference_screen_video" to "M 443.231 546.231 L 657.077 409.231 L 443.231 272.231 L 443.231 546.231 Z M 296.923 698.462 Q 273.865 698.462 257.702 682.298 Q 241.538 666.135 241.538 643.077 L 241.538 175.384 Q 241.538 152.327 257.702 136.163 Q 273.865 120 296.923 120 L 764.616 120 Q 787.673 120 803.837 136.163 Q 820 152.327 820 175.384 L 820 643.077 Q 820 666.135 803.837 682.298 Q 787.673 698.462 764.616 698.462 L 296.923 698.462 Z M 296.923 667.693 L 764.616 667.693 Q 773.846 667.693 781.539 660 Q 789.231 652.308 789.231 643.077 L 789.231 175.384 Q 789.231 166.154 781.539 158.461 Q 773.846 150.769 764.616 150.769 L 296.923 150.769 Q 287.692 150.769 280 158.461 Q 272.308 166.154 272.308 175.384 L 272.308 643.077 Q 272.308 652.308 280 660 Q 287.692 667.693 296.923 667.693 Z M 195.384 800 Q 172.327 800 156.163 783.837 Q 140 767.674 140 744.616 L 140 246.154 L 170.769 246.154 L 170.769 744.616 Q 170.769 753.847 178.461 761.539 Q 186.154 769.231 195.384 769.231 L 693.847 769.231 L 693.847 800 L 195.384 800 Z M 272.308 150.769 L 272.308 667.693 L 272.308 150.769 Z",
|
||||
"revanced_preference_screen_ryd" to "M 699.074 481.004 C 698.563 481.043 698.053 481.102 697.547 481.182 C 681.175 483.76 666.913 493.845 659.026 508.423 L 658.268 509.993 C 657.981 510.654 655.82 515.661 655.65 516.147 L 552.982 809.509 C 550.494 816.617 543.178 820.945 535.75 819.701 L 499.996 813.754 C 443.044 804.239 400.895 754.465 400.895 696.724 C 400.895 688.361 401.779 680.022 403.533 671.846 L 424.913 572.027 C 426.104 566.465 424.718 560.664 421.142 556.241 C 417.567 551.818 412.184 549.249 406.497 549.249 L 266.917 549.249 C 216.923 549.241 173.262 514.231 162.391 465.436 C 155.707 435.053 162.51 403.217 181.029 378.221 C 181.029 378.221 185.968 373.415 185.445 369.345 C 185.87 366.286 183.58 359.867 183.58 359.867 L 183.553 359.755 C 174.778 321.998 186.15 282.272 213.577 254.879 C 213.577 254.879 218.251 250.166 218.313 247.302 C 219.183 245.036 219.101 240.49 219.101 240.49 L 219.419 233.28 C 219.941 227.405 221.116 221.612 222.933 216.007 L 226.37 206.831 C 243.703 166.432 283.635 140.094 327.616 140.087 L 451.39 140.087 L 464.541 140.321 C 524.276 142.292 582.784 157.838 635.617 185.78 L 647.133 192.125 L 656.293 197.35 C 667.839 203.951 680.756 207.842 694.046 208.72 L 694.809 208.755 L 699.755 208.898 L 767.398 208.898 C 785.349 208.898 800.121 223.67 800.121 241.621 L 800.121 447.782 C 800.121 465.733 785.349 480.505 767.398 480.505 L 705.873 480.505 L 699.074 481.004 Z M 626.348 505.839 C 638.156 472.139 670.165 449.437 705.875 449.43 L 750.212 449.43 C 760.613 449.43 769.046 440.998 769.046 430.596 L 769.046 258.808 C 769.046 248.406 760.613 239.974 750.212 239.974 L 699.756 239.974 C 679.092 239.972 658.784 234.574 640.848 224.314 L 631.3 218.836 L 621.228 213.29 C 568.945 185.613 510.676 171.149 451.519 171.162 L 327.58 171.162 C 293.567 171.162 263.335 192.941 252.553 225.286 L 252.075 226.978 C 251.938 227.553 250.907 231.924 250.833 232.354 C 250.402 234.836 250.143 240.006 250.143 240.075 L 250.143 262.237 L 235.484 276.872 C 218.122 294.233 209.668 318.351 212.085 342.424 L 212.273 343.793 L 213.512 351.297 L 213.791 352.736 L 219.717 378.176 L 205.987 396.75 C 196.224 409.803 190.913 425.775 190.913 442.18 C 190.913 483.868 225.217 518.173 266.905 518.173 L 406.477 518.173 C 433.872 518.205 456.396 540.756 456.396 568.15 C 456.396 571.639 456.031 575.119 455.306 578.531 L 433.899 678.316 C 432.605 684.35 431.953 690.505 431.953 696.677 C 431.953 739.296 463.063 776.035 505.099 783.057 L 505.271 783.086 L 512.122 784.228 C 512.122 784.228 522.69 787.048 526.374 782.204 C 529.856 780.169 534.282 768.803 533.196 771.907 L 626.348 505.839 Z",
|
||||
"revanced_preference_screen_return_youtube_username" to "M 480 840 Q 405.46 840 339.72 811.66 Q 273.99 783.32 225.36 734.74 Q 176.73 686.16 148.37 620.48 Q 120 554.81 120 480.13 Q 120 405.46 148.34 339.72 Q 176.68 273.99 225.26 225.36 Q 273.84 176.73 339.52 148.37 Q 405.19 120 479.87 120 Q 554.54 120 620.28 148.35 Q 686.01 176.7 734.64 225.3 Q 783.27 273.9 811.63 339.6 Q 840 405.3 840 480 L 840 516.85 Q 840 565.92 805.78 599.81 Q 771.55 633.69 721.69 633.69 Q 685.4 633.69 655.47 612.73 Q 625.54 591.77 613.92 557.46 Q 591.77 593 556.48 613.35 Q 521.2 633.69 480 633.69 Q 416.24 633.69 371.16 588.92 Q 326.08 544.15 326.08 479.6 Q 326.08 415.05 371.16 370.45 Q 416.24 325.85 480 325.85 Q 543.76 325.85 588.84 370.45 Q 633.92 415.06 633.92 480.09 L 633.92 516.85 Q 633.92 552.84 659.88 577.88 Q 685.85 602.92 721.58 602.92 Q 757.31 602.92 783.27 577.88 Q 809.23 552.84 809.23 516.85 L 809.23 480 Q 809.23 342.13 713.57 246.45 Q 617.91 150.77 480.07 150.77 Q 342.24 150.77 246.5 246.43 Q 150.77 342.09 150.77 479.93 Q 150.77 617.76 246.45 713.5 Q 342.13 809.23 480 809.23 L 686.31 809.23 L 686.31 840 L 480 840 Z M 480.1 602.92 Q 531.46 602.92 567.31 567.07 Q 603.15 531.22 603.15 480 Q 603.15 427.92 567.2 392.27 Q 531.25 356.62 479.9 356.62 Q 428.54 356.62 392.69 392.35 Q 356.85 428.08 356.85 480.27 Q 356.85 530.96 392.8 566.94 Q 428.75 602.92 480.1 602.92 Z",
|
||||
"revanced_preference_screen_vot" to "captions_key",
|
||||
"revanced_preference_screen_sb" to "M 480.005 130.15 C 368.854 130.11 259.451 157.66 161.696 210.28 C 141.953 220.92 129.797 241.61 130.157 263.94 C 133.403 492.55 252.34 700.85 448.319 821.11 C 467.813 832.77 492.186 832.77 511.677 821.11 C 707.657 700.82 826.582 492.55 829.843 263.94 C 830.21 241.6 818.052 220.91 798.3 210.27 C 700.548 157.65 591.149 130.11 480.005 130.15 Z M 482.832 160.6 C 587.914 161.07 691.258 187.32 783.7 237.03 C 793.405 242.32 799.392 252.5 799.273 263.5 C 796.079 488.36 675.506 684.81 495.63 795.19 C 486.038 801.03 473.96 801.03 464.37 795.19 C 284.494 684.81 163.918 488.36 160.724 263.5 C 160.604 252.5 166.59 242.32 176.294 237.03 C 270.425 186.4 375.831 160.13 482.832 160.6 Z M 397.267 297.03 L 397.267 588.83 L 651.282 442.93 L 397.267 297.03 Z",
|
||||
"revanced_preference_screen_misc" to "M 658.231 466.308 L 495.231 303.308 L 658.231 140.307 L 821.231 303.308 L 658.231 466.308 Z M 184.615 416.615 L 184.615 184.846 L 415.615 184.846 L 415.615 416.615 L 184.615 416.615 Z M 543.385 775.385 L 543.385 544.385 L 775.154 544.385 L 775.154 775.385 L 543.385 775.385 Z M 184.615 775.385 L 184.615 544.385 L 415.615 544.385 L 415.615 775.385 L 184.615 775.385 Z M 215.384 385.846 L 384.846 385.846 L 384.846 215.615 L 215.384 215.615 L 215.384 385.846 Z M 660.462 425.308 L 780.231 305.538 L 660.462 185 L 539.923 305.538 L 660.462 425.308 Z M 574.154 744.616 L 744.385 744.616 L 744.385 575.154 L 574.154 575.154 L 574.154 744.616 Z M 215.384 744.616 L 384.846 744.616 L 384.846 575.154 L 215.384 575.154 L 215.384 744.616 Z M 384.846 385.846 Z M 539.923 305.538 Z M 384.846 575.154 Z M 574.154 575.154 Z",
|
||||
"revanced_translations" to "M 557.85 719.92 L 513.69 830.23 Q 512.23 834.38 507.81 837.19 Q 503.38 840 499 840 Q 490.69 840 485.92 832.77 Q 481.15 825.54 484.85 818.23 L 647.15 411.69 Q 648.85 406.08 653.35 403.04 Q 657.85 400 663.46 400 L 677.85 400 Q 682.69 400 687.58 403.04 Q 692.46 406.08 694.15 411.69 L 856.46 817.46 Q 860.15 825.54 855.38 832.77 Q 850.62 840 842.31 840 Q 836.92 840 832.5 837.19 Q 828.08 834.38 826.62 830 L 782.46 719.92 L 557.85 719.92 Z M 356.85 528.23 L 173 713.38 Q 167.85 717.54 161.62 717.65 Q 155.38 717.77 151 713.38 Q 145.85 708.23 145.85 702.38 Q 145.85 696.54 151 691.38 L 335.62 506 Q 293.85 463 257.04 407.35 Q 220.23 351.69 206.85 310.77 L 241.08 310.77 Q 254 346.08 286.88 395.77 Q 319.77 445.46 357.85 483.77 Q 416.15 424.23 458.23 355.88 Q 500.31 287.54 512.69 230.77 L 110.77 230.77 Q 103.92 230.77 99.65 226.5 Q 95.38 222.23 95.38 215.38 Q 95.38 208.54 99.65 204.27 Q 103.92 200 110.77 200 L 344.62 200 L 344.62 166.15 Q 344.62 159.31 348.88 155.04 Q 353.15 150.77 360 150.77 Q 366.85 150.77 371.12 155.04 Q 375.38 159.31 375.38 166.15 L 375.38 200 L 609.23 200 Q 616.08 200 620.35 204.27 Q 624.62 208.54 624.62 215.38 Q 624.62 222.23 620.35 226.5 Q 616.08 230.77 609.23 230.77 L 546.23 230.77 Q 530 296.46 485.5 370.27 Q 441 444.08 379.08 506.23 L 483.38 613.31 L 470.92 645.54 L 356.85 528.23 Z M 569.62 691.08 L 770.69 691.08 L 670.54 438.46 L 569.62 691.08 Z",
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ val overlayButtonsPatch = resourcePatch(
|
|||
"MuteVolumeButton",
|
||||
"PlayAllButton",
|
||||
"PlaybackSpeedDialogButton",
|
||||
"VoiceOverTranslationButton",
|
||||
"WhitelistButton",
|
||||
).forEach { className ->
|
||||
injectControl("$OVERLAY_BUTTONS_PATH/${className};", false)
|
||||
|
|
@ -171,6 +172,8 @@ val overlayButtonsPatch = resourcePatch(
|
|||
"playlist_shuffle_button.xml",
|
||||
"revanced_repeat_button.xml",
|
||||
"revanced_mute_volume_button.xml",
|
||||
"revanced_vot_button.xml",
|
||||
"revanced_vot_button_icon.xml",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -265,6 +265,10 @@ internal enum class PatchList(
|
|||
"Video playback",
|
||||
"Adds options to customize settings related to video playback, such as default video quality and playback speed."
|
||||
),
|
||||
VOICE_OVER_TRANSLATION(
|
||||
"Voice Over Translation",
|
||||
"Adds an option to enable Yandex voice-over translation of video audio tracks."
|
||||
),
|
||||
VISUAL_PREFERENCES_ICONS_FOR_YOUTUBE(
|
||||
"Visual preferences icons for YouTube",
|
||||
"Adds icons to specific preferences in the settings."
|
||||
|
|
|
|||
|
|
@ -385,6 +385,13 @@ val videoInformationPatch = bytecodePatch(
|
|||
it.getWalkerMethod(it.patternMatch!!.startIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store receiver (p0) of the time-update method so VOT can use it for volume when playbackSpeedClass is null.
|
||||
*/
|
||||
videoTimeConstructorMethod.addInstruction(
|
||||
videoTimeConstructorInsertIndex++,
|
||||
"invoke-static { p0 }, $EXTENSION_CLASS_DESCRIPTOR->setTimeUpdateReceiver(Ljava/lang/Object;)V"
|
||||
)
|
||||
/**
|
||||
* Set current video time
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
package app.revanced.patches.youtube.video.voiceovertranslation
|
||||
|
||||
import app.revanced.patcher.patch.bytecodePatch
|
||||
import app.revanced.patcher.patch.resourcePatch
|
||||
import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE
|
||||
import app.revanced.patches.youtube.utils.extension.Constants.EXTENSION_PATH
|
||||
import app.revanced.patches.youtube.utils.patch.PatchList.VOICE_OVER_TRANSLATION
|
||||
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
|
||||
import app.revanced.patches.youtube.utils.settings.settingsPatch
|
||||
import app.revanced.patches.youtube.player.overlaybuttons.overlayButtonsPatch
|
||||
import app.revanced.patches.youtube.video.information.hookVideoInformation
|
||||
import app.revanced.patches.youtube.video.information.onCreateHook
|
||||
import app.revanced.patches.youtube.video.information.videoInformationPatch
|
||||
import app.revanced.patches.youtube.video.information.videoTimeHook
|
||||
|
||||
private const val EXTENSION_VOT_PATH =
|
||||
"$EXTENSION_PATH/patches/voiceovertranslation"
|
||||
|
||||
private const val EXTENSION_VOT_CLASS_DESCRIPTOR =
|
||||
"$EXTENSION_VOT_PATH/VoiceOverTranslationPatch;"
|
||||
|
||||
val voiceOverTranslationBytecodePatch = bytecodePatch(
|
||||
description = "voiceOverTranslationBytecodePatch"
|
||||
) {
|
||||
dependsOn(
|
||||
videoInformationPatch,
|
||||
)
|
||||
|
||||
execute {
|
||||
// Hook video time updates for audio sync
|
||||
videoTimeHook(
|
||||
EXTENSION_VOT_CLASS_DESCRIPTOR,
|
||||
"setVideoTime"
|
||||
)
|
||||
|
||||
// Hook player initialization
|
||||
onCreateHook(
|
||||
EXTENSION_VOT_CLASS_DESCRIPTOR,
|
||||
"initialize"
|
||||
)
|
||||
|
||||
// Hook new video started event to trigger translation
|
||||
hookVideoInformation(
|
||||
"$EXTENSION_VOT_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
val voiceOverTranslationPatch = resourcePatch(
|
||||
VOICE_OVER_TRANSLATION.title,
|
||||
VOICE_OVER_TRANSLATION.summary,
|
||||
) {
|
||||
compatibleWith(COMPATIBLE_PACKAGE)
|
||||
|
||||
dependsOn(
|
||||
overlayButtonsPatch,
|
||||
voiceOverTranslationBytecodePatch,
|
||||
settingsPatch,
|
||||
)
|
||||
|
||||
execute {
|
||||
/**
|
||||
* Add settings
|
||||
*/
|
||||
addPreference(
|
||||
arrayOf(
|
||||
"PREFERENCE_SCREEN: VOICE_OVER_TRANSLATION"
|
||||
),
|
||||
VOICE_OVER_TRANSLATION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector android:constantSize="true"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_activated="true" android:drawable="@drawable/revanced_vot_button_icon" android:tint="?ytCallToAction" />
|
||||
<item android:drawable="@drawable/revanced_vot_button_icon" android:tint="@color/yt_white1" />
|
||||
</selector>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material Design Translate icon (24dp), standard for translation -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12.87,15.07l-2.54,-2.51l0.03,-0.03c1.74,-1.94 2.98,-4.17 3.71,-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5,7.92 10.44,9.75 9,11.35 8.07,10.32 7.3,9.19 6.69,8h-2c0.73,1.63 1.73,3.17 2.98,4.56l-5.09,5.02L4,19l5,-5 3.11,3.11 0.76,-2.04zM18.5,10h-2L12,22h2l1.12,-3h4.75L21,22h2l-4.5,-12zM15.88,17l1.62,-4.33L19.12,17h-3.24z" />
|
||||
</vector>
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:yt="http://schemas.android.com/apk/res-auto" android:id="@+id/youtube_controls_bottom_ui_container" android:layout_width="match_parent" android:layout_height="wrap_content" android:layoutDirection="ltr">
|
||||
<View android:id="@+id/revanced_overlay_buttons_bottom_margin" android:layout_width="fill_parent" android:layout_height="0.0dip" yt:layout_constraintBottom_toTopOf="@+id/time_bar_reference_view" />
|
||||
<com.google.android.libraries.youtube.common.ui.TouchImageView android:id="@+id/revanced_gemini_button" android:contentDescription="@string/revanced_overlay_button_gemini_summarize_title" android:paddingTop="12.0dip" android:paddingBottom="12.0dip" android:layout_width="48.0dip" android:layout_height="48.0dip" android:src="@drawable/revanced_gemini_button" android:scaleType="center" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_gemini_button_placeholder" style="@style/YouTubePlayerButton" />
|
||||
<View android:id="@+id/revanced_gemini_button_placeholder" android:visibility="gone" android:layout_width="48.0dip" android:layout_height="48.0dip" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_playback_speed_dialog_button" />
|
||||
<View android:id="@+id/revanced_gemini_button_placeholder" android:visibility="gone" android:layout_width="48.0dip" android:layout_height="48.0dip" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_vot_button" />
|
||||
<com.google.android.libraries.youtube.common.ui.TouchImageView android:id="@+id/revanced_vot_button" android:contentDescription="@string/revanced_overlay_button_vot_title" android:paddingTop="12.0dip" android:paddingBottom="12.0dip" android:layout_width="48.0dip" android:layout_height="48.0dip" android:src="@drawable/revanced_vot_button" android:scaleType="center" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_vot_button_placeholder" style="@style/YouTubePlayerButton" />
|
||||
<View android:id="@+id/revanced_vot_button_placeholder" android:visibility="gone" android:layout_width="48.0dip" android:layout_height="48.0dip" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_playback_speed_dialog_button" />
|
||||
<com.google.android.libraries.youtube.common.ui.TouchImageView android:id="@+id/revanced_playback_speed_dialog_button" android:contentDescription="@string/revanced_overlay_button_speed_dialog_title" android:paddingTop="12.0dip" android:paddingBottom="12.0dip" android:layout_width="48.0dip" android:layout_height="48.0dip" android:src="@drawable/revanced_playback_speed_dialog_button" android:scaleType="center" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_playback_speed_dialog_button_placeholder" style="@style/YouTubePlayerButton" />
|
||||
<View android:id="@+id/revanced_playback_speed_dialog_button_placeholder" android:visibility="gone" android:layout_width="48.0dip" android:layout_height="48.0dip" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_copy_video_url_button" />
|
||||
<com.google.android.libraries.youtube.common.ui.TouchImageView android:id="@+id/revanced_copy_video_url_button" android:contentDescription="@string/revanced_overlay_button_copy_video_url_title" android:paddingTop="12.0dip" android:paddingBottom="12.0dip" android:layout_width="48.0dip" android:layout_height="48.0dip" android:src="@drawable/revanced_copy_button" android:scaleType="center" yt:layout_constraintBottom_toTopOf="@+id/revanced_overlay_buttons_bottom_margin" yt:layout_constraintRight_toLeftOf="@+id/revanced_copy_video_url_button_placeholder" style="@style/YouTubePlayerButton" />
|
||||
|
|
|
|||
|
|
@ -593,4 +593,38 @@
|
|||
<item>REPLACE</item>
|
||||
<item>BLOCK</item>
|
||||
</string-array>
|
||||
<string-array name="revanced_vot_source_language_entries">
|
||||
<item>@string/revanced_vot_lang_auto</item>
|
||||
<item>@string/revanced_language_EN</item>
|
||||
<item>@string/revanced_language_RU</item>
|
||||
<item>@string/revanced_language_DE</item>
|
||||
<item>@string/revanced_language_FR</item>
|
||||
<item>@string/revanced_language_ES</item>
|
||||
<item>@string/revanced_language_IT</item>
|
||||
<item>@string/revanced_language_JA</item>
|
||||
<item>@string/revanced_language_KO</item>
|
||||
<item>@string/revanced_language_ZH</item>
|
||||
</string-array>
|
||||
<string-array name="revanced_vot_source_language_entry_values">
|
||||
<item>auto</item>
|
||||
<item>en</item>
|
||||
<item>ru</item>
|
||||
<item>de</item>
|
||||
<item>fr</item>
|
||||
<item>es</item>
|
||||
<item>it</item>
|
||||
<item>ja</item>
|
||||
<item>ko</item>
|
||||
<item>zh</item>
|
||||
</string-array>
|
||||
<string-array name="revanced_vot_target_language_entries">
|
||||
<item>@string/revanced_language_RU</item>
|
||||
<item>@string/revanced_language_EN</item>
|
||||
<item>@string/revanced_language_KK</item>
|
||||
</string-array>
|
||||
<string-array name="revanced_vot_target_language_entry_values">
|
||||
<item>ru</item>
|
||||
<item>en</item>
|
||||
<item>kk</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1803,6 +1803,32 @@ To show the native playlist download button, turn on \'Spoof app version\' and c
|
|||
<string name="revanced_preference_screen_return_youtube_username_title">Return YouTube Username</string>
|
||||
<string name="revanced_preference_screen_ryd_title">Return YouTube Dislike</string>
|
||||
<string name="revanced_preference_screen_sb_title">SponsorBlock</string>
|
||||
<string name="revanced_preference_screen_vot_title">Voice Over Translation</string>
|
||||
<string name="revanced_vot_enabled_title">Enable Voice Over Translation</string>
|
||||
<string name="revanced_vot_enabled_summary_on">Yandex voice-over translation is enabled</string>
|
||||
<string name="revanced_vot_enabled_summary_off">Yandex voice-over translation is disabled</string>
|
||||
<string name="revanced_vot_lang_auto">Auto-detect</string>
|
||||
<string name="revanced_vot_source_language_title">Source language</string>
|
||||
<string name="revanced_vot_source_language_summary">Select the language of the video audio</string>
|
||||
<string name="revanced_vot_target_language_title">Target language</string>
|
||||
<string name="revanced_vot_target_language_summary">Select the language to translate to</string>
|
||||
<string name="revanced_vot_translation_volume_title">Translation volume</string>
|
||||
<string name="revanced_vot_translation_volume_summary">Volume of the translated audio track (0-100). Default: 100</string>
|
||||
<string name="revanced_vot_proxy_url_title">Proxy server</string>
|
||||
<string name="revanced_vot_proxy_url_summary">VOT worker proxy host. Default: vot-worker.toil.cc</string>
|
||||
<string name="revanced_overlay_button_vot_title">Voice Over Translation</string>
|
||||
<string name="revanced_overlay_button_vot_summary">Show overlay button to toggle voice-over translation for the current video</string>
|
||||
<string name="revanced_vot_unavailable_live">Translation is not available for live streams</string>
|
||||
<string name="revanced_vot_unavailable_too_long">Video is too long for translation (max 4 hours)</string>
|
||||
<string name="revanced_vot_unavailable_same_language">Video audio is already in the target language</string>
|
||||
<string name="revanced_vot_started">Translation started</string>
|
||||
<string name="revanced_vot_stopped">Translation stopped</string>
|
||||
<string name="revanced_vot_already_playing_stream">Translation is already playing (main player)</string>
|
||||
<string name="revanced_vot_stream_waiting">Waiting for translation…</string>
|
||||
<string name="revanced_vot_stream_ready">Translation ready, playing</string>
|
||||
<string name="revanced_vot_stream_not_ready">Translation not ready. Use the button to enable when ready.</string>
|
||||
<string name="revanced_vot_stream_reloading">Reloading video with translation…</string>
|
||||
<string name="revanced_vot_stream_requesting">Requesting translation for stream…</string>
|
||||
<string name="revanced_preference_screen_seekbar_summary">Hide or customize seekbar components.</string>
|
||||
<string name="revanced_preference_screen_seekbar_title">Seekbar</string>
|
||||
<string name="revanced_preference_screen_settings_menu_summary">Hide elements of the YouTube settings menu.</string>
|
||||
|
|
|
|||
|
|
@ -880,6 +880,16 @@
|
|||
</PreferenceScreen>PREFERENCE_SCREEN: RETURN_YOUTUBE_USERNAME -->
|
||||
|
||||
|
||||
<!-- PREFERENCE_SCREEN: VOICE_OVER_TRANSLATION
|
||||
<PreferenceScreen android:title="@string/revanced_preference_screen_vot_title" android:key="revanced_preference_screen_vot">
|
||||
<SwitchPreference android:title="@string/revanced_vot_enabled_title" android:key="vot_enabled" android:summaryOn="@string/revanced_vot_enabled_summary_on" android:summaryOff="@string/revanced_vot_enabled_summary_off" />
|
||||
<app.revanced.extension.shared.settings.preference.CustomDialogListPreference android:entries="@array/revanced_vot_source_language_entries" android:title="@string/revanced_vot_source_language_title" android:key="vot_source_language" android:summary="@string/revanced_vot_source_language_summary" android:dependency="vot_enabled" android:entryValues="@array/revanced_vot_source_language_entry_values" android:defaultValue="auto" />
|
||||
<app.revanced.extension.shared.settings.preference.CustomDialogListPreference android:entries="@array/revanced_vot_target_language_entries" android:title="@string/revanced_vot_target_language_title" android:key="vot_target_language" android:summary="@string/revanced_vot_target_language_summary" android:dependency="vot_enabled" android:entryValues="@array/revanced_vot_target_language_entry_values" android:defaultValue="ru" />
|
||||
<app.revanced.extension.shared.settings.preference.ResettableEditTextPreference android:title="@string/revanced_vot_translation_volume_title" android:key="vot_translation_volume" android:summary="@string/revanced_vot_translation_volume_summary" android:dependency="vot_enabled" />
|
||||
<app.revanced.extension.shared.settings.preference.ResettableEditTextPreference android:title="@string/revanced_vot_proxy_url_title" android:key="vot_proxy_url" android:summary="@string/revanced_vot_proxy_url_summary" android:dependency="vot_enabled" />
|
||||
</PreferenceScreen>PREFERENCE_SCREEN: VOICE_OVER_TRANSLATION -->
|
||||
|
||||
|
||||
<!-- PREFERENCE_SCREEN: SPONSOR_BLOCK
|
||||
<PreferenceScreen android:title="@string/revanced_preference_screen_sb_title" android:key="revanced_preference_screen_sb" >
|
||||
<app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockPreferenceGroup android:title="@string/revanced_preference_group_sb_title" android:key="revanced_preference_group_sb" />
|
||||
|
|
|
|||
|
|
@ -1781,6 +1781,32 @@ Shorts
|
|||
<string name="revanced_preference_screen_return_youtube_username_title">Имя пользователя YouTube - \"RYU\"</string>
|
||||
<string name="revanced_preference_screen_ryd_title">Вернуть YouTube Dislike</string>
|
||||
<string name="revanced_preference_screen_sb_title">SponsorBlock</string>
|
||||
<string name="revanced_preference_screen_vot_title">Закадровый перевод</string>
|
||||
<string name="revanced_vot_enabled_title">Включить закадровый перевод</string>
|
||||
<string name="revanced_vot_enabled_summary_on">Закадровый перевод Яндекс включён</string>
|
||||
<string name="revanced_vot_enabled_summary_off">Закадровый перевод Яндекс выключен</string>
|
||||
<string name="revanced_vot_lang_auto">Автоопределение</string>
|
||||
<string name="revanced_vot_source_language_title">Язык оригинала</string>
|
||||
<string name="revanced_vot_source_language_summary">Выберите язык аудио видео</string>
|
||||
<string name="revanced_vot_target_language_title">Язык перевода</string>
|
||||
<string name="revanced_vot_target_language_summary">Выберите язык перевода</string>
|
||||
<string name="revanced_vot_translation_volume_title">Громкость перевода</string>
|
||||
<string name="revanced_vot_translation_volume_summary">Громкость переведённой аудиодорожки (0-100). По умолчанию: 100</string>
|
||||
<string name="revanced_vot_proxy_url_title">Прокси-сервер</string>
|
||||
<string name="revanced_vot_proxy_url_summary">Хост прокси-сервера VOT worker. По умолчанию: vot-worker.toil.cc</string>
|
||||
<string name="revanced_overlay_button_vot_title">Закадровый перевод</string>
|
||||
<string name="revanced_overlay_button_vot_summary">Показывать кнопку на плеере для включения/выключения закадрового перевода</string>
|
||||
<string name="revanced_vot_unavailable_live">Перевод недоступен для стримов</string>
|
||||
<string name="revanced_vot_unavailable_too_long">Видео слишком длинное для перевода (макс. 4 часа)</string>
|
||||
<string name="revanced_vot_unavailable_same_language">Аудио видео уже на выбранном языке</string>
|
||||
<string name="revanced_vot_started">Перевод запущен</string>
|
||||
<string name="revanced_vot_stopped">Перевод остановлен</string>
|
||||
<string name="revanced_vot_already_playing_stream">Перевод уже воспроизводится (основной плеер)</string>
|
||||
<string name="revanced_vot_stream_waiting">Ожидание перевода…</string>
|
||||
<string name="revanced_vot_stream_ready">Перевод готов, воспроизведение</string>
|
||||
<string name="revanced_vot_stream_not_ready">Перевод ещё не готов. Включите кнопкой, когда будет готов.</string>
|
||||
<string name="revanced_vot_stream_reloading">Перезагрузка видео с переводом…</string>
|
||||
<string name="revanced_vot_stream_requesting">Запрос перевода для потока…</string>
|
||||
<string name="revanced_preference_screen_seekbar_summary">Настроить компоненты шкалы воспроизведения.</string>
|
||||
<string name="revanced_preference_screen_seekbar_title">Шкала воспроизведения</string>
|
||||
<string name="revanced_preference_screen_settings_menu_summary">Настройка меню YouTube.</string>
|
||||
|
|
|
|||
Loading…
Reference in a new issue