diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 399f698..595585f 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+
+
diff --git a/assets/l10n/en.json b/assets/l10n/en.json
index 79475bc..160382b 100644
--- a/assets/l10n/en.json
+++ b/assets/l10n/en.json
@@ -80,6 +80,7 @@
"JJ_MKVMaker 로 자막 만들기": "Make subtitles with JJ_MKVMaker",
"JJ_MKVMaker 로 재생": "Play with JJ_MKVMaker",
"JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_bookmarks.html",
+ "JavaScript 사용": "Enable JavaScript",
"MKV · 자막 (jj_mkv)": "MKV · subtitles (jj_mkv)",
"MKV · 자막 저장 위치": "Where to save MKV · subtitles",
"MKV 만들기": "Make MKV",
@@ -230,6 +231,8 @@
"길이 {0}": "Length {0}",
"끄기": "Off",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "When off, starts right away with the last settings: source {0}, languages {1}, {2}",
+ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). With the Chrome engine, page translation · video detection also stop.",
+ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.",
"끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.",
"끝": "End",
"끝 = 현재": "End = now",
@@ -269,6 +272,7 @@
"다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser",
"다른 앱에서 동영상을 열 때": "When opening a video from another app",
"다른 앱에서 열기: {0}": "Opened from another app: {0}",
+ "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "Translates pages in other languages into the display language ({0}). The page text is sent to Google Translate. Use the translate button next to the address bar to switch between original · translation.",
"다른 언어로 번역 (AI · 이 {0}에서)": "Translate to another language (AI · on this {0})",
"다른 이름으로 저장": "Save as",
"다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})",
@@ -535,6 +539,7 @@
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.",
"우르두어": "Urdu",
"우크라이나어": "Ukrainian",
+ "원문 보기 (번역 끄기)": "Show original (turn off translation)",
"원본 비율": "Original aspect",
"원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)",
"원본 크기": "Original size",
@@ -548,6 +553,8 @@
"웹 브라우저 (홈 주소)": "Web browser (home page)",
"웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n{0}": "Cannot start the web browser (Edge WebView2).\n{0}",
"웹 브라우저의 시작 · 🏠 주소": "Start · 🏠 page of the web browser",
+ "웹 페이지 자동 번역": "Translate web pages automatically",
+ "웹 페이지를 번역하지 못했습니다: {0}": "Could not translate the web page: {0}",
"위 폴더": "Parent folder",
"위로": "Up",
"음성": "Audio",
@@ -573,6 +580,7 @@
"이 이동 버튼 지우기": "Delete this move button",
"이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)",
"이 파일용": "For this file",
+ "이 페이지를 {0}(으)로 번역": "Translate this page to {0}",
"이 폴더 선택": "Choose this folder",
"이 폴더 전체": "This whole folder",
"이 폴더에 새 폴더": "New folder in this folder",
diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json
index daac76f..4db221d 100644
--- a/assets/l10n/ja.json
+++ b/assets/l10n/ja.json
@@ -80,6 +80,7 @@
"JJ_MKVMaker 로 자막 만들기": "JJ_MKVMaker で字幕を作成",
"JJ_MKVMaker 로 재생": "JJ_MKVMaker で再生",
"JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_ブックマーク.html",
+ "JavaScript 사용": "JavaScript を使う",
"MKV · 자막 (jj_mkv)": "MKV・字幕(jj_mkv)",
"MKV · 자막 저장 위치": "MKV・字幕の保存先",
"MKV 만들기": "MKV 作成",
@@ -230,6 +231,8 @@
"길이 {0}": "長さ {0}",
"끄기": "オフ",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "オフにすると前回の設定ですぐ開始: 原語 {0}、言語 {1}、{2}",
+ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。",
+ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。",
"끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。",
"끝": "終了",
"끝 = 현재": "終了 = 現在",
@@ -269,6 +272,7 @@
"다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む",
"다른 앱에서 동영상을 열 때": "他のアプリで動画を開いたとき",
"다른 앱에서 열기: {0}": "他のアプリから開く: {0}",
+ "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "ほかの言語のページを表示言語 ({0}) に翻訳します。ページの文章は Google 翻訳に送られます。アドレスバー横の翻訳ボタンで原文 · 翻訳を切り替えられます。",
"다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)",
"다른 이름으로 저장": "名前を付けて保存",
"다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})",
@@ -535,6 +539,7 @@
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
"우르두어": "ウルドゥー語",
"우크라이나어": "ウクライナ語",
+ "원문 보기 (번역 끄기)": "原文を表示 (翻訳をオフ)",
"원본 비율": "元の比率",
"원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)",
"원본 크기": "元のサイズ",
@@ -548,6 +553,8 @@
"웹 브라우저 (홈 주소)": "ウェブブラウザー(ホームページ)",
"웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n{0}": "ウェブブラウザー(Edge WebView2)を起動できません。\n{0}",
"웹 브라우저의 시작 · 🏠 주소": "ウェブブラウザーの開始・🏠 ページ",
+ "웹 페이지 자동 번역": "ウェブページを自動翻訳",
+ "웹 페이지를 번역하지 못했습니다: {0}": "ウェブページを翻訳できませんでした: {0}",
"위 폴더": "上のフォルダー",
"위로": "上へ",
"음성": "音声",
@@ -573,6 +580,7 @@
"이 이동 버튼 지우기": "この移動ボタンを削除",
"이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)",
"이 파일용": "このファイル用",
+ "이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳",
"이 폴더 선택": "このフォルダーを選択",
"이 폴더 전체": "このフォルダー全体",
"이 폴더에 새 폴더": "このフォルダーに新しいフォルダー",
diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json
index 030f5b1..9c31b07 100644
--- a/assets/l10n/zh-Hans.json
+++ b/assets/l10n/zh-Hans.json
@@ -80,6 +80,7 @@
"JJ_MKVMaker 로 자막 만들기": "用 JJ_MKVMaker 生成字幕",
"JJ_MKVMaker 로 재생": "用 JJ_MKVMaker 播放",
"JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_书签.html",
+ "JavaScript 사용": "启用 JavaScript",
"MKV · 자막 (jj_mkv)": "MKV · 字幕(jj_mkv)",
"MKV · 자막 저장 위치": "MKV · 字幕保存位置",
"MKV 만들기": "制作 MKV",
@@ -230,6 +231,8 @@
"길이 {0}": "时长 {0}",
"끄기": "关闭",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "关闭后将直接使用上次的设置开始: 原语言 {0},语言 {1},{2}",
+ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。",
+ "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。",
"끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。",
"끝": "结束",
"끝 = 현재": "结束 = 当前",
@@ -269,6 +272,7 @@
"다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签",
"다른 앱에서 동영상을 열 때": "从其他应用打开视频时",
"다른 앱에서 열기: {0}": "从其他应用打开: {0}",
+ "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "将其他语言的页面翻译为界面语言 ({0})。页面文字会发送到 Google 翻译。可用地址栏旁的翻译按钮切换原文 · 译文。",
"다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)",
"다른 이름으로 저장": "另存为",
"다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})",
@@ -535,6 +539,7 @@
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。",
"우르두어": "乌尔都语",
"우크라이나어": "乌克兰语",
+ "원문 보기 (번역 끄기)": "显示原文 (关闭翻译)",
"원본 비율": "原始比例",
"원본 유지 (재인코딩 없음)": "保持原样(不重新编码)",
"원본 크기": "原始尺寸",
@@ -548,6 +553,8 @@
"웹 브라우저 (홈 주소)": "网页浏览器(主页)",
"웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n{0}": "无法启动网页浏览器(Edge WebView2)。\n{0}",
"웹 브라우저의 시작 · 🏠 주소": "网页浏览器的起始 · 🏠 页面",
+ "웹 페이지 자동 번역": "自动翻译网页",
+ "웹 페이지를 번역하지 못했습니다: {0}": "无法翻译网页: {0}",
"위 폴더": "上级文件夹",
"위로": "向上",
"음성": "音频",
@@ -573,6 +580,7 @@
"이 이동 버튼 지우기": "删除此移动按钮",
"이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)",
"이 파일용": "适用于此文件",
+ "이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}",
"이 폴더 선택": "选择此文件夹",
"이 폴더 전체": "整个文件夹",
"이 폴더에 새 폴더": "在此文件夹中新建文件夹",
diff --git a/integration_test/web_translate_test.dart b/integration_test/web_translate_test.dart
new file mode 100644
index 0000000..d2cd6f1
--- /dev/null
+++ b/integration_test/web_translate_test.dart
@@ -0,0 +1,160 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:jj_mkvmaker/app/app_controller.dart';
+import 'package:jj_mkvmaker/app/bookmarks_controller.dart';
+import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
+import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
+import 'package:jj_mkvmaker/services/platform_services.dart';
+import 'package:jj_mkvmaker/ui/browser_page.dart';
+import 'package:path/path.dart' as p;
+
+/// 실제 웹뷰 (Windows: Edge WebView2 · Android: 시스템 WebView) + 실제 Google 번역:
+/// 페이지 자동 번역 · 원문 보기 · JavaScript 끄기.
+/// Android 는 JavaScript 를 끄면 앱이 페이지를 읽을 수 없어, 페이지 스크립트가 돌면 서버로 보내는 신호 (/ran) 로 확인한다.
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('웹 페이지 번역 · JavaScript 끄기', (tester) async {
+ final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
+ var pageLoads = 0, scriptRuns = 0;
+ server.listen((req) async {
+ if (req.uri.path == '/ran') {
+ scriptRuns++;
+ req.response.statusCode = 204;
+ await req.response.close();
+ return;
+ }
+ if (req.uri.path != '/') {
+ req.response.statusCode = 404;
+ await req.response.close();
+ return;
+ }
+ pageLoads++;
+ req.response
+ ..headers.contentType = ContentType.html
+ ..write('
JJ '
+ 'Hello world '
+ ' Download the video
'
+ 'Hello code'
+ '안녕하세요 여러분
'
+ ' '
+ 'no script
'
+ ''
+ '');
+ await req.response.close();
+ });
+ final url = 'http://127.0.0.1:${server.port}/';
+ final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
+
+ final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
+ c.settings
+ ..webViewDataDir = dataDir
+ ..uiLanguage = 'ko'
+ ..webTranslate = true;
+ final bm = BookmarksController(p.join(dataDir, 'bm.json'));
+ await tester.pumpWidget(MaterialApp(home: BrowserPage(c: c, bookmarks: bm, initialUrl: url)));
+
+ Future> dom() async {
+ final r = await tester.runAsync(() async => BrowserPage.debugNav?.evaluate(
+ 'JSON.stringify({h: (document.getElementById("h")||{}).textContent, p: (document.getElementById("p")||{}).textContent,'
+ ' c: (document.getElementById("c")||{}).textContent, k: (document.getElementById("k")||{}).textContent,'
+ ' i: (document.getElementById("i")||{getAttribute:function(){}}).getAttribute("placeholder"),'
+ ' js: (document.getElementById("js")||{}).textContent, late: (document.getElementById("late")||{}).textContent})'));
+ if (r == null) return {};
+ final v = jsonDecode(r is String ? r : jsonEncode(r));
+ // 없는 요소는 JSON 에서 빠진다 → 빈 글로
+ final m = (v is String ? jsonDecode(v) : v) as Map;
+ return {for (final k in const ['h', 'p', 'c', 'k', 'i', 'js', 'late']) k: '${m[k] ?? ''}'};
+ }
+
+ final hangul = RegExp('[가-힣]');
+ Future> waitFor(bool Function(Map) ok, String what) async {
+ var d = {};
+ for (var i = 0; i < 300; i++) {
+ await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100)));
+ await tester.pump();
+ d = await dom();
+ if (d.isNotEmpty && ok(d)) break;
+ }
+ // ignore: avoid_print
+ print('RESULT $what: $d');
+ return d;
+ }
+
+ // 1. 자동 번역 (JavaScript 켬): 영어 글 · 안내 글은 한국어로, 코드 · 한국어 글은 그대로, 나중에 생긴 글도
+ var d = await waitFor((d) => hangul.hasMatch(d['h']!) && hangul.hasMatch(d['late']!), '자동 번역');
+ expect(d['h'], matches(hangul));
+ expect(d['p'], matches(hangul));
+ expect(d['p'], startsWith(' ')); // 앞뒤 빈칸은 그대로
+ expect(d['i'], matches(hangul));
+ expect(d['late'], matches(hangul));
+ expect(d['c'], 'Hello code');
+ expect(d['k'], '안녕하세요 여러분');
+ expect(d['js'], isNot('no script')); // 페이지 스크립트가 돌았다 (그 글도 번역됨)
+
+ // 2. 번역 버튼 → 원문
+ await tester.tap(find.byTooltip('원문 보기 (번역 끄기)'));
+ d = await waitFor((d) => d['h'] == 'Hello world', '원문 보기');
+ expect(d['h'], 'Hello world');
+ expect(d['p'], ' Download the video ');
+ expect(d['i'], 'Search here');
+ expect(d['late'], 'Good morning');
+ expect(d['js'], 'script ran');
+
+ Future waitServer(bool Function() ok) async {
+ for (var i = 0; i < 300 && !ok(); i++) {
+ await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100)));
+ await tester.pump();
+ }
+ }
+
+ // 3. JavaScript 끄기 → 페이지를 다시 읽고, 페이지 스크립트는 돌지 않는다
+ expect(scriptRuns, greaterThan(0));
+ final loads = pageLoads, runs = scriptRuns;
+ await c.updateSettings((s) => s.webJavaScript = false);
+ await waitServer(() => pageLoads > loads);
+ await tester.runAsync(() => Future.delayed(const Duration(seconds: 3)));
+ // ignore: avoid_print
+ print('RESULT JavaScript 끔: 다시 읽음 ${pageLoads - loads}번, 스크립트 실행 ${scriptRuns - runs}번');
+ expect(pageLoads, greaterThan(loads));
+ expect(scriptRuns, runs); // 다시 읽은 페이지의 스크립트가 돌지 않았다
+
+ if (Platform.isAndroid) {
+ // 4. Android 웹뷰는 JavaScript 를 끄면 앱이 넣는 스크립트도 막는다 → 번역 · 페이지 읽기가 안 됨 (설정 설명대로)
+ expect(await dom(), isEmpty);
+ await tester.tap(find.byTooltip('이 페이지를 한국어(으)로 번역'));
+ await tester.runAsync(() => Future.delayed(const Duration(seconds: 3)));
+ await tester.pump();
+ // ignore: avoid_print
+ print('RESULT JavaScript 끈 채 번역 (Android): 페이지 읽기 ${await dom()} → 번역 안 됨 (예상대로)');
+ } else {
+ d = await waitFor((d) => d['js'] == 'no script', 'JavaScript 끔 (페이지)');
+ expect(d['js'], 'no script');
+ expect(d['late'], ''); // setTimeout 도 돌지 않음
+
+ // 4. Edge 는 JavaScript 를 꺼도 번역 버튼으로 번역된다 (앱이 넣는 스크립트는 실행)
+ await tester.tap(find.byTooltip('이 페이지를 한국어(으)로 번역'));
+ d = await waitFor((d) => hangul.hasMatch(d['h']!), 'JavaScript 끈 채 번역');
+ expect(d['h'], matches(hangul));
+ expect(d['js'], matches(hangul)); // "no script" 도 번역
+ }
+
+ // 5. 다시 켜기 → 다시 읽고 페이지 스크립트가 돈다, 번역도 다시
+ final runs2 = scriptRuns;
+ await c.updateSettings((s) => s.webJavaScript = true);
+ await waitServer(() => scriptRuns > runs2);
+ expect(scriptRuns, greaterThan(runs2));
+ d = await waitFor((d) => d['late']!.isNotEmpty && hangul.hasMatch(d['h']!), 'JavaScript 다시 켬');
+ expect(d['late'], isNotEmpty); // setTimeout 이 다시 돈다
+ expect(d['h'], matches(hangul));
+
+ await tester.pumpWidget(const SizedBox());
+ await server.close(force: true);
+ }, timeout: const Timeout(Duration(minutes: 3)));
+}
diff --git a/lib/app/settings.dart b/lib/app/settings.dart
index 5c4e530..75a3bdc 100644
--- a/lib/app/settings.dart
+++ b/lib/app/settings.dart
@@ -127,6 +127,12 @@ class AppSettings {
/// "외부 브라우저로 열기" 에 쓸 브라우저: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale'
String externalBrowser = 'system';
+ /// 앱 안 브라우저: 다른 언어로 된 웹 페이지를 화면 언어 ([uiLanguage]) 로 자동 번역 (Google 번역)
+ bool webTranslate = false;
+
+ /// 앱 안 브라우저: 페이지의 JavaScript 실행 (끄면 스크립트 없이 글 · 그림만)
+ bool webJavaScript = true;
+
/// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음
String webViewDataDir = '';
@@ -188,6 +194,8 @@ class AppSettings {
'homeUrl': homeUrl,
'browserEngine': browserEngine,
'externalBrowser': externalBrowser,
+ 'webTranslate': webTranslate,
+ 'webJavaScript': webJavaScript,
'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck,
'skippedVersion': skippedVersion,
@@ -259,6 +267,8 @@ class AppSettings {
..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/'
..browserEngine = j['browserEngine'] as String? ?? 'edge'
..externalBrowser = j['externalBrowser'] as String? ?? 'system'
+ ..webTranslate = j['webTranslate'] as bool? ?? false
+ ..webJavaScript = j['webJavaScript'] as bool? ?? true
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
..skippedVersion = j['skippedVersion'] as String? ?? ''
diff --git a/lib/core/web_translate.dart b/lib/core/web_translate.dart
new file mode 100644
index 0000000..3df17e6
--- /dev/null
+++ b/lib/core/web_translate.dart
@@ -0,0 +1,281 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+/// 앱 안 웹 브라우저의 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역).
+///
+/// 엔진 (Edge · Chrome · Android) 과 상관없이 같은 방식으로 동작한다:
+/// 1. [webTranslateCollectScript] 로 페이지의 글자 (텍스트 노드 · placeholder · title) 를 모은다.
+/// 한 번 번역한 글자는 다시 모으지 않고, 페이지가 바뀌면 (YouTube 처럼 주소만 바뀌는 사이트 포함) 새 글자만 모은다.
+/// 2. [WebPageTranslator] 가 Google 번역으로 화면 언어로 번역한다 (원문 언어는 자동 감지).
+/// 3. [webTranslateApplyScript] 로 페이지에 넣는다. [webTranslateRestoreScript] 는 원문으로 되돌린다.
+///
+/// 스크립트는 앱이 페이지에 넣는 것이라 페이지의 JavaScript 를 꺼도 Edge 에서는 동작한다.
+
+/// 화면 언어 코드 → Google 번역 언어 코드
+String googleLang(String uiLanguage) => switch (uiLanguage) {
+ 'zh-Hans' => 'zh-CN',
+ 'zh-Hant' => 'zh-TW',
+ 'he' => 'iw',
+ _ => uiLanguage,
+ };
+
+/// 두 언어 코드가 같은 언어인지 (ko-KR = ko, zh-CN ≠ zh-TW, he = iw)
+bool sameLanguage(String a, String b) {
+ String norm(String x) {
+ x = x.trim().toLowerCase().replaceAll('_', '-');
+ if (x == 'iw' || x.startsWith('iw-')) return 'he';
+ if (x.startsWith('zh')) return x.contains('tw') || x.contains('hk') || x.contains('hant') ? 'zh-tw' : 'zh-cn';
+ final i = x.indexOf('-');
+ return i < 0 ? x : x.substring(0, i);
+ }
+
+ return a.isNotEmpty && b.isNotEmpty && norm(a) == norm(b);
+}
+
+/// 페이지의 아직 번역하지 않은 글자를 모으는 스크립트. 결과는 JSON 문자열:
+/// `{"items": [[번호, 글자], ...], "more": 남은 글자가 있는지, "same": 이미 화면 언어인 페이지}`.
+///
+/// [force] 가 아니면 페이지가 이미 화면 언어 (`` 또는 앞서 번역해 보니 대부분 그 언어) 일 때 모으지 않는다.
+String webTranslateCollectScript(String target, {bool force = false, int max = 300}) =>
+ '(function(tl,force,max){$_init'
+ r'''
+ if (T.target && T.target !== tl) T.restore();
+ T.target = tl;
+ if (force && !T.forced) { T.forced = true; T.same = false; T.dirty = true; }
+ if (!force && !T.forced) {
+ var lang = (document.documentElement.getAttribute('lang') || '').toLowerCase();
+ if (lang && T.base(lang) === T.base(tl)) T.same = true;
+ }
+ if (T.same) return JSON.stringify({items: [], same: true});
+ if (!T.dirty && T.obs) return JSON.stringify({items: []});
+ T.dirty = false;
+ if (T.refs.length > 4000) T.refs = T.refs.filter(function(r){ return (r.n || r.e).isConnected; });
+ var items = [], more = false, letter = /\p{L}/u;
+ function add(r, text) {
+ if (items.length >= max) { more = true; return false; }
+ r.i = T.refs.length; T.refs.push(r);
+ items.push([r.i, text]);
+ return true;
+ }
+ var root = document.body || document.documentElement;
+ // 건너뛸 요소 (스크립트 · 코드 · 입력칸 · translate="no" …) 아래의 글은 거른다. 요소마다 한 번만 판단해 기억.
+ // TreeWalker 의 거르기 함수 (acceptNode) 는 쓰지 않는다: 페이지 JavaScript 를 끄면 Edge 가 그 함수를 부르지 못한다.
+ var memo = new Map();
+ function skipped(el) {
+ if (!el || el.nodeType !== 1) return false;
+ var s = memo.get(el);
+ if (s === undefined) { s = T.skipOne(el) || skipped(el.parentNode); memo.set(el, s); }
+ return s;
+ }
+ var w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT), n;
+ while ((n = w.nextNode())) {
+ var v = n.nodeValue;
+ if (n.__jjT === v || !letter.test(v) || skipped(n.parentNode)) continue;
+ if (!add({n: n, o: v}, v.replace(/\s+/g, ' ').trim())) break;
+ }
+ if (!more) {
+ var els = root.querySelectorAll('[placeholder],[title]');
+ for (var i = 0; i < els.length && !more; i++) {
+ var e = els[i];
+ // 입력칸 자체는 글자만 건너뛰고 안내 글 (placeholder) · 풍선 도움말 (title) 은 번역
+ if (skipped(e.parentNode) || e.getAttribute('translate') === 'no' || e.classList.contains('notranslate')) continue;
+ ['placeholder', 'title'].forEach(function(a){
+ var v = e.getAttribute(a);
+ if (v && e['__jjT_' + a] !== v && letter.test(v)) add({e: e, a: a, o: v}, v.trim());
+ });
+ }
+ }
+ if (more) T.dirty = true;
+ return JSON.stringify({items: items, more: more});
+})'''
+ '(${jsonEncode(target)},$force,$max)';
+
+/// 번역한 글자를 페이지에 넣는 스크립트. [texts] 는 번호 → 번역 (null = 이미 화면 언어라 그대로 둠).
+/// [same]: 이 페이지는 화면 언어로 보고 (직접 번역을 누르지 않았으면) 더 모으지 않는다.
+String webTranslateApplyScript(Map texts, {bool same = false}) =>
+ '(function(m,same){$_init'
+ r'''
+ var c = 0;
+ for (var k in m) {
+ var r = T.refs[k], t = m[k];
+ if (!r) continue;
+ if (r.a) {
+ if (r.e.getAttribute(r.a) !== r.o) continue;
+ if (t !== null) { r.e.setAttribute(r.a, t); r.t = t; c++; }
+ r.e['__jjT_' + r.a] = t === null ? r.o : t;
+ } else {
+ if (r.n.nodeValue !== r.o) continue;
+ if (t !== null) {
+ r.t = r.o.match(/^\s*/)[0] + t + r.o.match(/\s*$/)[0];
+ r.n.nodeValue = r.t;
+ c++;
+ }
+ r.n.__jjT = t === null ? r.o : r.t;
+ }
+ }
+ if (same && !T.forced) T.same = true;
+ if (T.obs) T.obs.takeRecords();
+ return c;
+})'''
+ '(${jsonEncode({for (final e in texts.entries) '${e.key}': e.value})},$same)';
+
+/// 번역한 글자를 원문으로 되돌리는 스크립트 (번역 끄기)
+const webTranslateRestoreScript = '(function(){var T = window.__jjTr; if (!T) return 0; return T.restore();})()';
+
+/// 페이지에 번역 도구를 한 번만 만든다 (T). 페이지가 바뀌는 것은 MutationObserver 로 알아챈다.
+const _init = r'''
+ var T = window.__jjTr;
+ if (!T) {
+ T = window.__jjTr = {refs: [], target: '', same: false, forced: false, dirty: true, obs: null};
+ T.base = function(x){ x = x.toLowerCase(); if (x.indexOf('zh') === 0) return /tw|hk|hant/.test(x) ? 'zh-tw' : 'zh-cn'; if (x === 'iw') return 'he'; return x.split('-')[0]; };
+ var SKIP = {SCRIPT:1, STYLE:1, NOSCRIPT:1, TEXTAREA:1, CODE:1, PRE:1, KBD:1, SAMP:1, VAR:1, TEMPLATE:1, INPUT:1, SELECT:1, OPTION:1};
+ T.skipOne = function(el){
+ return !!(SKIP[el.nodeName.toUpperCase()] || el.isContentEditable || el.getAttribute('translate') === 'no' ||
+ (el.classList && el.classList.contains('notranslate')));
+ };
+ T.restore = function(){
+ var c = 0;
+ for (var i = T.refs.length - 1; i >= 0; i--) {
+ var r = T.refs[i];
+ if (r.a) {
+ if (r.t !== undefined && r.e.getAttribute(r.a) === r.t) { r.e.setAttribute(r.a, r.o); c++; }
+ r.e['__jjT_' + r.a] = undefined;
+ } else {
+ if (r.t !== undefined && r.n.nodeValue === r.t) { r.n.nodeValue = r.o; c++; }
+ r.n.__jjT = undefined;
+ }
+ }
+ T.refs = []; T.target = ''; T.same = false; T.forced = false; T.dirty = true;
+ if (T.obs) T.obs.takeRecords();
+ return c;
+ };
+ try {
+ T.obs = new MutationObserver(function(){ T.dirty = true; });
+ T.obs.observe(document.documentElement, {childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['placeholder', 'title']});
+ } catch (e) { T.obs = null; }
+ }
+''';
+
+/// [webTranslateCollectScript] 의 결과
+class CollectedText {
+ final List ids;
+ final List texts;
+ final bool more;
+ final bool same;
+ const CollectedText(this.ids, this.texts, {this.more = false, this.same = false});
+
+ /// 웹뷰가 돌려준 값 (JSON 문자열, 또는 엔진에 따라 이미 풀린 Map). 알 수 없으면 null.
+ static CollectedText? parse(Object? raw) {
+ try {
+ var v = raw;
+ if (v is String) v = jsonDecode(v);
+ // 엔진에 따라 JSON 문자열을 한 번 더 감싸서 준다
+ if (v is String) v = jsonDecode(v);
+ if (v is! Map) return null;
+ final ids = [], texts = [];
+ for (final it in (v['items'] as List?) ?? const []) {
+ if (it is List && it.length == 2) {
+ ids.add((it[0] as num).toInt());
+ texts.add('${it[1]}');
+ }
+ }
+ return CollectedText(ids, texts, more: v['more'] == true, same: v['same'] == true);
+ } catch (_) {
+ return null;
+ }
+ }
+}
+
+/// Google 번역 요청 (주소, 본문) → 응답 본문. 테스트에서 바꿔 끼운다.
+typedef TranslatePost = Future Function(Uri url, String body);
+
+/// 여러 글자를 Google 번역으로 한 번에 번역한다 (원문 언어 자동 감지). 같은 글자는 다시 묻지 않는다.
+class WebPageTranslator {
+ final TranslatePost _post;
+ WebPageTranslator({TranslatePost? post}) : _post = post ?? _httpPost;
+
+ /// (대상 언어, 원문) → 번역. null = 이미 대상 언어
+ final _cache = <(String, String), String?>{};
+ static const _cacheMax = 20000;
+
+ /// 한 번에 보내는 양
+ static const _chunkChars = 3000, _chunkItems = 100;
+
+ /// [texts] 를 [target] (Google 언어 코드) 으로. 결과의 null 은 이미 그 언어라 번역하지 않은 것.
+ Future> translate(List texts, String target) async {
+ final out = List.filled(texts.length, null);
+ final todo = [];
+ final asked = {};
+ for (var i = 0; i < texts.length; i++) {
+ final key = (target, texts[i]);
+ if (_cache.containsKey(key)) {
+ out[i] = _cache[key];
+ } else if (texts[i].trim().isNotEmpty && asked.add(texts[i])) {
+ todo.add(i);
+ }
+ }
+ // 같은 양으로 나눠 동시에 3개까지
+ final chunks = >[];
+ var cur = [];
+ var size = 0;
+ for (final i in todo) {
+ final t = texts[i];
+ if (cur.isNotEmpty && (cur.length >= _chunkItems || size + t.length > _chunkChars)) {
+ chunks.add(cur);
+ cur = [];
+ size = 0;
+ }
+ cur.add(t);
+ size += t.length;
+ }
+ if (cur.isNotEmpty) chunks.add(cur);
+ for (var i = 0; i < chunks.length; i += 3) {
+ await Future.wait([for (final c in chunks.skip(i).take(3)) _ask(c, target)]);
+ }
+ if (_cache.length > _cacheMax) _cache.clear();
+ for (var i = 0; i < texts.length; i++) {
+ final key = (target, texts[i]);
+ if (_cache.containsKey(key)) out[i] = _cache[key];
+ }
+ return out;
+ }
+
+ Future _ask(List texts, String target) async {
+ final url = Uri.https('translate.googleapis.com', '/translate_a/t',
+ {'client': 'gtx', 'sl': 'auto', 'tl': target, 'format': 'text'});
+ final body = texts.map((t) => 'q=${Uri.encodeQueryComponent(t)}').join('&');
+ final res = parseGoogleResponse(await _post(url, body), texts.length);
+ for (var i = 0; i < texts.length; i++) {
+ final (text, lang) = res[i];
+ _cache[(target, texts[i])] = sameLanguage(lang, target) || text == texts[i] ? null : text;
+ }
+ }
+
+ /// 응답: `[["번역","en"], ...]` (원문 언어 자동 감지) 또는 `["번역", ...]`
+ static List<(String, String)> parseGoogleResponse(String body, int count) {
+ final v = jsonDecode(body);
+ if (v is! List || v.length != count) {
+ throw FormatException('번역 응답의 개수가 다릅니다 (${v is List ? v.length : '?'} / $count)');
+ }
+ return [
+ for (final x in v)
+ x is List ? ('${x.isEmpty ? '' : x[0]}', x.length > 1 ? '${x[1]}' : '') : ('$x', ''),
+ ];
+ }
+
+ static Future _httpPost(Uri url, String body) async {
+ final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
+ try {
+ final req = await client.postUrl(url);
+ req.headers.contentType = ContentType('application', 'x-www-form-urlencoded', charset: 'utf-8');
+ req.add(utf8.encode(body));
+ final res = await req.close().timeout(const Duration(seconds: 20));
+ final text = await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 20));
+ if (res.statusCode != 200) throw HttpException('번역 서버 응답 ${res.statusCode}', uri: url);
+ return text;
+ } finally {
+ client.close(force: true);
+ }
+ }
+}
diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart
index bc24c8e..64365b2 100644
--- a/lib/ui/browser_page.dart
+++ b/lib/ui/browser_page.dart
@@ -13,6 +13,8 @@ import '../app/download_manager.dart';
import '../core/bookmarks.dart';
import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies;
import '../core/web_address.dart';
+import '../core/web_translate.dart';
+import '../app/i18n_controller.dart' show I18nController;
import '../core/youtube_ads.dart';
import '../platform/windows/cef_runtime.dart';
import '../platform/windows/com_guard.dart';
@@ -40,6 +42,12 @@ abstract class WebNav {
/// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등)
Future runScript(String js);
+
+ /// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null.
+ Future evaluate(String js);
+
+ /// 페이지의 JavaScript 켜기 · 끄기 (바뀌면 지금 페이지를 다시 읽는다)
+ Future setJavaScript(bool on);
}
/// 페이지의 모든 동영상 · 소리를 멈추는 스크립트
@@ -97,6 +105,10 @@ class BrowserPage extends StatefulWidget {
/// 마지막으로 보던 주소: MKV 화면으로 갔다가 브라우저를 다시 열면 홈이 아니라 이 주소로
static String? _lastUrl;
+ /// 마지막으로 붙은 웹뷰 (통합 테스트에서 페이지 내용을 읽는다)
+ @visibleForTesting
+ static WebNav? debugNav;
+
/// 앱 안 웹뷰를 화면이 닫혀도 살려 둔다 (다시 열면 보던 페이지 · 재생 위치 · 뒤로 가기 기록 그대로)
static final _keepAlive = InAppWebViewKeepAlive();
static bool _keepAliveUsed = false;
@@ -138,12 +150,18 @@ class _BrowserPageState extends State with RouteAware {
onUrl: (u) {
if (!mounted || u.isEmpty) return;
BrowserPage._lastUrl = u;
+ if (u != _url) _trFails = 0; // 다른 페이지: 번역을 다시 시도
setState(() => _url = u);
if (!_addressFocus.hasFocus) _address.text = u;
_applyAdSettings();
+ _translateNow();
},
onTitle: (t) => mounted ? setState(() => _title = t) : null,
- onProgress: (p) => mounted ? setState(() => _progress = p) : null,
+ onProgress: (p) {
+ if (!mounted) return;
+ setState(() => _progress = p);
+ if (p >= 1) _translateNow(); // 다 읽었다: 바로 번역 (2초를 기다리지 않고)
+ },
onVideo: (v) => mounted ? setState(() => _hasVideo = v) : null,
onHistory: (b, f) {
if (!mounted) return;
@@ -152,15 +170,113 @@ class _BrowserPageState extends State with RouteAware {
_canFwd = f;
});
},
- attach: (n) => _nav = n,
+ attach: (n) => _nav = BrowserPage.debugNav = n,
);
BookmarksController get bm => widget.bookmarks;
+ // ───────── 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역) ─────────
+
+ static final _translator = WebPageTranslator();
+
+ /// 이 화면에서 번역 중인지. 처음엔 설정을 따르고, 도구 막대의 [번역] 버튼으로 바꾼다.
+ late bool _trOn = widget.c.settings.webTranslate;
+
+ /// 버튼으로 직접 켰다: 이미 화면 언어로 보이는 페이지도 번역한다
+ bool _trForce = false;
+ bool _trBusy = false;
+ int _trFails = 0;
+ Timer? _trTimer;
+
+ /// 브라우저가 다른 화면에 가려져 있지 않은지
+ bool _visible = true;
+
+ /// 바뀌었는지 비교할 설정 (initState 에서 정한다. late 초기화는 처음 읽을 때 - 이미 바뀐 뒤 - 정해져 안 됨)
+ late bool _lastTranslate, _lastJs;
+ late String _lastLang;
+
+ void _onSettings() {
+ if (!mounted) return;
+ final s = widget.c.settings;
+ if (s.webJavaScript != _lastJs) {
+ _lastJs = s.webJavaScript;
+ _nav?.setJavaScript(_lastJs);
+ }
+ if (s.webTranslate != _lastTranslate) {
+ _lastTranslate = s.webTranslate;
+ _setTranslate(_lastTranslate, force: false);
+ } else if (s.uiLanguage != _lastLang && _trOn) {
+ _translateNow(); // 다른 언어로 다시 번역 (스크립트가 앞의 번역을 되돌린 뒤 번역)
+ }
+ _lastLang = s.uiLanguage;
+ }
+
+ void _setTranslate(bool on, {required bool force}) {
+ setState(() {
+ _trOn = on;
+ _trForce = on && force;
+ _trFails = 0;
+ });
+ if (on) {
+ _translateNow();
+ } else {
+ _trTimer?.cancel();
+ _trTimer = null;
+ _nav?.evaluate(webTranslateRestoreScript);
+ }
+ }
+
+ /// 지금 번역하고, 켜져 있는 동안 2초마다 새로 나온 글자를 번역한다 (YouTube 처럼 글이 계속 바뀌는 페이지)
+ void _translateNow() {
+ if (!_trOn) return;
+ _trTimer ??= Timer.periodic(const Duration(seconds: 2), (_) => _translateTick());
+ _translateTick();
+ }
+
+ Future _translateTick() async {
+ final nav = _nav;
+ if (!_trOn || _trBusy || nav == null || !_visible || !_url.startsWith('http') || _trFails >= 3) return;
+ _trBusy = true;
+ try {
+ final target = googleLang(widget.c.settings.uiLanguage);
+ // 한 번에 300개씩, 페이지가 크면 몇 번 더
+ for (var round = 0; round < 10 && mounted && _trOn; round++) {
+ final got = CollectedText.parse(await nav.evaluate(webTranslateCollectScript(target, force: _trForce)));
+ if (got == null || got.texts.isEmpty) break;
+ final out = await _translator.translate(got.texts, target);
+ // 대부분 이미 화면 언어면 (직접 켠 게 아니면) 이 페이지는 더 번역하지 않는다
+ final chars = got.texts.fold(0, (a, t) => a + t.length);
+ var same = 0;
+ for (var i = 0; i < out.length; i++) {
+ if (out[i] == null) same += got.texts[i].length;
+ }
+ if (!mounted || !_trOn) break;
+ await nav.evaluate(webTranslateApplyScript({for (var i = 0; i < got.ids.length; i++) got.ids[i]: out[i]},
+ same: round == 0 && chars > 0 && same >= chars * 0.8));
+ if (!got.more) break;
+ }
+ _trFails = 0;
+ } catch (e) {
+ // 번역 서버에 닿지 않음 등: 세 번 실패하면 다른 페이지로 갈 때까지 쉰다
+ if (++_trFails == 3 && mounted) {
+ ScaffoldMessenger.maybeOf(context)
+ ?.showSnackBar(SnackBar(content: Text(trf('웹 페이지를 번역하지 못했습니다: {0}', [e]))));
+ }
+ } finally {
+ _trBusy = false;
+ }
+ }
+
@override
void initState() {
super.initState();
BrowserPage._open++;
+ final s = widget.c.settings;
+ _lastTranslate = s.webTranslate;
+ _lastJs = s.webJavaScript;
+ _lastLang = s.uiLanguage;
+ widget.c.addListener(_onSettings);
+ if (_trOn) _translateNow();
}
@override
@@ -173,12 +289,17 @@ class _BrowserPageState extends State with RouteAware {
/// 다운로드 목록 · 플레이어 · 설정 등이 위에 올라왔다: 보던 동영상을 멈춘다 (가려진 채 소리만 나지 않게)
@override
void didPushNext() {
+ _visible = false;
_nav?.pauseMedia();
}
/// 설정 화면 등에서 돌아왔다: 바뀐 광고 설정을 지금 페이지에 적용
@override
- void didPopNext() => _applyAdSettings();
+ void didPopNext() {
+ _visible = true;
+ _applyAdSettings();
+ _translateNow();
+ }
/// YouTube 페이지면 광고 건너뛰기 · 숨기기 스크립트를 넣는다 (환경 설정 > 시작 · 웹 브라우저)
void _applyAdSettings() {
@@ -197,6 +318,8 @@ class _BrowserPageState extends State with RouteAware {
void dispose() {
browserRouteObserver.unsubscribe(this);
BrowserPage._open--;
+ widget.c.removeListener(_onSettings);
+ _trTimer?.cancel();
_address.dispose();
_addressFocus.dispose();
super.dispose();
@@ -337,8 +460,18 @@ class _BrowserPageState extends State with RouteAware {
child: widget.viewBuilder?.call(_host, _url) ??
// 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge
(widget.c.settings.browserEngine == 'chrome' && CefRuntime.readyThisRun
- ? _CefView(host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir)
- : _EdgeView(host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir)),
+ // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다
+ ? _CefView(
+ key: ValueKey(widget.c.settings.webJavaScript),
+ host: _host,
+ initialUrl: _url,
+ dataDir: widget.c.settings.webViewDataDir,
+ javaScript: widget.c.settings.webJavaScript)
+ : _EdgeView(
+ host: _host,
+ initialUrl: _url,
+ dataDir: widget.c.settings.webViewDataDir,
+ javaScript: widget.c.settings.webJavaScript)),
),
]),
),
@@ -413,7 +546,15 @@ class _BrowserPageState extends State with RouteAware {
onSubmitted: _go,
),
),
- const SizedBox(width: 8),
+ const SizedBox(width: 4),
+ btn(
+ Icons.translate,
+ _trOn
+ ? tr('원문 보기 (번역 끄기)')
+ : trf('이 페이지를 {0}(으)로 번역', [I18nController.nativeName(widget.c.settings.uiLanguage)]),
+ () => _setTranslate(!_trOn, force: true),
+ color: _trOn ? JjColors.accent : null),
+ const SizedBox(width: 4),
FilledButton.icon(
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
@@ -463,11 +604,19 @@ class _SplitHandle extends StatelessWidget {
// ───────── 앱 안 Edge 웹뷰 ─────────
+/// Edge · Android 웹뷰 설정 (처음 만들 때와 JavaScript 켜기 · 끄기에 같이 쓴다)
+InAppWebViewSettings _webSettings({required bool javaScript}) => InAppWebViewSettings(
+ javaScriptEnabled: javaScript,
+ mediaPlaybackRequiresUserGesture: false,
+ supportMultipleWindows: false,
+ );
+
class _EdgeView extends StatefulWidget {
final BrowserHost host;
final String initialUrl;
final String dataDir;
- const _EdgeView({required this.host, required this.initialUrl, required this.dataDir});
+ final bool javaScript;
+ const _EdgeView({required this.host, required this.initialUrl, required this.dataDir, required this.javaScript});
@override
State<_EdgeView> createState() => _EdgeViewState();
@@ -543,13 +692,12 @@ class _EdgeViewState extends State<_EdgeView> {
keepAlive: _keepAlive,
webViewEnvironment: snap.data,
initialUrlRequest: URLRequest(url: WebUri(normalizeAddress(widget.initialUrl))),
- initialSettings: InAppWebViewSettings(
- javaScriptEnabled: true,
- mediaPlaybackRequiresUserGesture: false,
- supportMultipleWindows: false,
- ),
+ initialSettings: _webSettings(javaScript: widget.javaScript),
onWebViewCreated: (c) async {
- h.attach(_InAppNav(c, snap.data, widget.dataDir));
+ final nav = _InAppNav(c, snap.data, widget.dataDir);
+ h.attach(nav);
+ // 살려 둔 웹뷰는 처음 설정 그대로이므로 지금 설정에 맞춘다
+ await nav.setJavaScript(widget.javaScript);
// 살려 둔 웹뷰를 다시 붙였으면 보던 주소 · 제목 · 뒤로 가기 상태를 다시 알린다
final u = await c.getUrl();
if (u != null) h.onUrl(u.toString());
@@ -606,6 +754,25 @@ class _InAppNav implements WebNav {
} catch (_) {}
}
+ @override
+ Future evaluate(String js) async {
+ try {
+ return await c.evaluateJavascript(source: js);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ /// Edge 는 앱이 넣는 스크립트 (번역 · 동영상 찾기) 는 JavaScript 를 꺼도 실행된다. Android 는 꺼지면 안 된다.
+ @override
+ Future setJavaScript(bool on) async {
+ try {
+ if ((await c.getSettings())?.javaScriptEnabled == on) return;
+ await c.setSettings(settings: _webSettings(javaScript: on));
+ await c.reload();
+ } catch (_) {}
+ }
+
static const _cookieSites = [
'https://www.youtube.com/',
'https://m.youtube.com/',
@@ -662,7 +829,9 @@ class _CefView extends StatefulWidget {
final BrowserHost host;
final String initialUrl;
final String dataDir;
- const _CefView({required this.host, required this.initialUrl, required this.dataDir});
+ final bool javaScript;
+ const _CefView(
+ {super.key, required this.host, required this.initialUrl, required this.dataDir, required this.javaScript});
@override
State<_CefView> createState() => _CefViewState();
@@ -713,7 +882,7 @@ class _CefViewState extends State<_CefView> {
}
},
));
- await _c.initialize(normalizeAddress(widget.initialUrl));
+ await _c.initialize(normalizeAddress(widget.initialUrl), javaScript: widget.javaScript);
h.attach(_CefNav(_c, widget.dataDir));
h.onHistory(true, true); // Chrome 엔진은 뒤로 · 앞으로 가능 여부를 알려 주지 않는다
if (mounted) setState(() {});
@@ -770,6 +939,20 @@ class _CefNav implements WebNav {
} catch (_) {}
}
+ /// JavaScript 를 끈 Chrome 엔진에서는 결과를 받지 못한다 (null)
+ @override
+ Future evaluate(String js) async {
+ try {
+ return await c.evaluateJavascript(js).timeout(const Duration(seconds: 10));
+ } catch (_) {
+ return null;
+ }
+ }
+
+ /// Chrome 엔진은 만든 뒤에 바꿀 수 없다: 화면이 JavaScript 설정을 열쇠로 웹뷰를 새로 만든다 ([_CefView])
+ @override
+ Future setJavaScript(bool on) async {}
+
/// YouTube · Google 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다).
/// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다.
@override
diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart
index 4ae5364..b1c8213 100644
--- a/lib/ui/settings_page.dart
+++ b/lib/ui/settings_page.dart
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
+import '../app/i18n_controller.dart' show I18nController;
import '../core/download_detect.dart';
import '../core/playlist.dart';
import '../core/subtitle_detector.dart';
@@ -476,6 +477,25 @@ class _SettingsPageState extends State {
title: Text(tr('YouTube 광고 배너 숨기기')),
subtitle: Text(tr('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다')),
),
+ SwitchListTile(
+ value: s.webTranslate,
+ onChanged: (v) => c.updateSettings((x) => x.webTranslate = v),
+ title: Text(tr('웹 페이지 자동 번역')),
+ subtitle: Text(trf(
+ '다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. '
+ '주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.',
+ [I18nController.nativeName(s.uiLanguage)])),
+ ),
+ SwitchListTile(
+ value: s.webJavaScript,
+ onChanged: (v) => c.updateSettings((x) => x.webJavaScript = v),
+ title: Text(tr('JavaScript 사용')),
+ subtitle: Text(desk
+ ? tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). '
+ 'Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')
+ : tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). '
+ '끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')),
+ ),
if (desk) ...[
ListTile(
title: Text(tr('브라우저 엔진 (앱 안)')),
diff --git a/packages/webview_cef/JJ_PATCH.md b/packages/webview_cef/JJ_PATCH.md
index ae0297e..038e9a7 100644
--- a/packages/webview_cef/JJ_PATCH.md
+++ b/packages/webview_cef/JJ_PATCH.md
@@ -21,3 +21,7 @@ CEF 실행 파일은 앱이 공식 배포처 (https://cef-builds.spotifycdn.com)
4. `windows/webview_cef_plugin.cpp`: CEF 실행 파일이 없으면 화면 갱신 스레드 (vsync) 와 IME 가로채기를 켜지 않는다.
CEF 가 있어도 IME 는 **Chrome 화면 안의 입력칸에 포커스가 있을 때만** 가로챈다.
원본은 늘 IME 조합 창을 막아서, 앱의 다른 입력칸 (주소창 · 검색 등) 의 한글 입력에 영향을 줄 수 있었다.
+
+5. `common/webview_plugin.cc` · `common/webview_handler.cc` · `lib/src/webview.dart`: JavaScript 끄기.
+ `create` 가 주소 대신 `{url, javascript: false}` 를 받으면 그 브라우저의 `CefBrowserSettings.javascript` 를 `STATE_DISABLED` 로 만든다
+ (Dart: `WebViewController.initialize(url, javaScript: false)`). 이미 만든 브라우저는 바꿀 수 없어 앱이 새로 만든다.
diff --git a/packages/webview_cef/common/webview_handler.cc b/packages/webview_cef/common/webview_handler.cc
index b0a2ba0..335d89d 100644
--- a/packages/webview_cef/common/webview_handler.cc
+++ b/packages/webview_cef/common/webview_handler.cc
@@ -255,11 +255,11 @@ void WebviewHandler::closeBrowser(int browserId)
}
}
-void WebviewHandler::createBrowser(std::string url, std::function callback)
+void WebviewHandler::createBrowser(std::string url, bool javascript, std::function callback)
{
#ifndef OS_MAC
if(!CefCurrentlyOn(TID_UI)) {
- CefPostTask(TID_UI, base::BindOnce(&WebviewHandler::createBrowser, this, url, callback));
+ CefPostTask(TID_UI, base::BindOnce(&WebviewHandler::createBrowser, this, url, javascript, callback));
return;
}
#endif
@@ -267,6 +267,7 @@ void WebviewHandler::createBrowser(std::string url, std::function cal
// Capped at 60 by CEF; ignored entirely when external begin frame drives the
// frames (GPU path below). Kept for the software fallback path.
browser_settings.windowless_frame_rate = 60;
+ if (!javascript) browser_settings.javascript = STATE_DISABLED;
CefWindowInfo window_info;
window_info.SetAsWindowless(0);
#ifdef WEBVIEW_CEF_GPU_TEXTURE
diff --git a/packages/webview_cef/common/webview_handler.h b/packages/webview_cef/common/webview_handler.h
index 8e7ec62..7918481 100644
--- a/packages/webview_cef/common/webview_handler.h
+++ b/packages/webview_cef/common/webview_handler.h
@@ -153,7 +153,8 @@ public:
void CloseAllBrowsers(bool force_close);
void closeBrowser(int browserId);
- void createBrowser(std::string url, std::function callback);
+ // javascript: false 면 그 브라우저에서 페이지 스크립트를 실행하지 않는다 (JJ_PATCH.md 5)
+ void createBrowser(std::string url, bool javascript, std::function callback);
// Drives one external BeginFrame for every live browser (GPU path only).
// Marshals to the CEF UI thread; safe to call from any thread.
diff --git a/packages/webview_cef/common/webview_plugin.cc b/packages/webview_cef/common/webview_plugin.cc
index aaef456..747bc3a 100644
--- a/packages/webview_cef/common/webview_plugin.cc
+++ b/packages/webview_cef/common/webview_plugin.cc
@@ -298,8 +298,18 @@ namespace webview_cef {
result(1, nullptr);
}
else if (name.compare("create") == 0) {
- std::string url = webview_value_get_string(values);
- m_handler->createBrowser(url, [=, this](int browserId) {
+ // 주소만 오거나, {url, javascript} 묶음 (JJ_PATCH.md 5)
+ std::string url;
+ bool javascript = true;
+ if (webview_value_get_type(values) == Webview_Value_Type_Map) {
+ WValue* u = webview_value_get_by_string(values, "url");
+ if (u != nullptr && webview_value_get_type(u) == Webview_Value_Type_String) url = webview_value_get_string(u);
+ WValue* js = webview_value_get_by_string(values, "javascript");
+ if (js != nullptr && webview_value_get_type(js) == Webview_Value_Type_Bool) javascript = webview_value_get_bool(js);
+ } else {
+ url = webview_value_get_string(values);
+ }
+ m_handler->createBrowser(url, javascript, [=, this](int browserId) {
std::shared_ptr renderer = m_createTextureFunc();
m_renderers[browserId] = renderer;
WValue *response = webview_value_new_list();
diff --git a/packages/webview_cef/lib/src/webview.dart b/packages/webview_cef/lib/src/webview.dart
index 8c54255..ab30865 100644
--- a/packages/webview_cef/lib/src/webview.dart
+++ b/packages/webview_cef/lib/src/webview.dart
@@ -74,14 +74,17 @@ class WebViewController extends ValueNotifier {
_onImeCompositionRangeChangedMessage;
/// Initializes the underlying platform view.
- Future initialize(String url) async {
+ ///
+ /// [javaScript] false: 이 브라우저에서 페이지 스크립트를 실행하지 않는다 (JJ_PATCH.md 5).
+ Future initialize(String url, {bool javaScript = true}) async {
if (_isDisposed) {
return Future.value();
}
_creatingCompleter = Completer();
try {
await WebviewManager().ready;
- List args = await _pluginChannel.invokeMethod('create', url);
+ List args = await _pluginChannel.invokeMethod(
+ 'create', javaScript ? url : {'url': url, 'javascript': false});
_browserId = args[0] as int;
_textureId = args[1] as int;
WebviewManager().onBrowserCreated(_index, _browserId);
diff --git a/test/browser_test.dart b/test/browser_test.dart
index fb10a67..343a050 100644
--- a/test/browser_test.dart
+++ b/test/browser_test.dart
@@ -61,6 +61,15 @@ class _Nav implements WebNav {
var exported = 0;
@override
Future exportCookies() async => exported++;
+ @override
+ Future evaluate(String js) async {
+ scripts.add(js);
+ return null;
+ }
+
+ final javaScript = [];
+ @override
+ Future setJavaScript(bool on) async => javaScript.add(on);
}
void main() {
diff --git a/test/web_translate_test.dart b/test/web_translate_test.dart
new file mode 100644
index 0000000..4d17a4d
--- /dev/null
+++ b/test/web_translate_test.dart
@@ -0,0 +1,108 @@
+import 'dart:convert';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:jj_mkvmaker/app/settings.dart';
+import 'package:jj_mkvmaker/core/web_translate.dart';
+
+void main() {
+ test('화면 언어 → Google 언어 코드 · 같은 언어 판단', () {
+ expect(googleLang('ko'), 'ko');
+ expect(googleLang('zh-Hans'), 'zh-CN');
+ expect(googleLang('zh-Hant'), 'zh-TW');
+ expect(googleLang('he'), 'iw');
+ expect(sameLanguage('ko-KR', 'ko'), isTrue);
+ expect(sameLanguage('en', 'ko'), isFalse);
+ expect(sameLanguage('zh-CN', 'zh-TW'), isFalse);
+ expect(sameLanguage('zh', 'zh-CN'), isTrue);
+ expect(sameLanguage('iw', 'he'), isTrue);
+ expect(sameLanguage('', 'ko'), isFalse);
+ });
+
+ test('모은 글자 읽기: JSON 문자열 · 두 번 감싼 문자열 · Map · 잘못된 값', () {
+ const json = '{"items":[[0,"Hello"],[3,"Sign in"]],"more":true}';
+ for (final raw in [json, jsonEncode(json), jsonDecode(json)]) {
+ final c = CollectedText.parse(raw)!;
+ expect(c.ids, [0, 3]);
+ expect(c.texts, ['Hello', 'Sign in']);
+ expect(c.more, isTrue);
+ }
+ expect(CollectedText.parse('{"items":[],"same":true}')!.same, isTrue);
+ expect(CollectedText.parse(null), isNull);
+ expect(CollectedText.parse('not json'), isNull);
+ });
+
+ test('Google 번역: 한 번에 보내고, 이미 그 언어인 글은 null, 같은 글은 다시 묻지 않음', () async {
+ final asked = >[];
+ final urls = [];
+ final t = WebPageTranslator(post: (url, body) async {
+ urls.add(url);
+ final q = body.split('&').map((x) => Uri.decodeQueryComponent(x.substring(2))).toList();
+ asked.add(q);
+ return jsonEncode([
+ for (final x in q)
+ x == '안녕' ? [x, 'ko'] : ['[ko] $x', 'en'],
+ ]);
+ });
+ final r = await t.translate(['Hello', '안녕', 'Hello', 'Sign in & go'], 'ko');
+ expect(r, ['[ko] Hello', null, '[ko] Hello', '[ko] Sign in & go']);
+ expect(asked, [
+ ['Hello', '안녕', 'Sign in & go'], // 같은 글은 한 번만
+ ]);
+ expect(urls.single.queryParameters, containsPair('tl', 'ko'));
+ expect(urls.single.queryParameters, containsPair('sl', 'auto'));
+ expect(urls.single.queryParameters, containsPair('format', 'text'));
+
+ // 두 번째는 기억해 둔 것으로 (서버에 묻지 않음)
+ expect(await t.translate(['Sign in & go', '안녕'], 'ko'), ['[ko] Sign in & go', null]);
+ expect(asked, hasLength(1));
+ // 대상 언어가 바뀌면 다시 묻는다
+ await t.translate(['Hello'], 'ja');
+ expect(asked, hasLength(2));
+ });
+
+ test('Google 번역: 많으면 나눠 보낸다 (100개 · 3000자)', () async {
+ final sizes = [];
+ final t = WebPageTranslator(post: (url, body) async {
+ final q = body.split('&').map((x) => Uri.decodeQueryComponent(x.substring(2))).toList();
+ sizes.add(q.length);
+ return jsonEncode([for (final x in q) ['T$x', 'en']]);
+ });
+ final texts = [for (var i = 0; i < 250; i++) 'line $i'];
+ final r = await t.translate(texts, 'ko');
+ expect(r.first, 'Tline 0');
+ expect(r.last, 'Tline 249');
+ expect(sizes, [100, 100, 50]);
+
+ sizes.clear();
+ await t.translate([for (var i = 0; i < 4; i++) 'x' * 1000 + '$i'], 'ko');
+ expect(sizes, [2, 2]);
+ });
+
+ test('Google 응답이 이상하면 오류 (개수가 다름)', () async {
+ final t = WebPageTranslator(post: (url, body) async => '[["a","en"]]');
+ expect(() => t.translate(['a', 'b'], 'ko'), throwsFormatException);
+ expect(WebPageTranslator.parseGoogleResponse('["가","나"]', 2), [('가', ''), ('나', '')]);
+ });
+
+ test('스크립트: CEF 가 앞에 return 을 붙여도 되게 줄바꿈 없이 시작 · 대상 언어 · 번역 값이 들어간다', () {
+ final collect = webTranslateCollectScript('zh-CN', force: true);
+ expect(collect, startsWith('(function(tl,force,max){'));
+ expect(collect, endsWith('("zh-CN",true,300)'));
+ final apply = webTranslateApplyScript({0: '안녕 "세상"', 2: null}, same: true);
+ expect(apply, startsWith('(function(m,same){'));
+ expect(apply, endsWith('({"0":"안녕 \\"세상\\"","2":null},true)'));
+ expect(webTranslateRestoreScript, startsWith('(function(){'));
+ });
+
+ test('설정: 번역 (기본 끔) · JavaScript (기본 켬) 저장 · 읽기', () {
+ final d = AppSettings();
+ expect(d.webTranslate, isFalse);
+ expect(d.webJavaScript, isTrue);
+ final s = AppSettings.fromJson(jsonDecode(jsonEncode((AppSettings()
+ ..webTranslate = true
+ ..webJavaScript = false)
+ .toJson())) as Map);
+ expect(s.webTranslate, isTrue);
+ expect(s.webJavaScript, isFalse);
+ });
+}