484 lines
18 KiB
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),
|
|
);
|
|
}
|
|
|
|
|