diff --git a/lib/main.dart b/lib/main.dart index dfec908..3f995dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -417,7 +417,8 @@ Future runAndroid(String dataDir) async { downloads.log = controller.note; // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) AndroidKeepAlive(controller, downloads); - // 백그라운드로 실행: 화면을 닫았다가 다시 열면 새 화면 (Activity) 이 살아 있던 엔진에 붙는다 → 화면 방향을 다시 적용 + // 백그라운드로 실행: 화면 (Activity) 을 닫아도 엔진은 살아 있다가 다시 열면 새 화면에 붙는다 → 화면 방향을 다시 적용 + // (웹뷰는 텍스처로 그려 새 화면에 붙어도 그대로 보인다: browser_page _webSettings) AppLifecycleListener(onResume: () => applyScreenOrientation(controller.settings.screenOrientation)); // 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일 unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json'))); @@ -468,6 +469,7 @@ Future runAndroid(String dataDir) async { messengerKey.currentState?.showSnackBar(SnackBar( duration: const Duration(seconds: 20), content: Text(tr('동영상을 고르고 MKV 를 만들려면 "모든 파일에 대한 접근" 권한이 필요합니다.')), + persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기 action: SnackBarAction(label: tr('허용'), onPressed: AndroidAccess.request), )); } diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index 27a2113..830e35f 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -126,7 +126,9 @@ class AppNavButtons extends StatelessWidget { scope == null || onExplorerPage ? null : () => ExplorerPage.open(Navigator.of(context), c: scope.controller), here: onExplorerPage, ), - btn(const Icon(Icons.arrow_back), tr('뒤로'), atRoot ? null : () => Navigator.maybePop(context)), + btn(const Icon(Icons.arrow_back), tr('뒤로'), + // 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다 + atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context)), btn( Icon(Icons.download_for_offline_outlined, color: onDownloadsPage ? JjColors.accent : null), onDownloadsPage ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'), diff --git a/lib/ui/bookmark_ui.dart b/lib/ui/bookmark_ui.dart index 553bb4f..a6b0f0c 100644 --- a/lib/ui/bookmark_ui.dart +++ b/lib/ui/bookmark_ui.dart @@ -239,6 +239,7 @@ void removeBookmarkWithUndo(BuildContext context, BookmarksController bm, Bookma ..clearSnackBars() ..showSnackBar(SnackBar( content: Text(n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title])), + persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기 action: SnackBarAction(label: tr('실행 취소'), onPressed: bm.undoRemove), )); } diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 64365b2..6fd72cd 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; @@ -355,6 +356,7 @@ class _BrowserPageState extends State with RouteAware { content: Text(trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title])), duration: const Duration(minutes: 10), showCloseIcon: true, // [✕] 로 바로 닫기 + persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기 action: SnackBarAction(label: tr('목록 보기'), onPressed: () => DownloadsPage.open(nav, d)), )); // 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다 @@ -420,7 +422,14 @@ class _BrowserPageState extends State with RouteAware { const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _nav?.back(), const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _nav?.forward(), }, - child: Focus( + // Android 뒤로 키: 앞 웹 페이지가 있으면 웹 페이지 뒤로 (없으면 브라우저 화면을 닫는다). + // 앱 위쪽의 ← (화면 이동) 은 이것과 상관없이 브라우저 화면을 닫는다 (AppNavButtons.onBrowserPage). + child: PopScope( + canPop: !(defaultTargetPlatform == TargetPlatform.android && _canBack), + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _nav?.back(); + }, + child: Focus( autofocus: true, child: Scaffold( body: ListenableBuilder( @@ -495,6 +504,7 @@ class _BrowserPageState extends State with RouteAware { ), ), ), + ), ); } @@ -609,6 +619,9 @@ InAppWebViewSettings _webSettings({required bool javaScript}) => InAppWebViewSet javaScriptEnabled: javaScript, mediaPlaybackRequiresUserGesture: false, supportMultipleWindows: false, + // Android: 텍스처로 그린다. 기본 hybrid composition 은 백그라운드로 실행 중 화면 (Activity) 이 새로 붙을 때 + // Impeller 가 "EGL Bad Access" 로 그리지 못해 앱 화면이 비어 버린다. + useHybridComposition: false, ); class _EdgeView extends StatefulWidget { diff --git a/test/browser_test.dart b/test/browser_test.dart index 343a050..80c2fda 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; @@ -50,8 +51,9 @@ class _Nav implements WebNav { final loads = []; @override Future load(String url) async => loads.add(url); + int backs = 0; @override - Future back() async {} + Future back() async => backs++; @override Future forward() async {} @override @@ -290,6 +292,60 @@ void main() { d.dispose(); }); + testWidgets('Android 뒤로 키: 앞 웹 페이지가 있으면 웹 뒤로, 없으면 화면 닫기 · 위쪽 ← 는 늘 닫기', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final dir = Directory.systemTemp.createTempSync('jj_back_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); + final navKey = GlobalKey(); + late BrowserHost host; + final nav = _Nav(); + Future open() async { + navKey.currentState!.push(MaterialPageRoute( + builder: (_) => BrowserPage( + c: c, + bookmarks: bm, + initialUrl: 'https://example.com/', + viewBuilder: (h, url) { + host = h; + h.attach(nav); + return const ColoredBox(color: Colors.white); + }, + ), + )); + await tester.pumpAndSettle(); + } + + await tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Scaffold(body: Text('MKV 화면')))); + await open(); + host.onHistory(true, false); + await tester.pump(); + await tester.binding.handlePopRoute(); // 기기의 뒤로 키 + await tester.pumpAndSettle(); + expect(nav.backs, 1); + expect(find.byType(BrowserPage), findsOneWidget); + host.onHistory(false, true); + await tester.pump(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(nav.backs, 1); + expect(find.byType(BrowserPage), findsNothing); + + // 위쪽 ← (화면 이동) 은 웹 기록이 있어도 브라우저 화면을 닫는다 + await open(); + host.onHistory(true, false); + await tester.pump(); + await tester.tap(find.byTooltip('뒤로')); + await tester.pumpAndSettle(); + expect(nav.backs, 1); + expect(find.byType(BrowserPage), findsNothing); + debugDefaultTargetPlatformOverride = null; // 끝나기 전에 되돌려야 한다 (테스트 검사) + }); + testWidgets('브라우저: ☆ 추가 · 표시줄 · 다운로드 버튼 · 관리 패널 (삭제 · 실행 취소)', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1; @@ -364,6 +420,18 @@ void main() { await tester.tap(find.text('실행 취소')); await tester.pumpAndSettle(); expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull); + // 실행 취소 알림은 누르지 않으면 몇 초 뒤 저절로 사라진다 (Flutter 3.47+ 는 action 이 있으면 기본이 안 사라짐) + await tester.tap(find.byTooltip('더 보기').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('삭제').last); + await tester.pumpAndSettle(); + expect(find.text('실행 취소'), findsOneWidget); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(find.text('실행 취소'), findsNothing); + bm.undoRemove(); + await tester.pumpAndSettle(); + expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull); // 패널에서 누르면 그 주소로 이동 await tester.tap(find.text('OpenSubtitles').last);