Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ tests/
settings-host.test.ts # nibbleDisabledDomains normalize / toggle
language-detection.test.ts # RTL detection, title lookup, supported codes
selection-popup.test.ts # @mousedown.prevent.stop regression
caption-translation-gate.test.ts # whole-caption translation: settle ~150 ms, skip the caption already sent (E-06); in-flight sharing is in translate.service.test.ts (E-04)
nibble-surface.test.ts # bridge-driven hide/show
translate-card.test.ts # popup translate input flow
login-password.test.ts # popup password form (ENABLE_PASSWORD_AUTH=true)
Expand Down
38 changes: 38 additions & 0 deletions src/common/services/translate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export class TranslateService {
// Cache for translation results
private translationCache: TranslationCache = {};
private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
// Requests on their way, by cache key (E-04).
private inFlight = new Map<string, Promise<any>>();

/**
* The learner's own language code, the one translations are written in.
Expand Down Expand Up @@ -135,6 +137,28 @@ export class TranslateService {
};
}

/**
* E-04: register a request as in flight, so an identical call made before
* it answers waits for it instead of sending a second request. The entry is
* removed when the request settles, so a failure is never reused or cached.
*/
private startInFlight(cacheKey: string) {
let resolve!: (value: any) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<any>((res, rej) => {
resolve = res;
reject = rej;
});
// Nobody may be waiting on it: a failure here must not be "unhandled".
promise.catch(() => {});
this.inFlight.set(cacheKey, promise);
const settle = () => this.inFlight.delete(cacheKey);
return {
resolve: (value: any) => (settle(), resolve(value)),
reject: (reason: unknown) => (settle(), reject(reason)),
};
}

// Clean expired cache entries
private cleanExpiredCache(): void {
const now = Date.now();
Expand All @@ -160,6 +184,11 @@ export class TranslateService {
return cachedResult;
}

// The same request is already on its way: share it (E-04).
const pending = this.inFlight.get(cacheKey);
if (pending) return pending;
const inFlight = this.startInFlight(cacheKey);

// If not cached, fetch from API
try {
const result = await withAuthRetry(() =>
Expand All @@ -180,11 +209,13 @@ export class TranslateService {

// Cache the result
this.cacheResult(cacheKey, result);
inFlight.resolve(result);

return result;
} catch (error) {
// Log error but don't cache failed requests
console.error("Translation error:", error);
inFlight.reject(error);
throw error;
}
}
Expand All @@ -204,6 +235,11 @@ export class TranslateService {
return cachedResult;
}

// The same request is already on its way: share it (E-04).
const pending = this.inFlight.get(cacheKey);
if (pending) return pending;
const inFlight = this.startInFlight(cacheKey);

// If not cached, fetch from API
try {
const data = await withAuthRetry(() =>
Expand All @@ -229,11 +265,13 @@ export class TranslateService {

// Cache the result
this.cacheResult(cacheKey, data);
inFlight.resolve(data);

return data;
} catch (error) {
// Log error but don't cache failed requests
console.error("Detailed translation error:", error);
inFlight.reject(error);
throw error;
}
}
Expand Down
39 changes: 30 additions & 9 deletions src/subtitle/_support-template/components/Subtitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,14 @@
</template>

<script lang="ts">
import { defineComponent, PropType, StyleValue } from "vue";
import { defineComponent, markRaw, PropType, StyleValue } from "vue";
import { clamp } from "../../../common/helper/math";
import { getDir, rtls } from "../../../common/helper/text";
import { TranslateService } from "../../../common/services/translate.service";
import {
createCaptionTranslationGate,
type CaptionTranslationGate,
} from "../../helpers/caption-translation-gate";
import { Dictionary } from "../../../common/types/general.type";
import { analytic } from "../../../plugins/mixpanel";

Expand All @@ -84,6 +88,7 @@ interface DataModel {
sourceLanguage: string;
showTranslatedSentence: boolean;
showWordDetail: boolean;
captionGate: CaptionTranslationGate | null;
}

export default defineComponent({
Expand All @@ -102,9 +107,21 @@ export default defineComponent({
sourceLanguage: "en",
showTranslatedSentence: false,
showWordDetail: false,
captionGate: null,
};
},

created() {
// Not reactive state: the gate only holds a timer and the last caption.
this.captionGate = markRaw(
createCaptionTranslationGate((text) => this.translateWholeCaption(text))
);
},

beforeUnmount() {
this.captionGate?.cancel();
},

computed: {
lines() {
let lines = this.translatedLines.length
Expand Down Expand Up @@ -174,13 +191,17 @@ export default defineComponent({

if (!this.showTranslatedSentence || !value || !value.length) return;

this.translateWholeCaption();
// Wait for the caption to settle and send it once (E-06).
if (this.captionGate!.request(value.join("\n"))) {
this.translatedLines = [];
}
},
},

showTranslatedSentence(value) {
if (!value || this.translatedLines.length) return;
this.translateWholeCaption();
if (!value) return this.captionGate!.cancel();
if (this.translatedLines.length || !this.textList?.length) return;
this.captionGate!.sendNow(this.textList.join("\n"));
},

hoveredWord(value) {
Expand All @@ -204,10 +225,9 @@ export default defineComponent({
return list;
},

translateWholeCaption() {
// let words = this.getWordList();
let lines = this.textList as unknown as Array<string>;
let translatingList = [lines.join("\n")];
/** Called by the caption gate with the settled caption (E-06). */
translateWholeCaption(caption: string) {
let translatingList = [caption];

this.translatedLines = [];

Expand All @@ -219,7 +239,8 @@ export default defineComponent({
translatingList.forEach((result, i) => {
this.translatedLines.push(translatedText);
});
});
})
.catch(() => this.captionGate?.forget(caption));
},

translateWord(word) {
Expand Down
71 changes: 71 additions & 0 deletions src/subtitle/helpers/caption-translation-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Translate each caption once (E-06).
*
* With "translate whole caption" on, the caption watcher fires on every
* `textList` change, and a player can change it several times for one caption
* (lines added one by one, a caption that blinks off and back on). Each change
* used to send a translation request.
*
* The gate waits for the caption to settle (`delayMs`, about 150 ms), then
* sends it only when the joined text differs from the one whose translation
* is on screen (the last one sent). A failed request is forgotten, so the same
* caption can be tried again.
*/
export const CAPTION_SETTLE_MS = 150;

export interface CaptionTranslationGate {
/**
* The caption changed to `text`: send it once it has been stable for the
* delay, unless its translation is already on screen. Returns true when the
* translation on screen belongs to another caption, so the caller should
* clear it now instead of showing it under the new text while it settles.
*/
request(text: string): boolean;
/** Send `text` now, even if it was sent before (the user turned it on). */
sendNow(text: string): void;
/** The request for `text` failed: let the same caption be sent again. */
forget(text: string): void;
/** Drop a waiting request (unmount, translation turned off). */
cancel(): void;
}

export function createCaptionTranslationGate(
send: (text: string) => void,
delayMs: number = CAPTION_SETTLE_MS
): CaptionTranslationGate {
// The caption whose translation is on screen, or on its way.
let lastSent: string | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;

const cancel = () => {
if (timer) clearTimeout(timer);
timer = null;
};

const sendNow = (text: string) => {
cancel();
lastSent = text;
send(text);
};

return {
request(text) {
cancel();
const stale = text !== lastSent;
// The caller clears the old translation, so nothing is on screen now.
if (stale) lastSent = null;
timer = setTimeout(() => {
timer = null;
if (text !== lastSent) sendNow(text);
}, delayMs);
return stale;
},
sendNow,
forget(text) {
if (lastSent === text) lastSent = null;
},
cancel() {
cancel();
},
};
}
37 changes: 30 additions & 7 deletions src/subtitle/web_netflix/components/Subtitle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,26 @@
</template>

<script lang="ts">
import { defineComponent, PropType, StyleValue } from "vue";
import { defineComponent, markRaw, PropType, StyleValue } from "vue";
import { mapState, mapActions } from "pinia";
import { useMarkerStore } from "../../../stores/marker";
import { clamp } from "../../../common/helper/math";
import { getDir, rtls, cleanText } from "../../../common/helper/text";
import { TranslateService } from "../../../common/services/translate.service";
import { log } from "../../../common/helper/log";
import { LOADING_ICON } from "../../../common/icons/icons";
import {
createCaptionTranslationGate,
type CaptionTranslationGate,
} from "../../helpers/caption-translation-gate";

interface DataModel {
translatedLines: String[];

showTranslatedSentence: boolean;
showWordDetail: boolean;
isTranslatingWholeCaption: boolean;
captionGate: CaptionTranslationGate | null;
}

export default defineComponent({
Expand All @@ -101,9 +106,21 @@ export default defineComponent({
showTranslatedSentence: false,
showWordDetail: false,
isTranslatingWholeCaption: false,
captionGate: null,
};
},

created() {
// Not reactive state: the gate only holds a timer and the last caption.
this.captionGate = markRaw(
createCaptionTranslationGate((text) => this.translateWholeCaption(text))
);
},

beforeUnmount() {
this.captionGate?.cancel();
},

computed: {
...mapState(useMarkerStore, [
"selectedPhrase",
Expand Down Expand Up @@ -210,16 +227,22 @@ export default defineComponent({

if (!this.showTranslatedSentence || !value || !value.length) return;

this.translateWholeCaption();
// Wait for the caption to settle and send it once (E-06).
if (this.captionGate!.request(value.join("\n"))) {
this.translatedLines = [];
}
},
},

showTranslatedSentence(value) {
log("showTranslatedSentence", value);

if (value == true) {
this.translateWholeCaption();
if (this.textList?.length) {
this.captionGate!.sendNow(this.textList.join("\n"));
}
} else {
this.captionGate!.cancel();
this.translatedLines = [];
this.clear();
}
Expand All @@ -241,10 +264,9 @@ export default defineComponent({
return list;
},

translateWholeCaption() {
// let words = this.getWordList();
let lines = this.textList as unknown as Array<string>;
let translatingList = [lines.join("\n")];
/** Called by the caption gate with the settled caption (E-06). */
translateWholeCaption(caption: string) {
let translatingList = [caption];

this.translatedLines = [];

Expand All @@ -257,6 +279,7 @@ export default defineComponent({
this.translatedLines.push(translationResult);
});
})
.catch(() => this.captionGate?.forget(caption))
.finally(() => {
this.isTranslatingWholeCaption = false;
});
Expand Down
Loading
Loading