feat(YouTube - Voice Over Translation): Add Yandex voice-over translation (#1370)

This commit is contained in:
Jav1x 2026-02-13 15:32:06 +03:00 committed by GitHub
parent 374c2bec4f
commit 889118eb63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1858 additions and 12 deletions

View file

@ -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.

View file

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

View file

@ -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?,

View file

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

View file

@ -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;

View file

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

View file

@ -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) { }
}
}

View file

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

View file

@ -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
}
}
}

View file

@ -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;
}
}
}

View file

@ -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);
/**

View file

@ -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 &gt; 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;
}
/**

View file

@ -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}" }
}
}
}
}
}

View file

@ -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

View file

@ -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",

View file

@ -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",
)
)

View file

@ -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."

View file

@ -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
*/

View file

@ -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
)
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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" />

View file

@ -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>