JJ_MKVMaker/lib/ui/browser_page.dart

894 lines
39 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../app/app_controller.dart';
import '../app/bookmarks_controller.dart';
import '../app/download_manager.dart';
import '../app/net_guard.dart';
import '../app/tor_service.dart';
import '../app/web_proxy.dart';
import '../core/download_detect.dart' show CookieExport, internalBrowserCookies;
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/elevation.dart';
import '../services/downloader.dart' show DownloadState;
import 'app_actions.dart';
import 'bookmark_manager_page.dart';
import 'bookmark_ui.dart';
import 'confirm.dart';
import 'downloads_page.dart';
import 'key_hint.dart';
import 'settings_page.dart';
import 'tor_page.dart';
import 'theme.dart';
import 'work_panel.dart';
import '../l10n/tr.dart';
import 'notice.dart';
import 'browser/bookmark_bar.dart';
import 'browser/bookmark_panel.dart';
import 'browser/cef_view.dart';
import 'browser/edge_view.dart';
import 'browser/web_nav.dart';
export 'browser/bookmark_panel.dart' show BookmarkPanel, editBookmark;
export 'browser/edge_view.dart' show browserEnvironment, edgeBrowserArguments;
export 'browser/web_nav.dart';
/// 웹 브라우저 (앱 안 Edge 엔진). 동영상 페이지면 상단 [다운로드] 로 바로 받는다.
class BrowserPage extends StatefulWidget {
final AppController c;
final DownloadManager? downloads;
final BookmarksController bookmarks;
final String? initialUrl;
/// 테스트용 웹뷰 대체
final Widget Function(BrowserHost host, String url)? viewBuilder;
const BrowserPage({
super.key,
required this.c,
required this.bookmarks,
this.downloads,
this.initialUrl,
this.viewBuilder,
});
@override
State<BrowserPage> createState() => _BrowserPageState();
/// 브라우저 화면의 경로 이름 (이미 열려 있으면 그 화면으로 돌아가기 위해)
static const routeName = 'browser';
/// 열려 있는 브라우저 화면 수
static int _open = 0;
/// 마지막으로 보던 주소: MKV 화면으로 갔다가 브라우저를 다시 열면 홈이 아니라 이 주소로
static String? _lastUrl;
/// 마지막으로 붙은 웹뷰 (통합 테스트에서 페이지 내용을 읽는다)
@visibleForTesting
static WebNav? debugNav;
/// 웹 브라우저로: 이미 열린 브라우저가 있으면 그 화면으로 돌아가고 (보던 페이지 그대로),
/// 없으면 새로 연다. 다운로드 목록 · 플레이어 등에서 브라우저 버튼을 눌러도 보던 페이지를 잃지 않는다.
static Future<void> open(NavigatorState nav,
{required AppController c, DownloadManager? downloads, required BookmarksController bookmarks}) async {
if (_open > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == routeName) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(
settings: const RouteSettings(name: routeName),
builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks),
));
}
}
class _BrowserPageState extends State<BrowserPage> with RouteAware {
/// 207: Chrome 엔진 [다시 시도] 때마다 새로
int _cefTry = 0;
late String _url = widget.initialUrl ?? BrowserPage._lastUrl ?? widget.c.settings.browser.homeUrl;
String _title = '';
double _progress = 1;
/// 250: 이 페이지를 DNS 서버에 닿지 않아 열지 못함 (주소 이름 · DNS 서버). 엔진의 오류 화면 위에 이유와 할 일을 보인다
(String, String)? _dnsBlocked;
DateTime _loadStartAt = DateTime.now();
/// [error]: 엔진이 알려 준 본문 오류. 프록시 터널 실패이고 DNS 서버에 지금 닿지 않는 중이면, 그 이름의 실패 기록이 없어도
/// (Chromium 이 실패한 프록시를 기억해 다시 묻지 않는 때 등) DNS 때문으로 본다.
/// 덮개는 여기서 걷지 않는다 (새로 읽기 시작 · [다시 시도] 에서) - Android 는 오류 뒤에 읽기 끝이 또 온다.
/// 248: 덮개가 보이는 동안은 아래 알림을 띄우지 않는다 (같은 안내 · [시스템 DNS 로 바꾸기] 가 두 개 보였다 - a4 933)
void _setDnsBlocked((String, String)? v) {
final g = NetGuard.instance.noticeBlockers;
if (_dnsBlocked == null && v != null) g.value++;
if (_dnsBlocked != null && v == null) g.value--;
_dnsBlocked = v;
}
/// 250: 사용자가 다른 곳으로 가거나 다시 읽음 - 덮개를 걷는다 (다시 실패하면 또 뜬다)
void _userNavigated() {
_loadStartAt = DateTime.now();
if (_dnsBlocked != null && mounted) setState(() => _setDnsBlocked(null));
}
void _checkDnsBlocked({String? host, String? error}) {
final h = host ?? Uri.tryParse(_url)?.host ?? '';
if (h.isEmpty) return;
final g = NetGuard.instance;
var server = g.dnsUnreachableFor(h, _loadStartAt);
final f = g.failure.value;
if (server == null && f != null && error != null && (error.contains('TUNNEL') || error.contains('PROXY'))) server = f.server;
if (server == null) return;
final next = (h, server);
if (next == _dnsBlocked) return;
// 기기에서 "덮개를 띄웠는데 안 보임" 과 "띄우지 않음" 을 가릴 수 있게
widget.c.note(trf('DNS 안내 덮개를 띄움 ({0} · DNS {1})', [h, server]));
setState(() => _setDnsBlocked(next));
}
/// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]).
/// 트리 모양은 늘 같게 (Stack 첫 칸이 웹 뷰) - 덮개를 띄울 때 모양이 바뀌면 웹 뷰가 새로 만들어져 페이지를 다시 읽고,
/// 다시 읽기가 시작되며 덮개가 걷혔다 (931 노트 10+ 에서 덮개가 보이지 않던 것, Windows 실제 시험에서 재현)
Widget _withDnsBlock(Widget view) {
final b = _dnsBlocked;
void retry() {
setState(() => _setDnsBlocked(null));
_nav?.reload();
}
return Stack(children: [
Positioned.fill(child: view),
if (b != null)
Positioned.fill(
child: ColoredBox(
color: JjColors.bg,
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.dns_outlined, size: 40, color: Colors.orange),
const SizedBox(height: 12),
Text(trf('DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.', [b.$2, b.$1]),
textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)),
const SizedBox(height: 8),
Text(tr('인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.'),
textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: JjColors.textDim)),
const SizedBox(height: 16),
Wrap(spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [
FilledButton(
onPressed: () async {
await widget.c.updateSettings((x) => x.network.mode = 'system');
// 새 DNS 설정이 프록시에 걸린 뒤 다시 읽는다
await Future<void>.delayed(const Duration(milliseconds: 300));
if (mounted) retry();
},
child: Text(tr('시스템 DNS 로 바꾸기')),
),
OutlinedButton(onPressed: retry, child: Text(tr('다시 시도'))),
]),
]),
),
),
),
),
),
]);
}
bool _hasVideo = false, _canBack = false, _canFwd = false, _panel = false;
WebNav? _nav;
/// 오른쪽 작업 현황 (화면 분할). 앱을 켜 둔 동안은 열림 여부 · 너비를 기억.
static bool? _workOpen;
static double _workWidth = 360;
late bool _work = _workOpen ?? widget.c.busy;
void _toggleWork() => setState(() => _workOpen = _work = !_work);
late final _address = TextEditingController(text: _url);
final _addressFocus = FocusNode();
late final BrowserHost _host = BrowserHost(
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) {
if (!mounted) return;
// 250: 새로 읽기 시작 - 시작 시각을 적어 둔다 (덮개는 여기서 걷지 않는다 - [_userNavigated])
if (p < 1 && _progress >= 1) _loadStartAt = DateTime.now();
setState(() => _progress = p);
if (p >= 1) {
_checkDnsBlocked();
_translateNow(); // 다 읽었다: 바로 번역 (2초를 기다리지 않고)
}
},
onVideo: (v) => mounted ? setState(() => _hasVideo = v) : null,
onHistory: (b, f) {
if (!mounted) return;
setState(() {
_canBack = b;
_canFwd = f;
});
},
attach: (n) => _nav = BrowserPage.debugNav = NavWatch(n, _userNavigated),
// 250: 엔진의 본문 오류 (Edge · Android). 읽기 끝 (진행률) 만으로는 Android 에서 덮개가 뜨지 않았다 (931, 노트 10+)
onMainFrameError: (u, e) {
if (!mounted) return;
final host = Uri.tryParse(u)?.host ?? '';
if (NetGuard.instance.forcing || NetGuard.instance.failure.value != null) {
widget.c.note(trf('웹 페이지를 열지 못함 ({0}): {1}', [host, e]));
}
_checkDnsBlocked(host: host, error: e);
},
);
BookmarksController get bm => widget.bookmarks;
// ───────── 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역) ─────────
static final _translator = WebPageTranslator();
/// 이 화면에서 번역 중인지. 처음엔 설정을 따르고, 도구 막대의 [번역] 버튼으로 바꾼다.
late bool _trOn = widget.c.settings.browser.translate;
/// 버튼으로 직접 켰다: 이미 화면 언어로 보이는 페이지도 번역한다
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;
_applyCookieExport();
final s = widget.c.settings;
if (s.browser.javaScript != _lastJs) {
_lastJs = s.browser.javaScript;
_nav?.setJavaScript(_lastJs);
}
if (s.browser.translate != _lastTranslate) {
_lastTranslate = s.browser.translate;
_setTranslate(_lastTranslate, force: false);
} else if (s.display.language != _lastLang && _trOn) {
_translateNow(); // 다른 언어로 다시 번역 (스크립트가 앞의 번역을 되돌린 뒤 번역)
}
_lastLang = s.display.language;
}
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<void> _translateTick() async {
final nav = _nav;
if (!_trOn || _trBusy || nav == null || !_visible || !_url.startsWith('http') || _trFails >= 3) return;
_trBusy = true;
try {
final target = googleLang(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<int>(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.browser.translate;
_lastJs = s.browser.javaScript;
_lastLang = s.display.language;
_applyCookieExport();
widget.c.addListener(_onSettings);
if (_trOn) _translateNow();
// 66-2: 브라우저 가운데는 페이지가 좌우로 밀리므로 화면 옮기기는 위쪽 막대에서 - 처음 한 번만 알린다.
// 알림 (SnackBar) 은 다른 알림 (다운로드 등) 을 뒤로 밀어 기다리게 하므로 위쪽 막대 아래의 한 줄로
if (s.display.swipeNav && !s.browser.swipeHinted) {
_swipeHint = true;
WidgetsBinding.instance.addPostFrameCallback((_) => widget.c.updateSettings((x) => x.browser.swipeHinted = true));
}
}
/// 66-2: 화면 옮기기 안내 한 줄 (처음 한 번, ✕ 로 닫음)
bool _swipeHint = false;
Widget _swipeHintBar() => Material(
color: JjColors.accent.withValues(alpha: 0.14),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 2, 4, 2),
child: Row(children: [
Icon(Icons.swipe, size: 16, color: JjColors.accent),
const SizedBox(width: 8),
Expanded(
child: Text(tr('웹 브라우저에서는 위쪽 막대를 좌우로 밀면 다른 화면으로 옮깁니다 (가운데는 페이지가 밀립니다).'),
style: const TextStyle(fontSize: 12))),
IconButton(
tooltip: tr('안내 닫기'),
visualDensity: VisualDensity.compact,
iconSize: 16,
icon: const Icon(Icons.close),
onPressed: () => setState(() => _swipeHint = false),
),
]),
),
);
Widget _torIdleBanner(DateTime at) => Material(
color: JjColors.panel,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 2, 4, 2),
child: Row(children: [
Icon(Icons.timer_outlined, size: 16, color: JjColors.textDim),
const SizedBox(width: 8),
Expanded(
child: Text(
trf('Tor 대기 중 ({0}분 뒤 끔) - 이 브라우저는 Tor 를 쓰지 않습니다', [(at.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)]),
style: TextStyle(fontSize: 12, color: JjColors.textDim),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
TextButton(onPressed: () => TorService.instance.stop(), child: Text(tr('지금 끄기'))),
]),
),
);
Widget _torBanner() => Material(
color: const Color(0xFF9C6ADE).withValues(alpha: 0.18),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
child: Row(children: [
const Icon(Icons.vpn_lock_outlined, size: 16, color: Color(0xFF9C6ADE)),
const SizedBox(width: 8),
Expanded(child: Text(tr('Tor 화면이 열려 있는 동안 이 브라우저도 Tor 로 나갑니다'), style: const TextStyle(fontSize: 12))),
]),
),
);
/// Tor 브라우저로: 켜져 있으면 Tor 화면, 아니면 짧은 안내와 [환경 설정 > 컴포넌트]
Future<void> _openTor() async {
if (widget.c.settings.display.components.contains('tor')) {
await TorPage.open(Navigator.of(context), c: widget.c);
return;
}
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('Tor 브라우저')),
content: Text(tr('Tor 브라우저는 페이지를 Tor 로 열어 사이트가 이 기기의 주소를 알 수 없게 합니다 (쿠키 · 기록은 따로, 닫으면 지움). '
'환경 설정 > 컴포넌트 에서 켜면 이 브라우저 옆에 Tor 화면이 생깁니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('환경 설정 > 컴포넌트'))),
],
),
);
if (go == true && mounted) {
await Navigator.push(context, MaterialPageRoute<void>(builder: (_) => SettingsPage(c: widget.c, initialGroup: 'components')));
}
}
/// 53: 쿠키를 쓰도록 정했을 때만 · 고른 사이트만 내보낸다
void _applyCookieExport() {
final s = widget.c.settings;
CookieExport.enabled = s.download.ytCookiesBrowser == internalBrowserCookies;
CookieExport.sites = s.download.loginCookieSites;
}
/// 53: 쿠키 · 방문 기록 지우기 (확인 뒤). 다운로드용으로 내보낸 쿠키 파일도 지운다
Future<void> _clearData() async {
final ok = await confirmAction(
context,
title: tr('쿠키 · 방문 기록 지우기'),
body: tr('앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. '
'사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.'),
ok: tr('지우기'),
);
if (!ok || !mounted) return;
try {
await _nav?.clearData();
} catch (_) {}
await deleteExportedCookies(widget.c.settings.webViewDataDir);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('쿠키 · 방문 기록을 지웠습니다.'))));
unawaited(_nav?.reload());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null) browserRouteObserver.subscribe(this, route);
}
/// 다운로드 목록 · 플레이어 · 설정 등이 위에 올라왔다: 보던 동영상을 멈춘다 (가려진 채 소리만 나지 않게)
@override
void didPushNext() {
_visible = false;
// 환경 설정 > 웹 브라우저 > "다른 화면으로 가면 페이지 소리 멈춤" (끄면 다른 화면에서도 계속 들림)
if (widget.c.settings.browser.pauseOnLeave) _nav?.pauseMedia();
}
/// 설정 화면 등에서 돌아왔다: 바뀐 광고 설정을 지금 페이지에 적용
@override
void didPopNext() {
_visible = true;
_applyAdSettings();
_translateNow();
}
/// YouTube 페이지면 광고 건너뛰기 · 숨기기 스크립트를 넣는다 (환경 설정 > 시작 · 웹 브라우저)
void _applyAdSettings() {
if (!(Uri.tryParse(_url)?.host ?? '').endsWith('youtube.com')) return;
final s = widget.c.settings;
_nav?.runScript(youtubeAdScript(skip: s.browser.adSkip, hide: s.browser.adHide, badge: tr('광고 건너뛰는 중…')));
}
/// MKV 화면 등으로 가며 브라우저 화면이 닫힌다: 살려 둔 웹뷰에서 소리만 나지 않게 멈춘다
@override
void didPop() {
if (widget.c.settings.browser.pauseOnLeave) _nav?.pauseMedia();
}
@override
void dispose() {
// 화면을 닫는 중에는 다른 위젯 (아래 알림) 을 바꾸지 않게 다음 차례에
if (_dnsBlocked != null) scheduleMicrotask(() => NetGuard.instance.noticeBlockers.value--);
browserRouteObserver.unsubscribe(this);
BrowserPage._open--;
widget.c.removeListener(_onSettings);
_trTimer?.cancel();
_address.dispose();
_addressFocus.dispose();
super.dispose();
}
void _go(String input) {
final url = normalizeAddress(input);
_address.text = url;
_addressFocus.unfocus();
_nav?.load(url);
}
/// 27: 어느 사이트에서나 받아 볼 수 있다 (yt-dlp 가 지원하면 받고, 아니면 다운로드 목록에 이유가 보인다)
bool get _canDownload => widget.downloads != null && _url.startsWith('http');
/// 동영상이 있어 보이는 페이지 (버튼을 눈에 띄게). 137: 못 찾은 페이지는 테두리만 있는 버튼
bool get _likelyVideo => downloadLooksUseful(_url, pageHasVideo: _hasVideo);
String get _downloadTip =>
_likelyVideo ? tr('다운로드') : tr('다운로드 (이 페이지에서 동영상을 찾지 못했지만 받아 볼 수 있습니다)');
Future<void> _download() async {
// 로그인 쿠키를 먼저 내보내 yt-dlp 가 같은 로그인으로 받도록
try {
await _nav?.exportCookies();
} catch (_) {}
if (!mounted) return;
final d = widget.downloads!;
final t = d.addPage(_url);
final m = ScaffoldMessenger.of(context);
if (t == null) {
m.showSnackBar(SnackBar(content: Text(tr('이미 받는 중이거나 받을 수 없는 주소입니다.'))));
return;
}
// 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다
// (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다)
final nav = Navigator.of(context);
// 받기 준비가 끝날 때까지 두고 아래 check 가 닫는다 ([✕] 로 바로 닫기)
final bar = showNotice(m,
life: NoticeLife.untilClosed,
text: trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title]),
action: (tr('목록 보기'), () => DownloadsPage.open(nav, d)))!;
// 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다
void check() {
final ready = !d.tasks.contains(t) ||
t.progress != null ||
(t.state != DownloadState.queued && t.state != DownloadState.downloading);
if (ready) {
d.removeListener(check);
bar.close();
}
}
d.addListener(check);
unawaited(bar.closed.then((_) => d.removeListener(check)));
}
void _openExternal(String url) =>
widget.c.services.shell.openUrl(url, browser: widget.c.settings.browser.external);
// ───────── 즐겨찾기 ─────────
/// 즐겨찾기 관리자 (전체 화면, Ctrl+Shift+O)
void _openManager({String? folderId}) => Navigator.push(
context,
MaterialPageRoute<void>(
builder: (_) => BookmarkManagerPage(
bm: bm,
onOpen: _go,
onOpenExternal: _openExternal,
currentUrl: _url.startsWith('http') ? _url : '',
currentTitle: _title,
initialFolderId: folderId,
),
),
);
Future<void> _star() async {
final existing = bm.tree.findByUrl(_url);
if (existing != null) {
await editBookmark(context, bm, existing);
return;
}
final n = bm.addLink(_title.isEmpty ? _url : _title, _url);
if (!mounted) return;
await showBookmarkEditor(context, bm, n, justAdded: true);
}
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyL, control: true): () {
_addressFocus.requestFocus();
_address.selection = TextSelection(baseOffset: 0, extentOffset: _address.text.length);
},
const SingleActivator(LogicalKeyboardKey.f5): () => _nav?.reload(),
const SingleActivator(LogicalKeyboardKey.keyD, control: true): _star,
const SingleActivator(LogicalKeyboardKey.keyB, control: true, shift: true): () =>
setState(() => _panel = !_panel),
const SingleActivator(LogicalKeyboardKey.keyO, control: true, shift: true): _openManager,
const SingleActivator(LogicalKeyboardKey.keyJ, control: true, shift: true): _toggleWork,
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _nav?.back(),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _nav?.forward(),
},
// Android 뒤로 키: 앞 웹 페이지가 있으면 웹 페이지 뒤로 (없으면 브라우저 화면을 닫는다).
// 앱 위쪽의 ← (화면 이동) 은 이것과 상관없이 브라우저 화면을 닫는다 (AppNavButtons.onBrowserPage).
child: PopScope(
canPop: !(defaultTargetPlatform == TargetPlatform.android && _canBack),
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _nav?.back();
},
child: Focus(
autofocus: true,
child: Scaffold(
body: ListenableBuilder(
listenable: Listenable.merge([bm, widget.c]),
builder: (context, _) => Column(children: [
_toolbar(),
if (_swipeHint) _swipeHintBar(),
// Android: Tor 화면이 열려 있는 동안은 웹 뷰 프록시가 앱 전체라 이 브라우저도 Tor 로 (관리자 결정 10/10)
ValueListenableBuilder<bool>(
valueListenable: WebProxy.torOpen,
builder: (_, on, _) => on ? _torBanner() : const SizedBox(),
),
// 226: Tor 화면을 떠난 뒤 tor 를 잠시 켜 두는 중 (이 브라우저는 Tor 를 쓰지 않음) - 언제 끄는지 · 지금 끄기
ValueListenableBuilder<DateTime?>(
valueListenable: TorService.instance.idleStopAt,
builder: (_, at, _) => at == null || WebProxy.torOpen.value ? const SizedBox() : _torIdleBanner(at),
),
Expanded(child: Row(children: [
if (_panel)
SizedBox(
width: 320,
child: BookmarkPanel(
bm: bm,
currentUrl: _url,
currentTitle: _title,
onOpen: (u) => _go(u),
onOpenExternal: _openExternal,
onOpenManager: _openManager,
onClose: () => setState(() => _panel = false),
),
),
if (_panel) const VerticalDivider(width: 1),
Expanded(
child: Column(children: [
BookmarkBar(
bm: bm,
onOpen: _go,
onOpenExternal: _openExternal,
currentUrl: _url,
currentTitle: _title,
onOpenManager: _openManager,
),
SizedBox(
height: 2,
child: _progress < 1 ? LinearProgressIndicator(value: _progress) : null,
),
Expanded(
child: _withDnsBlock(widget.viewBuilder?.call(_host, _url) ??
// 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge
(widget.c.settings.browser.engine == 'chrome' && CefRuntime.readyThisRun
// 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것
? Elevation.elevated && !widget.c.settings.browser.cefWhenElevated
? CefElevatedBlocked(c: widget.c)
// Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다
: CefView(
key: ValueKey('${widget.c.settings.browser.javaScript}|$_cefTry'),
host: _host,
initialUrl: _url,
dataDir: widget.c.settings.webViewDataDir,
javaScript: widget.c.settings.browser.javaScript,
// 207: 시작하지 못하면 사용자가 바로 고를 수 있게
onUseEdge: () => widget.c.updateSettings((x) => x.browser.engine = 'edge'),
onRetry: () => setState(() => _cefTry++),
cefWhenElevated: widget.c.settings.browser.cefWhenElevated)
: EdgeView(
host: _host,
initialUrl: _url,
dataDir: widget.c.settings.webViewDataDir,
javaScript: widget.c.settings.browser.javaScript))),
),
]),
),
if (_work) ...[
_SplitHandle(onDrag: (dx) => setState(() {
final max = MediaQuery.sizeOf(context).width - 400;
_workWidth = (_workWidth - dx).clamp(260.0, max < 260 ? 260.0 : max);
})),
SizedBox(
width: _workWidth,
child: WorkPanel(
c: widget.c,
downloads: widget.downloads,
onClose: _toggleWork,
onOpenHome: () => Navigator.maybePop(context),
),
),
],
])),
]),
),
),
),
),
);
}
/// 위쪽 막대: 여기서 좌우로 밀면 화면 이동 (웹 페이지 위에서는 웹 페이지의 스크롤과 다투어 위 · 아래가 이상했음)
Widget _toolbar() => SwipeNav(current: 'browser', anywhere: true, child: _toolbarBar());
Widget _toolbarBar() {
final starred = bm.tree.findByUrl(_url) != null;
IconButton btn(IconData i, String tip, VoidCallback? f, {Color? color}) => IconButton(
tooltip: tip,
visualDensity: VisualDensity.compact,
icon: Icon(i, size: 20, color: color),
onPressed: f,
);
// 주소 칸이 좁아지면 (폰 세로 · 태블릿 세로 · 폰 가로 100%) 자주 안 쓰는 버튼은 ⋮ 메뉴로 - 주소가 "http" 만 보이거나
// 아예 사라지던 것. 버튼을 없애지는 않는다.
// 201: 폰 가로처럼 넓어도 버튼이 폭을 다 차지하면 주소 칸이 사라졌다 - 그때는 두 줄 (아래 줄을 주소 칸이 다 씀)
return AppTopBar(
minMiddleWidth: 380,
nav: const AppNavButtons(onBrowserPage: true),
actions: AppActions(c: widget.c),
middle: LayoutBuilder(builder: (context, box) {
final compact = box.maxWidth < 640;
return Row(children: [
if (!compact) ...[
Container(width: 1, height: 24, color: JjColors.border),
const SizedBox(width: 4),
],
// 웹 페이지 이동 (앱 화면 이동인 왼쪽 버튼과 구분되는 모양)
btn(Icons.arrow_back_ios_new, keyHint(tr('이전 페이지'), 'Alt+←'), _canBack ? () => _nav?.back() : null),
btn(Icons.arrow_forward_ios, keyHint(tr('다음 페이지'), 'Alt+→'), _canFwd ? () => _nav?.forward() : null),
_progress < 1
? btn(Icons.close, tr('중지'), () => _nav?.stop())
: btn(Icons.refresh, keyHint(tr('새로고침'), 'F5'), () => _nav?.reload()),
if (!compact) btn(Icons.home_outlined, tr('홈'), () => _go(widget.c.settings.browser.homeUrl)),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: _address,
focusNode: _addressFocus,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: JjColors.bg,
hintText: keyHint(tr('주소 입력 또는 검색'), 'Ctrl+L'),
prefixIcon: Icon(_url.startsWith('https') ? Icons.lock_outline : Icons.public, size: 16),
prefixIconConstraints: const BoxConstraints(minWidth: 32),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none),
suffixIcon: IconButton(
tooltip: starred ? keyHint(tr('즐겨찾기 수정'), 'Ctrl+D') : keyHint(tr('즐겨찾기 추가'), 'Ctrl+D'),
icon: Icon(starred ? Icons.star : Icons.star_border, size: 18,
color: starred ? JjColors.amber : JjColors.textDim),
onPressed: _star,
),
),
onSubmitted: _go,
),
),
const SizedBox(width: 4),
if (!compact)
btn(
Icons.translate,
_trOn
? tr('원문 보기 (번역 끄기)')
: trf('이 페이지를 {0}(으)로 번역', [I18nController.nativeName(widget.c.settings.display.language)]),
() => _setTranslate(!_trOn, force: true),
color: _trOn ? JjColors.accent : null),
const SizedBox(width: 4),
if (compact)
_likelyVideo
? IconButton.filled(
tooltip: _downloadTip,
visualDensity: VisualDensity.compact,
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
)
: IconButton.outlined(
tooltip: _downloadTip,
visualDensity: VisualDensity.compact,
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
)
else
Tooltip(
message: _downloadTip,
child: _likelyVideo
? FilledButton.icon(
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
label: Text(tr('다운로드')),
)
: OutlinedButton.icon(
onPressed: _canDownload ? _download : null,
icon: const Icon(Icons.download, size: 18),
label: Text(tr('다운로드')),
),
),
const SizedBox(width: 4),
if (!compact) btn(Icons.vpn_lock_outlined, tr('Tor 브라우저'), _openTor),
if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)),
if (!compact) btn(Icons.cookie_outlined, tr('쿠키 · 방문 기록 지우기'), _clearData),
if (widget.c.busy && !_work && !compact)
InkWell(
onTap: _toggleWork,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: JobIndicator(c: widget.c, compact: true),
),
),
if (!compact)
btn(_work ? Icons.view_sidebar : Icons.view_sidebar_outlined, keyHint(tr('작업 현황 보기 · 화면 분할'), 'Ctrl+Shift+J'),
_toggleWork, color: widget.c.busy ? JjColors.accent : null),
if (!compact)
btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, keyHint(tr('즐겨찾기 관리'), 'Ctrl+Shift+B'),
() => setState(() => _panel = !_panel))
else
PopupMenuButton<String>(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, size: 20),
onSelected: (v) => switch (v) {
'home' => _go(widget.c.settings.browser.homeUrl),
'translate' => _setTranslate(!_trOn, force: true),
'external' => _openExternal(_url),
'work' => _toggleWork(),
'clear' => _clearData(),
'tor' => _openTor(),
_ => setState(() => _panel = !_panel),
},
itemBuilder: (_) => [
PopupMenuItem(value: 'home', child: ListTile(dense: true, leading: const Icon(Icons.home_outlined), title: Text(tr('홈')))),
PopupMenuItem(
value: 'translate',
child: ListTile(
dense: true,
leading: Icon(Icons.translate, color: _trOn ? JjColors.accent : null),
title: Text(_trOn ? tr('원문 보기 (번역 끄기)') : tr('이 페이지 번역')))),
PopupMenuItem(
value: 'bookmarks',
child: ListTile(dense: true, leading: const Icon(Icons.bookmarks_outlined), title: Text(tr('즐겨찾기 관리')))),
PopupMenuItem(
value: 'work',
child: ListTile(dense: true, leading: const Icon(Icons.view_sidebar_outlined), title: Text(tr('작업 현황')))),
PopupMenuItem(
value: 'tor',
child: ListTile(dense: true, leading: const Icon(Icons.vpn_lock_outlined), title: Text(tr('Tor 브라우저')))),
PopupMenuItem(
value: 'external',
child: ListTile(dense: true, leading: const Icon(Icons.open_in_new), title: Text(tr('외부 브라우저로 열기')))),
PopupMenuItem(
value: 'clear',
child: ListTile(dense: true, leading: const Icon(Icons.cookie_outlined), title: Text(tr('쿠키 · 방문 기록 지우기')))),
],
),
]);
}),
);
}
}
/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대
class _SplitHandle extends StatelessWidget {
final ValueChanged<double> onDrag;
const _SplitHandle({required this.onDrag});
@override
Widget build(BuildContext context) => MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) => onDrag(d.delta.dx),
child: Container(
width: 6,
color: JjColors.bg,
alignment: Alignment.center,
child: Container(width: 1, color: JjColors.border),
),
),
);
}