feat(YouTube - Gemini): Redesign, support multiple API keys, add Ask about this video

Close https://github.com/anddea/revanced-patches/issues/1419
Close https://github.com/anddea/revanced-patches/issues/1453
This commit is contained in:
Aaron Veil 2026-03-26 09:20:47 +03:00
parent 84bfbc7bcd
commit 374b0e55b9
9 changed files with 2422 additions and 610 deletions

View file

@ -10,6 +10,7 @@ import android.app.Dialog;
import android.content.Context;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.graphics.Rect;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.VelocityTracker;
@ -58,21 +59,13 @@ public class SheetBottomDialog {
// Create wrapper layout for side margins.
LinearLayout wrapperLayout = new LinearLayout(context);
wrapperLayout.setOrientation(LinearLayout.VERTICAL);
wrapperLayout.setGravity(Gravity.BOTTOM);
// Create drag container.
DraggableLinearLayout dragContainer = new DraggableLinearLayout(context, animationDuration);
dragContainer.setOrientation(LinearLayout.VERTICAL);
dragContainer.setDialog(dialog);
// Add top spacer.
View spacer = new View(context);
final int dip40 = dipToPixels(40);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, dip40);
spacer.setLayoutParams(spacerParams);
spacer.setClickable(true);
dragContainer.addView(spacer);
// Add content view.
ViewGroup parent = (ViewGroup) contentView.getParent();
if (parent != null) parent.removeView(contentView);
@ -173,6 +166,8 @@ public class SheetBottomDialog {
private SlideDialog dialog;
private float dismissThreshold;
@Nullable
private View touchedScrollableChild;
/**
* Constructs a new {@link DraggableLinearLayout} with the specified context.
@ -223,23 +218,26 @@ public class SheetBottomDialog {
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (!isDragEnabled) return false;
if (dialog == null || !isDragEnabled) return false;
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
initialTouchRawY = ev.getRawY();
float initialTouchX = ev.getX();
float initialTouchY = ev.getY();
isDragging = false;
scroller.forceFinished(true);
removeCallbacks(settleRunnable);
velocityTracker.clear();
velocityTracker.addMovement(ev);
dragOffset = getTranslationY();
touchedScrollableChild = findScrollableChildUnder(this, (int) initialTouchX, (int) initialTouchY);
break;
case MotionEvent.ACTION_MOVE:
float dy = ev.getRawY() - initialTouchRawY;
if (dy > ViewConfiguration.get(getContext()).getScaledTouchSlop()
&& !canChildScrollUp()) {
&& canStartDrag()) {
isDragging = true;
return true; // Intercept touches for drag.
}
@ -255,7 +253,7 @@ public class SheetBottomDialog {
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (!isDragEnabled) return super.onTouchEvent(ev);
if (dialog == null || !isDragEnabled) return super.onTouchEvent(ev);
velocityTracker.addMovement(ev);
switch (ev.getActionMasked()) {
@ -327,28 +325,45 @@ public class SheetBottomDialog {
*
* @return True if a child can scroll upward, false otherwise.
*/
private boolean canChildScrollUp() {
View target = findScrollableChild(this);
return target != null && target.canScrollVertically(-1);
private boolean canStartDrag() {
return touchedScrollableChild == null || !touchedScrollableChild.canScrollVertically(-1);
}
/**
* Recursively searches for a scrollable child view within the given view group.
* Recursively searches for a scrollable child under the given touch point.
*
* @param group The view group to search.
* @return The scrollable child view, or null if none found.
* @param x The X coordinate relative to the current group.
* @param y The Y coordinate relative to the current group.
* @return The scrollable child view under the touch point, or null if none found.
*/
private View findScrollableChild(ViewGroup group) {
for (int i = 0; i < group.getChildCount(); i++) {
@Nullable
private View findScrollableChildUnder(@NonNull ViewGroup group, int x, int y) {
for (int i = group.getChildCount() - 1; i >= 0; i--) {
View child = group.getChildAt(i);
if (child.canScrollVertically(-1)) return child;
if (child.getVisibility() != View.VISIBLE || !isPointInsideChild(child, x, y)) {
continue;
}
int childX = x - child.getLeft() + child.getScrollX();
int childY = y - child.getTop() + child.getScrollY();
if (child instanceof ViewGroup) {
View scroll = findScrollableChild((ViewGroup) child);
View scroll = findScrollableChildUnder((ViewGroup) child, childX, childY);
if (scroll != null) return scroll;
}
if (child.canScrollVertically(-1) || child.canScrollVertically(1)) {
return child;
}
}
return null;
}
private boolean isPointInsideChild(@NonNull View child, int x, int y) {
Rect bounds = new Rect(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
return bounds.contains(x, y);
}
}
/**

View file

@ -41,7 +41,6 @@
package app.morphe.extension.youtube.utils;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
@ -51,19 +50,15 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.util.Pair;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.morphe.extension.shared.settings.AppLanguage;
import app.morphe.extension.shared.ui.SheetBottomDialog;
import app.morphe.extension.shared.utils.IntentUtils;
import app.morphe.extension.shared.utils.Logger;
import app.morphe.extension.youtube.settings.Settings;
@ -87,7 +82,6 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static app.morphe.extension.shared.utils.StringRef.str;
import static app.morphe.extension.shared.utils.Utils.dipToPixels;
import static app.morphe.extension.shared.utils.Utils.showToastLong;
import static app.morphe.extension.shared.utils.Utils.showToastShort;
@ -192,10 +186,28 @@ public final class GeminiManager {
private final ExecutorService metadataExecutor = Executors.newCachedThreadPool();
/**
* Reference to the progress dialog.
* Active loading bottom sheet for summarize/transcribe operations.
*/
@Nullable
private WeakReference<AlertDialog> progressDialogRef;
private GeminiBottomSheetUi.LoadingSheet loadingSheet;
/**
* Active summary bottom sheet used after summarization completes.
*/
@Nullable
private GeminiBottomSheetUi.SummarySheet summarySheet;
/**
* Active transcription bottom sheet used after Gemini transcription completes.
*/
@Nullable
private WeakReference<SheetBottomDialog.SlideDialog> transcriptionDialogRef;
/**
* Active follow-up chat task inside the summary bottom sheet.
*/
@Nullable
private Future<?> activeSummaryChatTask;
/**
* Flag indicating if the progress dialog is minimized.
@ -275,6 +287,11 @@ public final class GeminiManager {
*/
private final Map<String, String> summaryModelCache = new ConcurrentHashMap<>();
/**
* Cache for per-video summary follow-up chat history.
*/
private final Map<String, List<GeminiUtils.ChatMessage>> summaryConversationCache = new ConcurrentHashMap<>();
/**
* Cache for parsed transcriptions.
*/
@ -405,19 +422,27 @@ public final class GeminiManager {
prepareForNewOperationInternal(OperationType.SUMMARIZE, videoUrl);
final String apiKey = Settings.GEMINI_API_KEY.get();
if (isEmptyApiKey(apiKey)) {
final List<String> apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get());
if (isEmptyApiKey(apiKeys)) {
resetOperationStateInternal(OperationType.SUMMARIZE, true);
return;
}
Logger.printDebug(() -> "Starting new summarization workflow: " + videoId);
activeTasks.put(taskKey, DUMMY_FUTURE);
taskStartTimes.put(taskKey, System.currentTimeMillis());
showProgressDialogInternal(context, OperationType.SUMMARIZE);
GeminiUtils.getVideoSummary(videoUrl, apiKey, new GeminiUtils.Callback() {
Future<?> summaryTask = GeminiUtils.getVideoSummary(videoUrl, apiKeys, new GeminiUtils.Callback() {
@Override
public void onPartial(String partialText, String accumulatedText, @Nullable String model) {
if (!activeTasks.containsKey(taskKey)) {
Logger.printDebug(() -> "Summary partial callback ignored - task was cancelled: " + taskKey);
return;
}
ensureMainThread(() -> handleSummaryStreamUpdate(videoUrl, accumulatedText));
}
@Override
public void onSuccess(String result) {
onSuccessWithModel(result, null);
@ -443,6 +468,7 @@ public final class GeminiManager {
handleApiResponseInternal(context, OperationType.SUMMARIZE, videoUrl, null, error);
}
});
activeTasks.put(taskKey, summaryTask != null ? summaryTask : DUMMY_FUTURE);
});
}
@ -605,8 +631,8 @@ public final class GeminiManager {
updateTimerMessageInternal();
}
final String apiKey = Settings.GEMINI_API_KEY.get();
if (isEmptyApiKey(apiKey)) {
final List<String> apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get());
if (isEmptyApiKey(apiKeys)) {
activeTasks.remove(taskKey);
taskStartTimes.remove(taskKey);
if (Objects.equals(currentVideoUrl, videoUrl)) {
@ -618,10 +644,10 @@ public final class GeminiManager {
// Call Gemini for translation
String cleanedJson = YandexVotUtils.stripTokensFromYandexJson(rawIntermediateJson);
GeminiUtils.translateYandexJson(
Future<?> translationTask = GeminiUtils.translateYandexJson(
cleanedJson,
targetLangName,
apiKey,
apiKeys,
new GeminiUtils.Callback() {
@Override
public void onSuccess(String translatedJson) {
@ -644,6 +670,7 @@ public final class GeminiManager {
}
}
);
activeTasks.put(taskKey, translationTask != null ? translationTask : DUMMY_FUTURE);
});
}
@ -697,11 +724,9 @@ public final class GeminiManager {
isWaitingForYandexRetry = true;
baseLoadingMessage = str("revanced_gemini_loading_yandex_transcribe") + "\n(" + statusMessage + ")";
AlertDialog currentDialog = (progressDialogRef != null) ? progressDialogRef.get() : null;
if (currentDialog != null && currentDialog.isShowing() && !isProgressDialogMinimized) {
if (loadingSheet != null && loadingSheet.isShowing() && !isProgressDialogMinimized) {
updateTimerMessageInternal();
} else if (progressDialogRef == null && !isProgressDialogMinimized) {
} else if (loadingSheet == null && !isProgressDialogMinimized) {
showProgressDialogInternal(context, OperationType.TRANSCRIBE);
}
} else {
@ -889,8 +914,8 @@ public final class GeminiManager {
Logger.printInfo(() -> "Starting new Gemini direct transcription workflow: " + videoId);
String taskKey = getTaskKey(videoId, OperationType.TRANSCRIBE);
final String apiKey = Settings.GEMINI_API_KEY.get();
if (isEmptyApiKey(apiKey)) {
final List<String> apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get());
if (isEmptyApiKey(apiKeys)) {
resetOperationStateInternal(OperationType.TRANSCRIBE, true);
taskStartTimes.remove(taskKey);
return;
@ -898,7 +923,7 @@ public final class GeminiManager {
showProgressDialogInternal(context, OperationType.TRANSCRIBE);
GeminiUtils.getVideoTranscription(videoUrl, apiKey, new GeminiUtils.Callback() {
Future<?> transcriptionTask = GeminiUtils.getVideoTranscription(videoUrl, apiKeys, new GeminiUtils.Callback() {
@Override
public void onSuccess(String result) {
if (!activeTasks.containsKey(taskKey)) {
@ -919,6 +944,7 @@ public final class GeminiManager {
handleApiResponseInternal(context, OperationType.TRANSCRIBE, videoUrl, null, error);
}
});
activeTasks.put(taskKey, transcriptionTask != null ? transcriptionTask : DUMMY_FUTURE);
}
/**
@ -962,6 +988,9 @@ public final class GeminiManager {
private void prepareForNewOperationInternal(@NonNull OperationType newOperationType, @NonNull String newVideoUrl) {
Logger.printDebug(() -> "Preparing UI for operation: " + newOperationType + " for URL: " + newVideoUrl);
dismissSummarySheetInternal();
dismissTranscriptionDialogInternal();
isCancelled = false;
isProgressDialogMinimized = false;
isWaitingForYandexRetry = false;
@ -1017,6 +1046,7 @@ public final class GeminiManager {
if (opType == OperationType.SUMMARIZE) {
summaryCache.put(videoId, result);
summaryTimeCache.put(videoId, time);
summaryConversationCache.remove(videoId);
if (!TextUtils.isEmpty(usedModel)) {
summaryModelCache.put(videoId, usedModel);
} else {
@ -1230,11 +1260,11 @@ public final class GeminiManager {
/**
* Checks if the API key is empty and shows a toast if so.
*
* @param key The API key.
* @param apiKeys List of API keys.
* @return True if the key is empty, false otherwise.
*/
private boolean isEmptyApiKey(@Nullable String key) {
if (TextUtils.isEmpty(key)) {
private boolean isEmptyApiKey(@Nullable List<String> apiKeys) {
if (apiKeys == null || apiKeys.isEmpty()) {
showToastLong(str("revanced_gemini_error_no_api_key"));
Logger.printDebug(() -> "isValidApiKey: API key is empty or null.");
return true;
@ -1332,6 +1362,170 @@ public final class GeminiManager {
return "English";
}
@Nullable
private String getVideoMetaText(@Nullable String videoUrl) {
if (TextUtils.isEmpty(videoUrl)) {
return null;
}
String videoId = getVideoIdFromUrl(videoUrl);
String meta = videoMetadataCache.getOrDefault(videoId, "");
if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) {
return meta;
}
return null;
}
@Nullable
private String getCurrentVideoMetaText() {
return getVideoMetaText(currentVideoUrl);
}
@Nullable
private String buildInfoText(int seconds, @Nullable String usedModel) {
StringBuilder infoText = new StringBuilder();
if (!TextUtils.isEmpty(usedModel)) {
infoText.append(usedModel);
}
if (seconds >= 0) {
if (infoText.length() > 0) {
infoText.append('\n');
}
infoText.append(str("revanced_gemini_time_taken", seconds));
}
return infoText.length() == 0 ? null : infoText.toString();
}
@MainThread
private void handleSummaryStreamUpdate(@NonNull String videoUrl, @NonNull String accumulatedText) {
if (!Objects.equals(currentVideoUrl, videoUrl) || isProgressDialogMinimized || loadingSheet == null || !loadingSheet.isShowing()) {
return;
}
loadingSheet.updatePreview(accumulatedText);
}
@MainThread
private void handleSummaryChatSend(
@NonNull String videoUrl,
@NonNull String summary,
@NonNull GeminiBottomSheetUi.SummarySheet sheet
) {
if (summarySheet != sheet || !sheet.isShowing()) {
sheet.failPendingResponse();
return;
}
List<String> apiKeys = GeminiUtils.parseApiKeys(Settings.GEMINI_API_KEY.get());
if (isEmptyApiKey(apiKeys)) {
sheet.failPendingResponse();
return;
}
cancelActiveSummaryChatTaskInternal();
Future<?> chatTask = GeminiUtils.chatWithVideo(
videoUrl,
summary,
sheet.getConversationWithPendingQuestion(),
apiKeys,
new GeminiUtils.Callback() {
@Override
public void onSuccess(String result) {
onSuccessWithModel(result, null);
}
@Override
public void onPartial(String partialText, String accumulatedText, @Nullable String model) {
ensureMainThread(() -> {
if (summarySheet == sheet && sheet.isShowing()) {
sheet.updatePendingResponse(accumulatedText);
}
});
}
@Override
public void onSuccessWithModel(String result, @Nullable String model) {
ensureMainThread(() -> {
if (summarySheet == sheet && sheet.isShowing()) {
sheet.commitPendingResponse(normalizeGeminiText(result));
}
activeSummaryChatTask = null;
});
}
@Override
public void onFailure(String error) {
ensureMainThread(() -> {
if (summarySheet == sheet && sheet.isShowing()) {
sheet.failPendingResponse();
}
activeSummaryChatTask = null;
if (!"Operation cancelled.".equals(error)) {
showToastLong(str("revanced_gemini_error_api_failed", error));
}
});
}
}
);
if (chatTask == null) {
sheet.failPendingResponse();
return;
}
activeSummaryChatTask = chatTask;
}
@MainThread
private void cancelActiveSummaryChatTaskInternal() {
boolean hadActiveTask = activeSummaryChatTask != null;
if (hadActiveTask) {
try {
activeSummaryChatTask.cancel(true);
} catch (Exception e) {
Logger.printException(() -> "Error cancelling Gemini summary chat task", e);
} finally {
activeSummaryChatTask = null;
}
}
if (hadActiveTask && summarySheet != null && summarySheet.isShowing()) {
summarySheet.failPendingResponse();
}
}
@MainThread
private void dismissSummarySheetInternal() {
cancelActiveSummaryChatTaskInternal();
if (summarySheet != null) {
try {
if (summarySheet.isShowing()) {
summarySheet.dismiss();
}
} catch (Exception e) {
Logger.printException(() -> "Error dismissing Gemini summary sheet", e);
} finally {
summarySheet = null;
}
}
}
@MainThread
private void dismissTranscriptionDialogInternal() {
if (transcriptionDialogRef != null) {
SheetBottomDialog.SlideDialog dialog = transcriptionDialogRef.get();
if (dialog != null && dialog.isShowing()) {
try {
dialog.dismiss();
} catch (Exception e) {
Logger.printException(() -> "Error dismissing Gemini transcription sheet", e);
}
}
transcriptionDialogRef.clear();
transcriptionDialogRef = null;
}
}
// endregion Utility Methods
// region UI Methods: Dialogs
@ -1362,97 +1556,55 @@ public final class GeminiManager {
}
rebuildBaseLoadingMessage(opType);
String initialMsg = (baseLoadingMessage != null && !baseLoadingMessage.isEmpty())
? baseLoadingMessage
: str("revanced_gemini_loading_default");
String timeSuffix = "";
int elapsedSeconds = calculateElapsedTimeSeconds();
if (elapsedSeconds >= 0) {
timeSuffix = "\n" + elapsedSeconds + "s";
}
String metaPrefix = "";
if (currentVideoUrl != null) {
String videoId = getVideoIdFromUrl(currentVideoUrl);
String meta = videoMetadataCache.getOrDefault(videoId, "");
if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) {
metaPrefix = meta + "\n\n";
}
}
AlertDialog.Builder builder = new AlertDialog.Builder(context);
if (opType == OperationType.SUMMARIZE) {
builder.setTitle(str("revanced_gemini_summary_title"));
} else {
builder.setTitle(str("revanced_gemini_loading_default"));
}
builder.setMessage(metaPrefix + initialMsg + timeSuffix);
builder.setCancelable(false);
// Cancel Button
builder.setNegativeButton(str("revanced_cancel"), (d, w) -> {
final String urlAtClick = currentVideoUrl;
ensureMainThread(() -> {
if (urlAtClick == null) {
try {d.dismiss();} catch (Exception ignored) {}
return;
}
if (Objects.equals(currentVideoUrl, urlAtClick)) {
Logger.printInfo(() -> opType + " UI cancelled by user for " + urlAtClick);
isCancelled = true;
String videoId = getVideoIdFromUrl(urlAtClick);
String taskKey = getTaskKey(videoId, opType);
Future<?> f = activeTasks.remove(taskKey);
if (f != null) {
try {
f.cancel(true);
} catch (Exception e) {
Logger.printException(() -> "Error cancelling future for task: " + taskKey, e);
}
}
if (opType == OperationType.TRANSCRIBE) {
YandexVotUtils.forceReleaseWorkflowLock(urlAtClick);
}
resetOperationStateInternal(opType, true);
showToastShort(str("revanced_gemini_cancelled"));
}
});
});
// Minimize Button
builder.setNeutralButton(str("revanced_minimize"), (d, w) -> {
final String urlAtClick = currentVideoUrl;
ensureMainThread(() -> {
assert urlAtClick != null;
if (Objects.equals(currentVideoUrl, urlAtClick) && !isProgressDialogMinimized) {
isProgressDialogMinimized = true;
stopTimerInternal();
dismissProgressDialogInternal();
}
});
});
try {
AlertDialog dialog = builder.create();
dialog.show();
final String statusText = (baseLoadingMessage != null && !baseLoadingMessage.isEmpty())
? baseLoadingMessage
: str("revanced_gemini_loading_default");
final String videoUrlAtShow = currentVideoUrl;
Window window = dialog.getWindow();
if (window != null) {
window.setLayout((int) (context.getResources().getDisplayMetrics().widthPixels * 0.90), WindowManager.LayoutParams.WRAP_CONTENT);
}
loadingSheet = new GeminiBottomSheetUi.LoadingSheet(
context,
opType == OperationType.SUMMARIZE
? str("revanced_gemini_summary_title")
: str("revanced_gemini_loading_default"),
getCurrentVideoMetaText(),
statusText,
() -> ensureMainThread(() -> {
if (videoUrlAtShow == null) {
return;
}
if (Objects.equals(currentVideoUrl, videoUrlAtShow)) {
Logger.printInfo(() -> opType + " UI cancelled by user for " + videoUrlAtShow);
isCancelled = true;
progressDialogRef = new WeakReference<>(dialog);
String videoId = getVideoIdFromUrl(videoUrlAtShow);
String taskKey = getTaskKey(videoId, opType);
Future<?> future = activeTasks.remove(taskKey);
if (future != null) {
try {
future.cancel(true);
} catch (Exception e) {
Logger.printException(() -> "Error cancelling future for task: " + taskKey, e);
}
}
if (opType == OperationType.TRANSCRIBE) {
YandexVotUtils.forceReleaseWorkflowLock(videoUrlAtShow);
}
resetOperationStateInternal(opType, true);
showToastShort(str("revanced_gemini_cancelled"));
}
}),
() -> ensureMainThread(() -> {
if (Objects.equals(currentVideoUrl, videoUrlAtShow) && !isProgressDialogMinimized) {
isProgressDialogMinimized = true;
stopTimerInternal();
loadingSheet = null;
}
})
);
updateTimerMessageInternal();
startTimerInternal();
} catch (Exception e) {
Logger.printException(() -> "Error showing progress dialog for " + opType, e);
@ -1471,25 +1623,16 @@ public final class GeminiManager {
@MainThread
private void dismissProgressDialogInternal() {
stopTimerInternal();
if (progressDialogRef != null) {
AlertDialog dialog = progressDialogRef.get();
if (dialog != null && dialog.isShowing()) {
try {
Context ctx = dialog.getContext();
if (ctx instanceof Activity activity) {
if (!activity.isFinishing() && !activity.isDestroyed()) {
dialog.dismiss();
}
} else {
dialog.dismiss();
}
} catch (Exception e) {
Logger.printException(() -> "Error dismissing progress dialog", e);
if (loadingSheet != null) {
try {
if (loadingSheet.isShowing()) {
loadingSheet.dismiss();
}
} catch (Exception e) {
Logger.printException(() -> "Error dismissing progress dialog", e);
} finally {
loadingSheet = null;
}
progressDialogRef.clear();
progressDialogRef = null;
}
}
@ -1505,71 +1648,43 @@ public final class GeminiManager {
*/
@MainThread
private void showSummaryDialog(@NonNull Context context, @NonNull String summary, int seconds, @NonNull String videoUrl, @Nullable String usedModel) {
StringBuilder summaryMetaInfo = new StringBuilder();
if (!TextUtils.isEmpty(usedModel)) summaryMetaInfo.append(usedModel).append('\n');
if (seconds >= 0) summaryMetaInfo.append(str("revanced_gemini_time_taken", seconds));
String timeMsg = summaryMetaInfo.length() > 0 ? "\n\n" + summaryMetaInfo : "";
// Prepare the header (Metadata)
String videoId = getVideoIdFromUrl(videoUrl);
String meta = videoMetadataCache.getOrDefault(videoId, "");
String metaPrefix = "";
if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) {
metaPrefix = meta + "\n\n";
}
String normalizedSummary = normalizeGeminiText(summary);
CharSequence formattedSummary = formatGeminiText(normalizedSummary, videoUrl);
List<GeminiUtils.ChatMessage> cachedConversation = new ArrayList<>(
Objects.requireNonNull(summaryConversationCache.getOrDefault(videoId, Collections.emptyList()))
);
String normalizedSummary = summary.replace("\r\n", "\n").trim();
dismissSummarySheetInternal();
summarySheet = new GeminiBottomSheetUi.SummarySheet(
context,
getVideoMetaText(videoUrl),
buildInfoText(seconds, usedModel),
formattedSummary,
normalizedSummary,
cachedConversation,
text -> formatGeminiText(text, videoUrl),
text -> setClipboard(context, normalizeGeminiText(text), str("revanced_gemini_copy_success")),
history -> summaryConversationCache.put(videoId, new ArrayList<>(history)),
() -> ensureMainThread(() -> {
cancelActiveSummaryChatTaskInternal();
summarySheet = null;
}),
(sheet, question) -> ensureMainThread(() -> handleSummaryChatSend(videoUrl, normalizedSummary, sheet))
);
}
String displaySummary = wrapTimestampReferencesForPillStyle(normalizedSummary);
Spanned formattedSummary = MarkdownUtils.fromMarkdown(displaySummary);
SpannableStringBuilder clickableSummary = createClickableTimestampSummary(formattedSummary, videoUrl);
@NonNull
private String normalizeGeminiText(@NonNull String text) {
return text.replace("\r\n", "\n").trim();
}
SpannableStringBuilder summaryMessage = new SpannableStringBuilder();
if (!metaPrefix.isEmpty()) {
summaryMessage.append(metaPrefix);
}
summaryMessage.append(clickableSummary);
summaryMessage.append(timeMsg);
TextView summaryTextView = new TextView(context);
summaryTextView.setText(summaryMessage);
summaryTextView.setTextColor(ThemeUtils.getAppForegroundColor());
summaryTextView.setTextSize(16f);
summaryTextView.setMovementMethod(LinkMovementMethod.getInstance());
summaryTextView.setLinksClickable(true);
LinearLayout contentLayout = new LinearLayout(context);
contentLayout.setOrientation(LinearLayout.VERTICAL);
contentLayout.setPadding(dipToPixels(20), dipToPixels(10), dipToPixels(20), dipToPixels(8));
contentLayout.addView(summaryTextView, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
ScrollView scrollView = new ScrollView(context);
scrollView.setVerticalScrollBarEnabled(false);
scrollView.addView(contentLayout, new ScrollView.LayoutParams(
ScrollView.LayoutParams.MATCH_PARENT,
ScrollView.LayoutParams.WRAP_CONTENT
));
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(str("revanced_gemini_summary_title"));
builder.setView(scrollView);
AlertDialog dialog = builder
.setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss())
.setNeutralButton(str("revanced_copy"), (d, w) -> setClipboard(context, normalizedSummary, str("revanced_gemini_copy_success")))
.setCancelable(true)
.create();
dialog.show();
Window window = dialog.getWindow();
if (window != null) {
window.setLayout((int) (context.getResources().getDisplayMetrics().widthPixels * 0.90), WindowManager.LayoutParams.WRAP_CONTENT);
}
@NonNull
private CharSequence formatGeminiText(@NonNull String text, @NonNull String videoUrl) {
String normalizedText = normalizeGeminiText(text);
String displayText = wrapTimestampReferencesForPillStyle(normalizedText);
Spanned formattedText = MarkdownUtils.fromMarkdown(displayText);
return createClickableTimestampText(formattedText, videoUrl);
}
@NonNull
@ -1600,14 +1715,14 @@ public final class GeminiManager {
}
@NonNull
private SpannableStringBuilder createClickableTimestampSummary(@NonNull Spanned formattedSummary, @NonNull String videoUrl) {
SpannableStringBuilder clickableSummary = new SpannableStringBuilder(formattedSummary);
private SpannableStringBuilder createClickableTimestampText(@NonNull Spanned formattedText, @NonNull String videoUrl) {
SpannableStringBuilder clickableText = new SpannableStringBuilder(formattedText);
String videoId = getVideoIdFromUrl(videoUrl);
if (TextUtils.isEmpty(videoId)) {
return clickableSummary;
return clickableText;
}
Matcher matcher = SUMMARY_TIMESTAMP_PATTERN.matcher(clickableSummary.toString());
Matcher matcher = SUMMARY_TIMESTAMP_PATTERN.matcher(clickableText.toString());
while (matcher.find()) {
final long timestampSeconds;
try {
@ -1617,14 +1732,14 @@ public final class GeminiManager {
}
String vndLink = String.format(Locale.ENGLISH, VIDEO_SCHEME_INTENT_FORMAT, videoId, timestampSeconds);
clickableSummary.setSpan(
clickableText.setSpan(
new TimestampClickableSpan(vndLink),
matcher.start(),
matcher.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
return clickableSummary;
return clickableText;
}
private long parseTimestampToSeconds(@NonNull Matcher matcher) {
@ -1677,40 +1792,21 @@ public final class GeminiManager {
@MainThread
private void showTranscriptionResultDialogInternal(@NonNull Context context, @NonNull String rawTranscription, int seconds, @NonNull String videoUrl) {
Logger.printDebug(() -> "Showing raw Gemini transcription dialog.");
String timeMsg = (seconds >= 0) ? "\n\n" + str("revanced_gemini_time_taken", seconds) : "";
String metaPrefix = "";
String videoId = getVideoIdFromUrl(videoUrl);
String meta = videoMetadataCache.getOrDefault(videoId, "");
if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) {
metaPrefix = meta + "\n\n";
}
String msg = metaPrefix + rawTranscription + timeMsg;
final String dialogVideoUrl = videoUrl;
AlertDialog.Builder builder = new AlertDialog.Builder(context);
builder.setTitle(str("revanced_gemini_transcription_result_title"));
AlertDialog dialog = builder.setMessage(msg)
.setCancelable(true)
.setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss())
.setNegativeButton(str("revanced_copy"), (d, w) -> setClipboard(context, rawTranscription, str("revanced_gemini_copy_success")))
.setNeutralButton(str("revanced_gemini_transcription_parse_button"), (d, which) -> ensureMainThread(() -> {
dismissTranscriptionDialogInternal();
SheetBottomDialog.SlideDialog dialog = GeminiBottomSheetUi.showTranscriptionSheet(
context,
getVideoMetaText(videoUrl),
buildInfoText(seconds, null),
rawTranscription,
() -> setClipboard(context, rawTranscription, str("revanced_gemini_copy_success")),
() -> ensureMainThread(() -> {
Logger.printDebug(() -> "Parse button clicked (Gemini raw). Attempting parse and display overlay.");
hideTranscriptionOverlayInternal();
parseAndShowTranscriptionInternal(rawTranscription, dialogVideoUrl);
try { d.dismiss(); } catch (Exception ignored) {}
}))
.create();
dialog.show();
Window window = dialog.getWindow();
if (window != null) {
window.setLayout((int) (context.getResources().getDisplayMetrics().widthPixels * 0.90), WindowManager.LayoutParams.WRAP_CONTENT);
}
})
);
transcriptionDialogRef = new WeakReference<>(dialog);
}
// endregion UI Methods: Dialogs
@ -2009,13 +2105,11 @@ public final class GeminiManager {
*/
@MainThread
private void startTimerInternal() {
AlertDialog currentDialog = (progressDialogRef != null) ? progressDialogRef.get() : null;
if (currentOperation == OperationType.NONE
|| isCancelled
|| isProgressDialogMinimized
|| currentDialog == null
|| !currentDialog.isShowing()) {
|| loadingSheet == null
|| !loadingSheet.isShowing()) {
stopTimerInternal();
return;
}
@ -2025,8 +2119,11 @@ public final class GeminiManager {
timerRunnable = new Runnable() {
@Override
public void run() {
AlertDialog currentDialog = progressDialogRef.get();
if (currentOperation != OperationType.NONE && !isCancelled && !isProgressDialogMinimized && currentDialog != null && currentDialog.isShowing()) {
if (currentOperation != OperationType.NONE
&& !isCancelled
&& !isProgressDialogMinimized
&& loadingSheet != null
&& loadingSheet.isShowing()) {
updateTimerMessageInternal();
timerHandler.postDelayed(this, 1000);
} else {
@ -2057,40 +2154,24 @@ public final class GeminiManager {
*/
@MainThread
private void updateTimerMessageInternal() {
if (progressDialogRef == null) {
if (loadingSheet == null) {
stopTimerInternal();
return;
}
AlertDialog currentDialog = progressDialogRef.get();
if (currentDialog != null && currentDialog.isShowing() && !isCancelled && !isProgressDialogMinimized) {
if (loadingSheet.isShowing() && !isCancelled && !isProgressDialogMinimized) {
int sec = calculateElapsedTimeSeconds();
if (sec < 0) return;
if (sec < 0) {
return;
}
String time = "\n" + sec + "s";
String base = (baseLoadingMessage != null && !baseLoadingMessage.isEmpty())
? baseLoadingMessage
: str("revanced_gemini_loading_default");
String metaPrefix = "";
if (currentVideoUrl != null) {
String videoId = getVideoIdFromUrl(currentVideoUrl);
String meta = videoMetadataCache.getOrDefault(videoId, "");
if (!TextUtils.isEmpty(meta) && !meta.equals(str("revanced_gemini_loading_default"))) {
metaPrefix = meta + "\n\n";
}
}
String msg = metaPrefix + base + time;
try {
TextView tv = currentDialog.findViewById(android.R.id.message);
if (tv != null) {
tv.setText(msg);
} else {
currentDialog.setMessage(msg);
}
loadingSheet.updateMeta(getCurrentVideoMetaText());
loadingSheet.updateStatus(base);
loadingSheet.updateElapsed(sec);
} catch (Exception e) {
stopTimerInternal();
}

View file

@ -170,8 +170,10 @@ val overlayButtonsPatch = resourcePatch(
"drawable",
"playlist_repeat_button.xml",
"playlist_shuffle_button.xml",
"revanced_repeat_button.xml",
"revanced_gemini_copy.xml",
"revanced_gemini_send.xml",
"revanced_mute_volume_button.xml",
"revanced_repeat_button.xml",
"revanced_vot_button.xml",
)
)

View file

@ -0,0 +1,10 @@
<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="#FF000000"
android:pathData="M 14.668 12.667 C 14.668 11.957 14.668 11.463 14.637 11.079 C 14.626 10.87 14.588 10.663 14.524 10.464 L 14.469 10.334 C 14.315 10.033 14.081 9.78 13.793 9.603 L 13.666 9.531 C 13.508 9.451 13.296 9.394 12.921 9.363 C 12.537 9.332 12.044 9.332 11.333 9.332 L 8.5 9.332 C 7.789 9.332 7.296 9.332 6.912 9.363 C 6.703 9.373 6.496 9.411 6.297 9.476 L 6.167 9.531 C 5.865 9.685 5.613 9.919 5.436 10.207 L 5.366 10.334 C 5.285 10.492 5.228 10.704 5.197 11.079 C 5.166 11.463 5.165 11.956 5.165 12.667 L 5.165 15.5 C 5.165 16.211 5.165 16.704 5.197 17.088 C 5.228 17.464 5.285 17.675 5.365 17.833 L 5.435 17.959 C 5.612 18.247 5.865 18.481 6.167 18.635 L 6.297 18.691 C 6.441 18.743 6.63 18.78 6.912 18.803 C 7.296 18.834 7.789 18.835 8.5 18.835 L 11.333 18.835 C 12.043 18.835 12.537 18.835 12.921 18.803 C 13.297 18.772 13.508 18.715 13.666 18.635 L 13.793 18.565 C 14.08 18.388 14.315 18.135 14.469 17.833 L 14.524 17.703 C 14.576 17.559 14.614 17.37 14.637 17.088 C 14.668 16.704 14.668 16.211 14.668 15.5 L 14.668 12.667 Z M 15.998 14.665 C 16.453 14.663 16.801 14.66 17.088 14.637 C 17.464 14.606 17.675 14.549 17.833 14.469 L 17.959 14.398 C 18.247 14.221 18.481 13.968 18.635 13.666 L 18.691 13.536 C 18.755 13.337 18.792 13.13 18.803 12.921 C 18.834 12.537 18.835 12.044 18.835 11.333 L 18.835 8.5 C 18.835 7.789 18.835 7.296 18.803 6.912 C 18.792 6.703 18.755 6.496 18.691 6.297 L 18.635 6.167 C 18.481 5.866 18.247 5.613 17.959 5.436 L 17.833 5.366 C 17.675 5.285 17.463 5.228 17.088 5.197 C 16.704 5.166 16.211 5.165 15.5 5.165 L 12.667 5.165 C 11.957 5.165 11.463 5.166 11.079 5.197 C 10.797 5.22 10.608 5.257 10.464 5.309 L 10.334 5.365 C 10.032 5.519 9.78 5.753 9.603 6.041 L 9.531 6.167 C 9.451 6.325 9.394 6.537 9.363 6.912 C 9.34 7.199 9.336 7.547 9.334 8.002 L 11.333 8.002 C 12.022 8.002 12.579 8.002 13.029 8.038 C 13.487 8.076 13.894 8.155 14.271 8.347 L 14.488 8.469 C 14.984 8.773 15.388 9.209 15.653 9.729 L 15.72 9.872 C 15.864 10.209 15.93 10.57 15.962 10.971 C 15.999 11.421 15.998 11.978 15.998 12.667 L 15.998 14.665 Z M 20.165 11.333 C 20.165 12.022 20.165 12.579 20.129 13.029 C 20.096 13.43 20.031 13.791 19.887 14.128 L 19.82 14.271 C 19.555 14.791 19.15 15.227 18.655 15.531 L 18.436 15.653 C 18.06 15.845 17.654 15.924 17.196 15.962 C 16.859 15.989 16.462 15.993 15.996 15.995 C 15.993 16.462 15.989 16.859 15.962 17.196 C 15.929 17.597 15.864 17.958 15.72 18.294 L 15.653 18.436 C 15.388 18.958 14.984 19.394 14.488 19.698 L 14.271 19.82 C 13.894 20.012 13.487 20.091 13.029 20.129 C 12.579 20.166 12.022 20.165 11.333 20.165 L 8.5 20.165 C 7.81 20.165 7.254 20.165 6.804 20.129 C 6.404 20.096 6.042 20.031 5.706 19.887 L 5.563 19.82 C 5.043 19.555 4.607 19.152 4.302 18.655 L 4.18 18.436 C 3.988 18.06 3.909 17.654 3.871 17.196 C 3.834 16.746 3.835 16.189 3.835 15.5 L 3.835 12.667 C 3.835 11.978 3.835 11.421 3.871 10.971 C 3.909 10.513 3.988 10.106 4.18 9.729 L 4.302 9.512 C 4.606 9.016 5.042 8.612 5.563 8.347 L 5.706 8.28 C 6.042 8.136 6.403 8.07 6.804 8.038 C 7.141 8.011 7.537 8.006 8.004 8.004 C 8.006 7.537 8.011 7.141 8.038 6.804 C 8.075 6.346 8.155 5.94 8.347 5.564 L 8.469 5.344 C 8.773 4.849 9.209 4.445 9.729 4.18 L 9.872 4.113 C 10.209 3.969 10.57 3.903 10.971 3.871 C 11.421 3.834 11.978 3.835 12.667 3.835 L 15.5 3.835 C 16.19 3.835 16.746 3.835 17.196 3.871 C 17.654 3.909 18.06 3.988 18.436 4.18 L 18.656 4.302 C 19.151 4.606 19.555 5.042 19.82 5.563 L 19.887 5.706 C 20.031 6.042 20.097 6.403 20.129 6.804 C 20.166 7.254 20.165 7.811 20.165 8.5 L 20.165 11.333 Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<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="#FF000000"
android:pathData="M 10.812 19.185 L 10.812 7.704 L 6.868 11.648 C 6.567 11.96 6.123 12.085 5.704 11.975 C 5.284 11.866 4.957 11.538 4.848 11.12 C 4.738 10.701 4.863 10.255 5.174 9.955 L 11.162 3.966 L 11.254 3.885 C 11.73 3.498 12.422 3.533 12.856 3.966 L 18.844 9.955 L 18.926 10.046 C 19.31 10.522 19.272 11.211 18.84 11.643 C 18.407 12.076 17.719 12.113 17.242 11.73 L 17.151 11.648 L 13.207 7.704 L 13.207 19.185 C 13.207 19.847 12.672 20.383 12.009 20.383 C 11.348 20.383 10.812 19.847 10.812 19.185"/>
</vector>

View file

@ -636,6 +636,7 @@ Known issue: Sometimes the swipe overlay appears before switching to fullscreen
<string name="revanced_gemini_loading_summarize">Summarizing the video...</string>
<string name="revanced_gemini_loading_transcribe">Transcribing the video...</string>
<string name="revanced_gemini_loading_yandex_transcribe">Yandex: Getting subtitles...</string>
<string name="revanced_gemini_chat_input_hint">Ask about this video</string>
<string name="revanced_gemini_status_translating">Translating with Gemini...\n\n⚠ This step may fail if Gemini ignores the instructions or hits the limit.</string>
<string name="revanced_gemini_summary_title">Gemini Video Summary</string>
<string name="revanced_gemini_time_taken">(Took %d seconds)</string>
@ -645,6 +646,7 @@ Known issue: Sometimes the swipe overlay appears before switching to fullscreen
<string name="revanced_gemini_transcription_parse_button">Subtitles</string>
<string name="revanced_gemini_transcription_parse_success">Subtitle parsed successfully</string>
<string name="revanced_gemini_transcription_result_title">Gemini Video Transcription</string>
<string name="revanced_send">Send</string>
<string name="revanced_gms_show_dialog_summary">Displays the optimization dialog for GMSCore at each application startup.</string>
<string name="revanced_gms_show_dialog_title">Show optimization dialog for GMSCore</string>
<string name="revanced_gradient_seekbar_positions_summary">Gradient start and end positions from 0 to 1 separated by comma.</string>
@ -1753,8 +1755,8 @@ AI-generated responses may contain inaccurate information.
Tap here to get an API key."</string>
<string name="revanced_overlay_button_gemini_about_title">About Gemini</string>
<string name="revanced_overlay_button_gemini_summarize">Summarize</string>
<string name="revanced_overlay_button_gemini_summarize_api_key_summary">"Get API key at https://aistudio.google.com/apikey"</string>
<string name="revanced_overlay_button_gemini_summarize_api_key_title">Gemini API key</string>
<string name="revanced_overlay_button_gemini_summarize_api_key_summary">"Enter one Gemini API key per line. Get API keys at https://aistudio.google.com/apikey"</string>
<string name="revanced_overlay_button_gemini_summarize_api_key_title">Gemini API keys</string>
<string name="revanced_overlay_button_gemini_summarize_summary">"Tap to summarize the video. Tap and hold to transcribe.
Summarized and transcribed results are saved unless you closed the app or used Gemini on another video. Tap the button again to view results."</string>

View file

@ -339,7 +339,7 @@
<!-- SETTINGS: OVERLAY_BUTTONS
<PreferenceCategory android:title="@string/revanced_preference_category_overlay_buttons" android:layout="@layout/revanced_settings_preferences_category" />
<SwitchPreference android:title="@string/revanced_overlay_button_gemini_summarize_title" android:key="revanced_overlay_button_gemini_summarize" android:summary="@string/revanced_overlay_button_gemini_summarize_summary" />
<app.morphe.extension.shared.settings.preference.ResettableEditTextPreference android:hint="AIzaSy..." android:title="@string/revanced_overlay_button_gemini_summarize_api_key_title" android:key="revanced_overlay_button_gemini_summarize_api_key" android:summary="@string/revanced_overlay_button_gemini_summarize_api_key_summary" android:inputType="text" />
<app.morphe.extension.shared.settings.preference.ResettableEditTextPreference android:hint="AIzaSy..." android:title="@string/revanced_overlay_button_gemini_summarize_api_key_title" android:key="revanced_overlay_button_gemini_summarize_api_key" android:summary="@string/revanced_overlay_button_gemini_summarize_api_key_summary" android:inputType="textMultiLine" />
<app.morphe.extension.shared.settings.preference.ResettableEditTextPreference android:hint="14" android:title="@string/revanced_gemini_transcribe_subtitles_font_size_title" android:key="revanced_gemini_transcribe_subtitles_font_size" android:summary="@string/revanced_gemini_transcribe_subtitles_font_size_summary" android:inputType="number" />
<SwitchPreference android:title="@string/revanced_yandex_transcribe_subtitles_title" android:key="revanced_yandex_transcribe_subtitles" android:summary="@string/revanced_yandex_transcribe_subtitles_summary" />
<app.morphe.extension.shared.settings.preference.ResettableEditTextPreference android:hint="en" android:title="@string/revanced_yandex_transcribe_subtitles_language_title" android:key="revanced_yandex_transcribe_subtitles_language" android:summary="@string/revanced_yandex_transcribe_subtitles_language_summary" android:inputType="text" />