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 createState() => _BrowserPageState(); /// 브라우저 화면의 경로 이름 (이미 열려 있으면 그 화면으로 돌아가기 위해) static const routeName = 'browser'; /// 열려 있는 브라우저 화면 수 static int _open = 0; /// 마지막으로 붙은 웹뷰 (통합 테스트에서 페이지 내용을 읽는다) @visibleForTesting static WebNav? debugNav; /// 웹 브라우저로: 이미 열린 브라우저가 있으면 그 화면으로 돌아가고 (보던 페이지 그대로), /// 없으면 새로 연다. 다운로드 목록 · 플레이어 등에서 브라우저 버튼을 눌러도 보던 페이지를 잃지 않는다. static Future 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( settings: const RouteSettings(name: routeName), builder: (_) => BrowserPage(c: c, downloads: downloads, bookmarks: bookmarks), )); } } class _BrowserPageState extends State with RouteAware { /// 207: Chrome 엔진 [다시 시도] 때마다 새로 int _cefTry = 0; late String _url = widget.initialUrl ?? widget.c.session.browserLastUrl ?? 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.delayed(const Duration(milliseconds: 300)); if (mounted) retry(); }, ); } bool _hasVideo = false, _canBack = false, _canFwd = false, _panel = false; WebNav? _nav; /// 오른쪽 작업 현황 (화면 분할). 앱을 켜 둔 동안은 열림 여부 · 너비를 기억 (c.session - 설계 M5) late bool _work = widget.c.session.browserWorkOpen ?? widget.c.busy; void _toggleWork() => setState(() => widget.c.session.browserWorkOpen = _work = !_work); late final _address = TextEditingController(text: _url); final _addressFocus = FocusNode(); late final BrowserHost _host = BrowserHost( onUrl: (u) { if (!mounted || u.isEmpty) return; widget.c.session.browserLastUrl = 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 _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 _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 _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( builder: (_) => BookmarkManagerPage( bm: bm, onOpen: _go, onOpenExternal: _openExternal, currentUrl: _url.startsWith('http') ? _url : '', currentTitle: _title, initialFolderId: folderId, ), ), ); Future _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( valueListenable: WebProxy.torOpen, builder: (_, on, _) => on ? const TorBanner() : const SizedBox(), ), // 226: Tor 화면을 떠난 뒤 tor 를 잠시 켜 두는 중 (이 브라우저는 Tor 를 쓰지 않음) - 언제 끄는지 · 지금 끄기 ValueListenableBuilder( 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; widget.c.session.browserWorkWidth = (widget.c.session.browserWorkWidth - dx).clamp(260.0, max < 260 ? 260.0 : max); })), SizedBox( width: widget.c.session.browserWorkWidth, 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), ); }