JJ_MKVMaker/lib/ui/browser_page.dart

484 lines
18 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/youtube_ads.dart';
import 'bookmark_manager_page.dart';
import 'bookmark_ui.dart';
import 'work_panel.dart';
import '../l10n/tr.dart';
import 'browser/bookmark_bar.dart';
import 'browser/bookmark_panel.dart';
import 'browser/cef_view.dart';
import 'browser/web_nav.dart';
import 'browser/browser_actions.dart';
import 'browser/browser_banners.dart';
import 'browser/browser_toolbar.dart';
import 'browser/dns_block.dart';
import 'browser/page_translation.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 서버에 닿지 않아 열지 못한 페이지 (덮개)
final _dns = DnsBlockWatch();
/// 250: 사용자가 다른 곳으로 가거나 다시 읽음 - 덮개를 걷는다 (다시 실패하면 또 뜬다)
void _userNavigated() {
_dns.loadStartAt = DateTime.now();
if (_dns.blocked != null && mounted) setState(() => _dns.set(null));
}
void _checkDnsBlocked({String? host, String? error}) {
final next = _dns.find(host: host ?? Uri.tryParse(_url)?.host ?? '', error: error);
if (next == null) return;
// 기기에서 "덮개를 띄웠는데 안 보임" 과 "띄우지 않음" 을 가릴 수 있게
widget.c.note(trf('DNS 안내 덮개를 띄움 ({0} · DNS {1})', [next.$1, next.$2]));
setState(() => _dns.set(next));
}
Widget _withDnsBlock(Widget view) {
void retry() {
setState(() => _dns.set(null));
_nav?.reload();
}
return DnsBlockCover(
view: view,
blocked: _dns.blocked,
onRetry: retry,
onSystemDns: () async {
await widget.c.updateSettings((x) => x.network.mode = 'system');
// 새 DNS 설정이 프록시에 걸린 뒤 다시 읽는다
await Future<void>.delayed(const Duration(milliseconds: 300));
if (mounted) retry();
},
);
}
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) _tr.resetFails(); // 다른 페이지: 번역을 다시 시도
setState(() => _url = u);
if (!_addressFocus.hasFocus) _address.text = u;
_applyAdSettings();
_tr.now();
},
onTitle: (t) => mounted ? setState(() => _title = t) : null,
onProgress: (p) {
if (!mounted) return;
// 250: 새로 읽기 시작 - 시작 시각을 적어 둔다 (덮개는 여기서 걷지 않는다 - [_userNavigated])
if (p < 1 && _progress >= 1) _dns.loadStartAt = DateTime.now();
setState(() => _progress = p);
if (p >= 1) {
_checkDnsBlocked();
_tr.now(); // 다 읽었다: 바로 번역 (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;
// ───────── 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역) ─────────
late final _tr = PageTranslation(
on: widget.c.settings.browser.translate,
nav: () => _nav,
canRun: () => mounted && _visible && _url.startsWith('http'),
onGiveUp: (e) {
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('웹 페이지를 번역하지 못했습니다: {0}', [e]))));
},
);
/// 브라우저가 다른 화면에 가려져 있지 않은지
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 && _tr.on) {
_tr.now(); // 다른 언어로 다시 번역 (스크립트가 앞의 번역을 되돌린 뒤 번역)
}
_lastLang = s.display.language;
}
void _setTranslate(bool on, {required bool force}) => setState(() => _tr.setOn(on, force: force));
@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);
_tr.now();
// 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;
Future<void> _openTor() => openTorOrExplain(context, widget.c);
/// 53: 쿠키를 쓰도록 정했을 때만 · 고른 사이트만 내보낸다
void _applyCookieExport() {
final s = widget.c.settings;
CookieExport.enabled = s.download.ytCookiesBrowser == internalBrowserCookies;
CookieExport.sites = s.download.loginCookieSites;
}
Future<void> _clearData() => confirmClearBrowserData(context, nav: _nav, dataDir: widget.c.settings.webViewDataDir);
@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();
_tr.now();
}
/// 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() {
// 화면을 닫는 중에는 다른 위젯 (아래 알림) 을 바꾸지 않게 다음 차례에
_dns.dispose();
browserRouteObserver.unsubscribe(this);
BrowserPage._open--;
widget.c.removeListener(_onSettings);
_tr.dispose();
_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() => addPageDownload(context, widget.downloads!, nav: _nav, url: _url, title: _title);
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(onClose: () => setState(() => _swipeHint = false)),
// Android: Tor 화면이 열려 있는 동안은 웹 뷰 프록시가 앱 전체라 이 브라우저도 Tor 로 (관리자 결정 10/10)
ValueListenableBuilder<bool>(
valueListenable: WebProxy.torOpen,
builder: (_, on, _) => on ? const TorBanner() : const SizedBox(),
),
// 226: Tor 화면을 떠난 뒤 tor 를 잠시 켜 두는 중 (이 브라우저는 Tor 를 쓰지 않음) - 언제 끄는지 · 지금 끄기
ValueListenableBuilder<DateTime?>(
valueListenable: TorService.instance.idleStopAt,
builder: (_, at, _) => at == null || WebProxy.torOpen.value ? const SizedBox() : TorIdleBanner(at: 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) ??
browserEngineView(widget.c, host: _host, url: _url, cefTry: _cefTry, onCefRetry: () => setState(() => _cefTry++))),
),
]),
),
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() => BrowserToolbar(
c: widget.c,
nav: _nav,
url: _url,
progress: _progress,
canBack: _canBack,
canForward: _canFwd,
starred: bm.tree.findByUrl(_url) != null,
translating: _tr.on,
likelyVideo: _likelyVideo,
canDownload: _canDownload,
downloadTip: _downloadTip,
workOpen: _work,
panelOpen: _panel,
address: _address,
addressFocus: _addressFocus,
onGo: _go,
onStar: _star,
onToggleTranslate: () => _setTranslate(!_tr.on, force: true),
onDownload: _download,
onTor: _openTor,
onExternal: () => _openExternal(_url),
onClearData: _clearData,
onToggleWork: _toggleWork,
onTogglePanel: () => setState(() => _panel = !_panel),
);
}