feat(YouTube - Overlay buttons): Use cascade of Gemini models

Gemini 3 models will display timestamps for each section, allowing users to jump directly to the part they need.
This commit is contained in:
Aaron Veil 2026-03-05 19:32:25 +03:00
parent bc75d5a4b0
commit 56db9b3195
5 changed files with 3125 additions and 4445 deletions

View file

@ -56,7 +56,7 @@
}
],
[
"@saithodev/semantic-release-backmerge",
"@cleyrop-org/semantic-release-backmerge",
{
"backmergeBranches": [{"from": "main", "to": "dev"}],
"clearWorkspace": true

View file

@ -49,16 +49,22 @@ import android.os.Handler;
import android.os.Looper;
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.utils.IntentUtils;
import app.morphe.extension.shared.utils.Logger;
import app.morphe.extension.youtube.settings.Settings;
import app.morphe.extension.youtube.shared.VideoInformation;
@ -81,6 +87,7 @@ 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;
@ -100,6 +107,13 @@ public final class GeminiManager {
private static final Pattern TRANSCRIPTION_PATTERN = Pattern.compile(
"\\[\\s*(?:(\\d{2}):)?(\\d{2}):(\\d{2})[.,:](\\d{1,3})\\s*-\\s*(?:(\\d{2}):)?(\\d{2}):(\\d{2})[.,:](\\d{1,3})\\s*\\]:?\\s*(.*)"
);
/**
* Pattern to match summary timestamps (HH:MM:SS or MM:SS), with optional milliseconds.
*/
private static final Pattern SUMMARY_TIMESTAMP_PATTERN = Pattern.compile(
"(?<!\\d)(?:(\\d{1,2}):)?(\\d{2}):(\\d{2})(?:[.,:](\\d{1,3}))?(?!\\d)"
);
private static final String VIDEO_SCHEME_INTENT_FORMAT = "vnd.youtube://%s?start=%d";
/**
* Pattern to extract video ID from YouTube URLs.
@ -256,6 +270,11 @@ public final class GeminiManager {
*/
private final Map<String, Integer> summaryTimeCache = new ConcurrentHashMap<>();
/**
* Cache for summary generation model names.
*/
private final Map<String, String> summaryModelCache = new ConcurrentHashMap<>();
/**
* Cache for parsed transcriptions.
*/
@ -369,7 +388,8 @@ public final class GeminiManager {
Logger.printDebug(() -> "Displaying cached summary for ID: " + videoId);
String result = summaryCache.get(videoId);
Integer time = summaryTimeCache.getOrDefault(videoId, -1);
showSummaryDialog(context, Objects.requireNonNull(result), Objects.requireNonNull(time), videoUrl);
String model = summaryModelCache.get(videoId);
showSummaryDialog(context, Objects.requireNonNull(result), Objects.requireNonNull(time), videoUrl, model);
resetOperationStateInternal(OperationType.SUMMARIZE, false);
return;
}
@ -400,12 +420,17 @@ public final class GeminiManager {
GeminiUtils.getVideoSummary(videoUrl, apiKey, new GeminiUtils.Callback() {
@Override
public void onSuccess(String result) {
onSuccessWithModel(result, null);
}
@Override
public void onSuccessWithModel(String result, String model) {
if (!activeTasks.containsKey(taskKey)) {
Logger.printDebug(() -> "Summary success callback ignored - task was cancelled: " + taskKey);
return;
}
activeTasks.remove(taskKey);
handleApiResponseInternal(context, OperationType.SUMMARIZE, videoUrl, result, null);
handleApiResponseInternal(context, OperationType.SUMMARIZE, videoUrl, result, null, model);
}
@Override
@ -961,6 +986,15 @@ public final class GeminiManager {
*/
@MainThread
private void handleApiResponseInternal(@NonNull Context context, @NonNull OperationType opType, @NonNull String videoUrl, @Nullable String result, @Nullable String error) {
handleApiResponseInternal(context, opType, videoUrl, result, error, null);
}
/**
* Handles the result (success or failure) from the *direct* Gemini API callback
* with optional model metadata.
*/
@MainThread
private void handleApiResponseInternal(@NonNull Context context, @NonNull OperationType opType, @NonNull String videoUrl, @Nullable String result, @Nullable String error, @Nullable String usedModel) {
dismissProgressDialogInternal();
String videoId = getVideoIdFromUrl(videoUrl);
@ -983,9 +1017,14 @@ public final class GeminiManager {
if (opType == OperationType.SUMMARIZE) {
summaryCache.put(videoId, result);
summaryTimeCache.put(videoId, time);
if (!TextUtils.isEmpty(usedModel)) {
summaryModelCache.put(videoId, usedModel);
} else {
summaryModelCache.remove(videoId);
}
if (Objects.equals(currentVideoUrl, videoUrl)) {
showSummaryDialog(context, result, time, videoUrl);
showSummaryDialog(context, result, time, videoUrl, usedModel);
resetOperationStateInternal(opType, false);
}
} else if (opType == OperationType.TRANSCRIBE) {
@ -1462,10 +1501,15 @@ public final class GeminiManager {
* @param summary The summary text.
* @param seconds Time taken for the operation.
* @param videoUrl The video URL.
* @param usedModel The Gemini model used for this summary (nullable).
*/
@MainThread
private void showSummaryDialog(@NonNull Context context, @NonNull String summary, int seconds, @NonNull String videoUrl) {
String timeMsg = (seconds >= 0) ? "\n\n" + str("revanced_gemini_time_taken", seconds) : "";
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);
@ -1475,21 +1519,48 @@ public final class GeminiManager {
metaPrefix = meta + "\n\n";
}
Spanned formattedSummary = MarkdownUtils.fromMarkdown(summary);
String normalizedSummary = summary.replace("\r\n", "\n").trim();
SpannableStringBuilder finalMessage = new SpannableStringBuilder();
String displaySummary = wrapTimestampReferencesForPillStyle(normalizedSummary);
Spanned formattedSummary = MarkdownUtils.fromMarkdown(displaySummary);
SpannableStringBuilder clickableSummary = createClickableTimestampSummary(formattedSummary, videoUrl);
SpannableStringBuilder summaryMessage = new SpannableStringBuilder();
if (!metaPrefix.isEmpty()) {
finalMessage.append(metaPrefix);
summaryMessage.append(metaPrefix);
}
finalMessage.append(formattedSummary);
finalMessage.append(timeMsg);
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.setMessage(finalMessage)
AlertDialog dialog = builder
.setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss())
.setNeutralButton(str("revanced_copy"), (d, w) -> setClipboard(context, summary, str("revanced_gemini_copy_success")))
.setNeutralButton(str("revanced_copy"), (d, w) -> setClipboard(context, normalizedSummary, str("revanced_gemini_copy_success")))
.setCancelable(true)
.create();
@ -1501,6 +1572,97 @@ public final class GeminiManager {
}
}
@NonNull
private String wrapTimestampReferencesForPillStyle(@NonNull String summary) {
Matcher matcher = SUMMARY_TIMESTAMP_PATTERN.matcher(summary);
StringBuilder builder = new StringBuilder(summary.length() + 16);
int cursor = 0;
while (matcher.find()) {
int start = matcher.start();
int end = matcher.end();
builder.append(summary, cursor, start);
boolean alreadyWrapped = (start > 0 && summary.charAt(start - 1) == '`')
|| (end < summary.length() && summary.charAt(end) == '`');
if (alreadyWrapped) {
builder.append(summary, start, end);
} else {
builder.append('`').append(summary, start, end).append('`');
}
cursor = end;
}
builder.append(summary, cursor, summary.length());
return builder.toString();
}
@NonNull
private SpannableStringBuilder createClickableTimestampSummary(@NonNull Spanned formattedSummary, @NonNull String videoUrl) {
SpannableStringBuilder clickableSummary = new SpannableStringBuilder(formattedSummary);
String videoId = getVideoIdFromUrl(videoUrl);
if (TextUtils.isEmpty(videoId)) {
return clickableSummary;
}
Matcher matcher = SUMMARY_TIMESTAMP_PATTERN.matcher(clickableSummary.toString());
while (matcher.find()) {
final long timestampSeconds;
try {
timestampSeconds = parseTimestampToSeconds(matcher);
} catch (Exception ignored) {
continue;
}
String vndLink = String.format(Locale.ENGLISH, VIDEO_SCHEME_INTENT_FORMAT, videoId, timestampSeconds);
clickableSummary.setSpan(
new TimestampClickableSpan(vndLink),
matcher.start(),
matcher.end(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
);
}
return clickableSummary;
}
private long parseTimestampToSeconds(@NonNull Matcher matcher) {
long hours = 0L;
String hoursPart = matcher.group(1);
if (!TextUtils.isEmpty(hoursPart)) {
hours = Long.parseLong(hoursPart);
}
long minutes = Long.parseLong(Objects.requireNonNull(matcher.group(2)));
long seconds = Long.parseLong(Objects.requireNonNull(matcher.group(3)));
return TimeUnit.HOURS.toSeconds(hours) + TimeUnit.MINUTES.toSeconds(minutes) + seconds;
}
private static final class TimestampClickableSpan extends ClickableSpan {
private final String vndLink;
private TimestampClickableSpan(@NonNull String vndLink) {
this.vndLink = vndLink;
}
@Override
public void onClick(@NonNull View widget) {
try {
IntentUtils.launchView(vndLink, widget.getContext().getPackageName());
} catch (Exception e) {
Logger.printException(() -> "Failed to open summary timestamp link: " + vndLink, e);
}
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(false);
ds.setColor(ThemeUtils.getAppForegroundColor());
}
}
/**
* Displays the raw Gemini transcription result in an AlertDialog.
* Includes options to copy or attempt to parse and show as an overlay.

View file

@ -66,8 +66,12 @@ import java.util.concurrent.Executors;
public class GeminiUtils {
private static final ExecutorService executor = Executors.newCachedThreadPool();
private static final String BASE_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/";
private static final String GEMINI_MODEL = "gemini-3-flash-preview";
private static final String GEMINI_FALLBACK_MODEL = "gemini-2.5-flash";
private static final String[] GEMINI_MODELS = {
"gemini-3-flash-preview",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-2.5-flash"
};
private static final String ACTION = ":generateContent?key=";
private static final Handler mainThreadHandler = new Handler(Looper.getMainLooper());
@ -81,9 +85,10 @@ public class GeminiUtils {
*/
public static void getVideoSummary(@NonNull String videoUrl, @NonNull String apiKey, @NonNull Callback callback) {
String langName = getLanguageName();
String prompt = "Summarize the key points of this video in " + langName + ". Skip any preamble, intro phrases, or explanations — output only the summary.";
Logger.printDebug(() -> "GeminiUtils (SUMMARY): Sending Prompt: " + prompt);
generateContent(videoUrl, apiKey, prompt, GEMINI_MODEL, callback, true);
String prompt = "Write a spoiler-heavy summary of this video in " + langName + ". Focus on key events in chronological order. Output only summary. Do not include preamble, greetings, or phrases like \"Here is the summary\". If the video contains sponsored segments, exclude them from the summary entirely. Add timestamp references at the end of each summary section in this format: [HH:MM:SS - HH:MM:SS].";
String promptForGemini25 = "Write a spoiler-heavy summary of this video in " + langName + ". Focus on key events in chronological order. Output only summary. Do not include preamble, greetings, or phrases like \"Here is the summary\". If the video contains sponsored segments, exclude them from the summary entirely.";
Logger.printDebug(() -> "GeminiUtils (SUMMARY): Sending Prompt (Gemini 3.x base): " + prompt);
generateContent(videoUrl, apiKey, prompt, promptForGemini25, GEMINI_MODELS[0], 0, callback);
}
/**
@ -97,7 +102,7 @@ public class GeminiUtils {
String langName = getLanguageName();
String prompt = "Transcribe this video precisely in " + langName + ", including spoken words, written words in the video and significant sounds. Provide timestamps for each segment in the format [HH:MM:SS.mmm - HH:MM:SS.mmm]: Text. Skip any preamble, intro phrases, or explanations — output only the transcription.";
Logger.printDebug(() -> "GeminiUtils (TRANSCRIPTION): Sending Prompt: " + prompt);
generateContent(videoUrl, apiKey, prompt, GEMINI_MODEL, callback, true);
generateContent(videoUrl, apiKey, prompt, null, GEMINI_MODELS[0], 0, callback);
}
/**
@ -128,7 +133,7 @@ public class GeminiUtils {
String prompt = "Translate ONLY the string values associated with the \"text\" keys within the following JSON subtitle data to " + targetLangName + ". Preserve the exact JSON structure, including all keys (like \"startMs\", \"endMs\", \"durationMs\") and their original numeric values. Output ONLY the fully translated JSON data, without any introductory text, explanations, comments, or markdown formatting (like ```json ... ```).\n\nInput JSON:\n" + yandexJson;
Logger.printDebug(() -> "GeminiUtils (JSON TRANSLATE): Sending Translation Prompt for target '" + targetLangName + "'.");
generateContent(null, apiKey, prompt, GEMINI_MODEL, callback, true);
generateContent(null, apiKey, prompt, null, GEMINI_MODELS[0], 0, callback);
}
/**
@ -137,15 +142,23 @@ public class GeminiUtils {
* @param videoUrl The publicly accessible URL of the video (nullable).
* @param apiKey The Gemini API key.
* @param textPrompt The specific text prompt to send.
* @param promptForGemini25 Optional prompt override used specifically for Gemini 2.5 models.
* @param model The Gemini model to use.
* @param modelIndex The index of the model in {@link #GEMINI_MODELS}.
* @param callback The {@link Callback} to handle the success or failure response.
* @param allowFallback If true, a failure will trigger a retry with the fallback model.
*/
private static void generateContent(@Nullable String videoUrl, @NonNull String apiKey, @NonNull String textPrompt, @NonNull String model, @NonNull Callback callback, boolean allowFallback) {
private static void generateContent(@Nullable String videoUrl, @NonNull String apiKey, @NonNull String textPrompt, @Nullable String promptForGemini25, @NonNull String model, int modelIndex, @NonNull Callback callback) {
executor.submit(() -> {
HttpURLConnection connection = null;
try {
final int attemptNumber = modelIndex + 1;
Logger.printInfo(() -> "GeminiUtils: Attempting model [" + attemptNumber + "/" + GEMINI_MODELS.length + "]: " + model);
final String effectivePrompt = resolvePromptForModel(model, textPrompt, promptForGemini25);
if (!effectivePrompt.equals(textPrompt)) {
Logger.printDebug(() -> "GeminiUtils: Using model-specific prompt for " + model + ".");
}
URL url = new URL(BASE_API_URL + model + ACTION + apiKey);
connection = (HttpURLConnection) url.openConnection();
@ -169,11 +182,11 @@ public class GeminiUtils {
JSONObject videoPart = new JSONObject().put("fileData", fileData);
partsArray.put(videoPart);
JSONObject textPart = new JSONObject().put("text", textPrompt);
JSONObject textPart = new JSONObject().put("text", effectivePrompt);
partsArray.put(textPart);
} else {
Logger.printDebug(() -> "GeminiUtils: Constructing payload with ONLY text part.");
JSONObject textPartOnly = new JSONObject().put("text", textPrompt);
JSONObject textPartOnly = new JSONObject().put("text", effectivePrompt);
partsArray.put(textPartOnly);
}
@ -198,14 +211,13 @@ public class GeminiUtils {
JSONObject generationConfig = new JSONObject();
JSONObject thinkingConfig;
if (model.equals(GEMINI_MODEL)) {
// Gemini 3 (Thinking Model) Configuration
// Gemini 3 does not support turning thinking fully "off", but "minimal" acts as the disabled state.
if (model.startsWith("gemini-3")) {
// Gemini 3.x configuration: keep thinking minimal.
thinkingConfig = new JSONObject()
.put("thinkingLevel", "minimal")
.put("includeThoughts", false);
} else {
// Fallback Model (Gemini 2.5 Flash) Configuration
// Gemini 2.5 configuration: disable thinking.
thinkingConfig = new JSONObject().put("thinkingBudget", 0);
}
generationConfig.put("thinkingConfig", thinkingConfig);
@ -217,7 +229,7 @@ public class GeminiUtils {
String jsonInputString = requestBody.toString();
String logIdentifier = (videoUrl != null) ? "VIDEO" : "TEXT/JSON";
Logger.printDebug(() -> "GeminiUtils (" + logIdentifier + " - " + model + "): Sending Payload. Prompt starts: " + textPrompt.substring(0, Math.min(textPrompt.length(), 200)) + "...");
Logger.printDebug(() -> "GeminiUtils (" + logIdentifier + " - " + model + "): Sending Payload. Prompt starts: " + effectivePrompt.substring(0, Math.min(effectivePrompt.length(), 200)) + "...");
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8);
@ -242,13 +254,17 @@ public class GeminiUtils {
String responseString = response.toString();
if (responseCode == HttpURLConnection.HTTP_OK) {
Logger.printInfo(() -> "GeminiUtils: Model succeeded: " + model + " (HTTP 200)");
JSONObject jsonResponse = new JSONObject(responseString);
try {
if (!jsonResponse.has("candidates") || jsonResponse.getJSONArray("candidates").length() == 0) {
String blockReason = extractBlockReason(jsonResponse);
final String finalBlockReason = blockReason != null ? "Content blocked: " + blockReason :
"API response missing valid candidates. Full Response: " + responseString.substring(0, Math.min(responseString.length(), 500)) + "...";
Logger.printException(() -> "Gemini API Error: " + finalBlockReason);
if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned no valid candidates")) {
return;
}
Logger.printException(() -> "Gemini API Error (" + model + "): " + finalBlockReason);
mainThreadHandler.post(() -> callback.onFailure(finalBlockReason));
return;
}
@ -268,7 +284,11 @@ public class GeminiUtils {
if (!looksLikeJson) {
Logger.printInfo(() -> "Gemini JSON translation result doesn't look like valid JSON!");
Logger.printInfo(() -> "Start of content: " + finalResult.substring(0, Math.min(finalResult.length(), 50)));
mainThreadHandler.post(() -> callback.onFailure("Translation result format error. Expected JSON."));
final String jsonError = "Translation result format error. Expected JSON.";
if (tryFallback(null, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned non-JSON translation output")) {
return;
}
mainThreadHandler.post(() -> callback.onFailure(jsonError));
return;
}
}
@ -279,23 +299,28 @@ public class GeminiUtils {
boolean looksLikeJson = finalResult.startsWith("[") || finalResult.startsWith("{");
if (!looksLikeJson) {
Logger.printInfo(() -> "Gemini JSON translation result doesn't look like valid JSON!");
mainThreadHandler.post(() -> callback.onFailure("Translation result format error. Expected JSON."));
final String jsonError = "Translation result format error. Expected JSON.";
if (tryFallback(null, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned non-JSON translation output")) {
return;
}
mainThreadHandler.post(() -> callback.onFailure(jsonError));
return;
}
}
mainThreadHandler.post(() -> callback.onSuccess(finalResult));
mainThreadHandler.post(() -> callback.onSuccessWithModel(finalResult, model));
} catch (JSONException jsonEx) {
Logger.printException(() -> "Gemini API Response JSON Parsing Error. Full Response: " + responseString.substring(0, Math.min(responseString.length(), 500)) + "...", jsonEx);
if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "returned unparsable JSON payload")) {
return;
}
Logger.printException(() -> "Gemini API Response JSON Parsing Error (" + model + "). Full Response: " + responseString.substring(0, Math.min(responseString.length(), 500)) + "...", jsonEx);
String blockReason = extractBlockReason(jsonResponse);
final String finalError = blockReason != null ? "Content blocked: " + blockReason :
"Failed to parse result from API response. Check logs.";
mainThreadHandler.post(() -> callback.onFailure(finalError));
}
} else {
if (allowFallback) {
Logger.printDebug(() -> "GeminiUtils: Primary model " + model + " failed (HTTP " + responseCode + "). Fallback to " + GEMINI_FALLBACK_MODEL);
generateContent(videoUrl, apiKey, textPrompt, GEMINI_FALLBACK_MODEL, callback, false);
if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "failed with HTTP " + responseCode)) {
return;
}
@ -311,15 +336,13 @@ public class GeminiUtils {
} catch (Exception jsonEx) {
errorMessage += errorDetails;
}
Logger.printException(() -> "Gemini API Error (" + responseCode + "). Response: " + responseString);
Logger.printException(() -> "Gemini API Error (" + model + ", HTTP " + responseCode + "). Response: " + responseString);
final String finalError = errorMessage;
mainThreadHandler.post(() -> callback.onFailure(finalError));
}
} catch (java.net.SocketTimeoutException e) {
if (allowFallback) {
Logger.printDebug(() -> "GeminiUtils: Primary model " + model + " timed out. Fallback to " + GEMINI_FALLBACK_MODEL);
generateContent(videoUrl, apiKey, textPrompt, GEMINI_FALLBACK_MODEL, callback, false);
if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "timed out")) {
return;
}
@ -331,9 +354,7 @@ public class GeminiUtils {
mainThreadHandler.post(() -> callback.onFailure("Operation cancelled."));
Thread.currentThread().interrupt();
} catch (IOException e) {
if (allowFallback) {
Logger.printDebug(() -> "GeminiUtils: Primary model " + model + " network failed. Fallback to " + GEMINI_FALLBACK_MODEL);
generateContent(videoUrl, apiKey, textPrompt, GEMINI_FALLBACK_MODEL, callback, false);
if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "network request failed")) {
return;
}
@ -341,9 +362,7 @@ public class GeminiUtils {
final String ioErrorMsg = e.getMessage() != null ? "Network error: " + e.getMessage() : "Unknown network error";
mainThreadHandler.post(() -> callback.onFailure(ioErrorMsg));
} catch (Exception e) {
if (allowFallback) {
Logger.printDebug(() -> "GeminiUtils: Primary model " + model + " failed with exception. Fallback to " + GEMINI_FALLBACK_MODEL);
generateContent(videoUrl, apiKey, textPrompt, GEMINI_FALLBACK_MODEL, callback, false);
if (tryFallback(videoUrl, apiKey, textPrompt, promptForGemini25, model, modelIndex, callback, "failed with exception: " + e.getClass().getSimpleName())) {
return;
}
@ -358,6 +377,36 @@ public class GeminiUtils {
});
}
private static boolean tryFallback(
@Nullable String videoUrl,
@NonNull String apiKey,
@NonNull String textPrompt,
@Nullable String promptForGemini25,
@NonNull String currentModel,
int currentModelIndex,
@NonNull Callback callback,
@NonNull String reason
) {
int nextModelIndex = currentModelIndex + 1;
if (nextModelIndex >= GEMINI_MODELS.length) {
Logger.printDebug(() -> "GeminiUtils: No fallback left after model " + currentModel + " (" + reason + ").");
return false;
}
String nextModel = GEMINI_MODELS[nextModelIndex];
Logger.printDebug(() -> "GeminiUtils: Model " + currentModel + " " + reason + ". Falling back to " + nextModel + ".");
generateContent(videoUrl, apiKey, textPrompt, promptForGemini25, nextModel, nextModelIndex, callback);
return true;
}
@NonNull
private static String resolvePromptForModel(@NonNull String model, @NonNull String defaultPrompt, @Nullable String promptForGemini25) {
if (model.startsWith("gemini-2.5") && !TextUtils.isEmpty(promptForGemini25)) {
return promptForGemini25;
}
return defaultPrompt;
}
/**
* Gets a {@link BufferedReader} for reading the response from an {@link HttpURLConnection}.
* Handles both successful (2xx) and error responses by choosing the appropriate input stream
@ -549,6 +598,17 @@ public class GeminiUtils {
*/
void onSuccess(String result);
/**
* Called when the Gemini API request completes successfully, with the model used.
* Default implementation preserves backward compatibility by delegating to {@link #onSuccess(String)}.
*
* @param result The generated text (summary or transcription) from the API.
* @param model The exact model that produced the result.
*/
default void onSuccessWithModel(String result, String model) {
onSuccess(result);
}
/**
* Called when the Gemini API request fails.
*

7248
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
{
"devDependencies": {
"@saithodev/semantic-release-backmerge": "^4.0.1",
"@cleyrop-org/semantic-release-backmerge": "^5.2.4",
"@MorpheApp/changelog": "git+https://github.com/MorpheApp/changelog.git#bundle",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"gradle-semantic-release-plugin": "^1.10.1",
"semantic-release": "^24.1.2"
"gradle-semantic-release-plugin": "^1.10.3",
"semantic-release": "^25.0.3"
}
}