From 14b2489938dcec6cd3076941b630f3eaabf357ef Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:02:50 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC:=20=EC=A4=80=EB=B9=84=EA=B0=80=20=EB=81=9D?= =?UTF-8?q?=EB=82=98=EB=A9=B4=20=EB=8B=AB=ED=9E=98,=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EC=98=A4=EA=B0=84=20=EB=92=A4=EC=97=90=EB=8F=84=20?= =?UTF-8?q?'=EB=AA=A9=EB=A1=9D=20=EB=B3=B4=EA=B8=B0'=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=20(=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=80=20=EC=97=B4=EB=A6=B0=20=ED=99=94=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- lib/main.dart | 6 ++- lib/ui/app_actions.dart | 2 +- lib/ui/browser_page.dart | 38 ++++++++++++----- lib/ui/downloads_page.dart | 30 ++++++++++++++ lib/ui/home_page.dart | 3 +- lib/ui/work_panel.dart | 7 +--- test/browser_test.dart | 85 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 150 insertions(+), 21 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index a9f2f6c..9540ed2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -177,8 +177,10 @@ Future main(List args) async { hotkey: controller.settings.showHotkey, minimizeToTray: () => controller.settings.minimizeToTray, onCloseRequested: confirmQuit, - onDownloadsRequested: () => navigatorKey.currentState - ?.push(MaterialPageRoute(builder: (_) => DownloadsPage(d: downloads))), + onDownloadsRequested: () { + final nav = navigatorKey.currentState; + if (nav != null) unawaited(DownloadsPage.open(nav, downloads)); + }, ); // 제목 표시줄에 버전 · 지난번 창 위치에서 열기 · 동영상 목록 불러오기 (창끼리 공유) await windowManager.setTitle(appTitle); diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index ec17672..fdaa45e 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -109,7 +109,7 @@ class AppNavButtons extends StatelessWidget { onDownloadsPage ? '다운로드 목록 (지금 여기)' : '다운로드 목록', downloads == null || onDownloadsPage ? null - : () => Navigator.push(context, MaterialPageRoute(builder: (_) => DownloadsPage(d: downloads))), + : () => DownloadsPage.open(Navigator.of(context), downloads), here: onDownloadsPage, ), ]), diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 9e5b933..2069f4c 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -14,6 +15,7 @@ import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies; import '../core/web_address.dart'; import '../platform/windows/cef_runtime.dart'; import '../platform/windows/com_guard.dart'; +import '../services/downloader.dart' show DownloadState; import 'app_actions.dart'; import 'bookmark_ui.dart'; import 'downloads_page.dart'; @@ -199,18 +201,34 @@ class _BrowserPageState extends State with RouteAware { await _nav?.exportCookies(); } catch (_) {} if (!mounted) return; - final t = widget.downloads!.addPage(_url); + final d = widget.downloads!; + final t = d.addPage(_url); final m = ScaffoldMessenger.of(context); - m.showSnackBar(SnackBar( - content: Text(t == null ? '이미 받는 중이거나 받을 수 없는 주소입니다.' : '다운로드 추가: ${_title.isEmpty ? _url : _title}'), - action: t == null - ? null - : SnackBarAction( - label: '목록 보기', - onPressed: () => Navigator.push( - context, MaterialPageRoute(builder: (_) => DownloadsPage(d: widget.downloads!))), - ), + if (t == null) { + m.showSnackBar(const SnackBar(content: Text('이미 받는 중이거나 받을 수 없는 주소입니다.'))); + return; + } + // 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다 + // (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다) + final nav = Navigator.of(context); + final bar = m.showSnackBar(SnackBar( + content: Text('다운로드 추가: ${_title.isEmpty ? _url : _title}'), + duration: const Duration(minutes: 10), + action: SnackBarAction(label: '목록 보기', onPressed: () => 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) => diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index dc8d111..585fa33 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -15,13 +15,43 @@ class DownloadsPage extends StatefulWidget { @override State createState() => _DownloadsPageState(); + + /// 다운로드 목록 화면의 경로 이름 (이미 열려 있으면 그 화면으로 돌아가기 위해) + static const routeName = 'downloads'; + + /// 열려 있는 다운로드 목록 화면 수 + static int _open = 0; + + /// 다운로드 목록으로: 이미 열려 있으면 그 화면으로 돌아가고, 없으면 새로 연다. + /// 위쪽 [다운로드 목록] 버튼 · 브라우저의 "목록 보기" · 작업 현황 등 어디서 열어도 같다. + static Future open(NavigatorState nav, DownloadManager d) 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: (_) => DownloadsPage(d: d), + )); + } } class _DownloadsPageState extends State { final _url = TextEditingController(); + @override + void initState() { + super.initState(); + DownloadsPage._open++; + } + @override void dispose() { + DownloadsPage._open--; _url.dispose(); super.dispose(); } diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 77109ad..f091ed7 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -288,8 +288,7 @@ class _DownloadBox extends StatelessWidget { final p = d.overallProgress; return InkWell( borderRadius: BorderRadius.circular(6), - onTap: () => Navigator.push( - context, MaterialPageRoute(builder: (_) => DownloadsPage(d: d))), + onTap: () => DownloadsPage.open(Navigator.of(context), d), child: Container( width: 150, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index affa273..b191fa7 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -48,12 +48,7 @@ class WorkPanel extends StatelessWidget { _title( '다운로드 ${downloads!.tasks.length}개', action: TextButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => DownloadsPage(d: downloads!), - ), - ), + onPressed: () => DownloadsPage.open(Navigator.of(context), downloads!), child: const Text( '전체 목록', style: TextStyle(fontSize: 12), diff --git a/test/browser_test.dart b/test/browser_test.dart index 683c2d9..8c525c7 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/downloader.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; import 'package:path/path.dart' as p; class _Backend implements DownloadBackend { @@ -182,6 +183,90 @@ void main() { }); }); + testWidgets('다운로드 알림: 브라우저 화면이 닫혀도 "목록 보기" 가 되고, 준비가 끝나면 사라진다', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_dlbar_'); + 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 d = DownloadManager( + backends: [_Backend(DownloadKind.video)], settings: () => c.settings, readClipboard: () async => null); + final navKey = GlobalKey(); + late BrowserHost host; + Future settle() async { + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + await tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Scaffold(body: Text('MKV 화면')))); + navKey.currentState!.push(MaterialPageRoute( + builder: (_) => BrowserPage( + c: c, + bookmarks: bm, + downloads: d, + initialUrl: 'https://www.youtube.com/watch?v=abc', + viewBuilder: (h, url) { + host = h; + h.attach(_Nav()); + return const ColoredBox(color: Colors.white); + }, + ), + )); + await settle(); + host.onUrl('https://www.youtube.com/watch?v=abc'); + await tester.pump(); + await tester.tap(find.text('다운로드')); + await settle(); + expect(find.text('목록 보기'), findsOneWidget); + final t = d.tasks.single; + + // MKV 화면으로 (브라우저 화면이 닫힘) → 알림의 "목록 보기" 를 눌러도 다운로드 목록이 열린다 + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(); + expect(find.byType(BrowserPage), findsNothing); + await tester.tap(find.text('목록 보기')); + await settle(); + expect(find.byType(DownloadsPage), findsOneWidget); + expect(find.text('목록 보기'), findsNothing); // 누르면 알림은 닫힘 + // 다시 열어도 (위쪽 버튼 등) 목록 화면이 겹쳐 쌓이지 않는다 + await DownloadsPage.open(navKey.currentState!, d); + await settle(); + expect(find.byType(DownloadsPage, skipOffstage: false), findsOneWidget); + expect(t.state, DownloadState.downloading); + + // 다른 동영상: 받기 준비가 끝나면 (진행률이 나오면) 알림이 저절로 사라진다 + navKey.currentState!.popUntil((r) => r.isFirst); + navKey.currentState!.push(MaterialPageRoute( + builder: (_) => BrowserPage( + c: c, + bookmarks: bm, + downloads: d, + initialUrl: 'https://www.youtube.com/watch?v=def', + viewBuilder: (h, url) { + host = h; + h.attach(_Nav()); + return const ColoredBox(color: Colors.white); + }, + ), + )); + await settle(); + host.onUrl('https://www.youtube.com/watch?v=def'); + await tester.pump(); + await tester.tap(find.text('다운로드')); + await settle(); + expect(find.text('목록 보기'), findsOneWidget); + await tester.pump(const Duration(seconds: 30)); + expect(find.text('목록 보기'), findsOneWidget, reason: '준비 중에는 그대로'); + d.tasks.first.progress = 0.1; + d.refresh(); + await settle(); + expect(find.text('목록 보기'), findsNothing); + d.dispose(); + }); + testWidgets('브라우저: ☆ 추가 · 표시줄 · 다운로드 버튼 · 관리 패널 (삭제 · 실행 취소)', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1;