From 6a782b2fdb6c9edcb4cc8caa4172a53de726a215 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:34:21 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EB=8B=A4=EC=9D=8C=20=ED=8C=90:=20199=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=95=B1=20=EB=AA=A9=EB=A1=9D=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=C2=B7=20188=20=EB=B2=84=ED=8A=BC=20=EA=B8=80=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=C2=B7=20206=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=C2=B7=20181=20=C2=B7=20182=20mp3=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=20=C2=B7=20184=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9D=BC=EB=B0=98=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20185=20[=EA=B3=84=EC=86=8D=20=EB=B0=9B=EA=B8=B0]=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=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 --- assets/l10n/en.json | 5 ++ assets/l10n/ja.json | 5 ++ assets/l10n/zh-Hans.json | 5 ++ lib/app/download_manager.dart | 9 ++++ lib/core/download_detect.dart | 13 ++++- lib/main.dart | 2 +- lib/ui/all_files_guide.dart | 46 ++++++++++++++++-- lib/ui/audio_now_playing.dart | 71 +++++++++++++++++++++++++++ lib/ui/confirm.dart | 6 ++- lib/ui/downloads_page.dart | 2 + lib/ui/explorer_page.dart | 22 ++++++--- lib/ui/home_page.dart | 21 ++++++-- lib/ui/player_page.dart | 27 +++++++++++ test/audio_now_playing_test.dart | 26 ++++++++++ test/batch_test.dart | 33 +++++++++++++ test/download_direct_file_test.dart | 74 +++++++++++++++++++++++++++++ test/explorer_labels_test.dart | 59 +++++++++++++++++++++++ test/restore_access_test.dart | 35 ++++++++++++++ 18 files changed, 445 insertions(+), 16 deletions(-) create mode 100644 lib/ui/audio_now_playing.dart create mode 100644 test/audio_now_playing_test.dart create mode 100644 test/download_direct_file_test.dart create mode 100644 test/explorer_labels_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 5f8f757..9f8ff2f 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -581,6 +581,7 @@ "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently", "계속": "Always", "계속 (앱이 켜져 있는 동안)": "Always (while the app is running)", + "계속 받기": "Keep downloading", "계속 보기 ({0}초마다 다음 장)": "Slideshow (next page every {0} s)", "계속 보기 간격": "Slideshow interval", "계속 보기 간격 늘리기 (지금 {0}초)": "Longer slideshow interval (now {0} s)", @@ -648,6 +649,7 @@ "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})", + "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.", "기능 버튼 줄": "Button bar", "기본": "Default", "기본 DNS": "Primary DNS", @@ -782,6 +784,7 @@ "다시 시도": "Try again", "다시 시작": "Restart", "다시 쓰려면 다시 받아야 합니다.": "You'll need to download it again to use it.", + "다시 열기": "Open again", "다시 켤 때 동기화": "Sync on restart", "다시 확인": "Check again", "다운로드": "Downloads", @@ -1375,6 +1378,7 @@ "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment", "아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}", + "아직 허용되지 않았습니다": "Not allowed yet", "안내 닫기": "Close tip", "안쪽 파일을 확인하는 중 …": "Checking files inside …", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)", @@ -1388,6 +1392,7 @@ "앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again", + "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "If a list of apps opened, find and tap JJ_MKVMaker in the list, turn on \"All files access\", and come back.", "앱 설정 전체 (처음 상태로)": "All app settings (back to initial)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)", "앱 아이콘": "App icon", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 4376af1..db39338 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -581,6 +581,7 @@ "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます", "계속": "常時", "계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)", + "계속 받기": "ダウンロードを続ける", "계속 보기 ({0}초마다 다음 장)": "連続表示 ({0} 秒ごとに次のページ)", "계속 보기 간격": "連続表示の間隔", "계속 보기 간격 늘리기 (지금 {0}초)": "連続表示の間隔を長く (現在 {0} 秒)", @@ -648,6 +649,7 @@ "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})", + "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。", "기능 버튼 줄": "機能ボタンの列", "기본": "既定", "기본 DNS": "優先 DNS", @@ -782,6 +784,7 @@ "다시 시도": "再試行", "다시 시작": "再起動", "다시 쓰려면 다시 받아야 합니다.": "再び使うには再ダウンロードが必要です。", + "다시 열기": "もう一度開く", "다시 켤 때 동기화": "再起動時の同期", "다시 확인": "再確認", "다운로드": "ダウンロード", @@ -1375,6 +1378,7 @@ "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください", "아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}", + "아직 허용되지 않았습니다": "まだ許可されていません", "안내 닫기": "案内を閉じる", "안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)", @@ -1388,6 +1392,7 @@ "앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります", + "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。", "앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)", "앱 아이콘": "アプリアイコン", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 102973a..2387fec 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -581,6 +581,7 @@ "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除", "계속": "持续", "계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)", + "계속 받기": "继续下载", "계속 보기 ({0}초마다 다음 장)": "连续浏览 (每 {0} 秒下一页)", "계속 보기 간격": "连续浏览间隔", "계속 보기 간격 늘리기 (지금 {0}초)": "延长连续浏览间隔 (当前 {0} 秒)", @@ -648,6 +649,7 @@ "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})", + "기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。", "기능 버튼 줄": "功能按钮栏", "기본": "默认", "기본 DNS": "首选 DNS", @@ -782,6 +784,7 @@ "다시 시도": "重试", "다시 시작": "重新启动", "다시 쓰려면 다시 받아야 합니다.": "再次使用需要重新下载。", + "다시 열기": "再次打开", "다시 켤 때 동기화": "重新启动时同步", "다시 확인": "重新检查", "다운로드": "下载", @@ -1375,6 +1378,7 @@ "아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)", "아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试", "아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}", + "아직 허용되지 않았습니다": "尚未允许", "안내 닫기": "关闭提示", "안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …", "안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)", @@ -1388,6 +1392,7 @@ "앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。", "앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。", "앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放", + "앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。", "앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)", "앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)", "앱 아이콘": "应用图标", diff --git a/lib/app/download_manager.dart b/lib/app/download_manager.dart index 8eedaab..b163631 100644 --- a/lib/app/download_manager.dart +++ b/lib/app/download_manager.dart @@ -161,6 +161,15 @@ class DownloadManager extends ChangeNotifier { if (detectLinks(url).isNotEmpty) return add(url); if (!RegExp(r'^https?://', caseSensitive: false).hasMatch(url)) return null; if (tasks.any((t) => t.source == url && t.state != DownloadState.cancelled)) return null; + // 184: 동영상이 아닌 직접 파일 주소 (zip 등) 는 일반 파일 받기 (aria2) 로 - 원래 이름 그대로 + if (isDirectFileUrl(url) && backends[DownloadKind.torrent] != null) { + final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.torrent, source: url.trim(), dir: settings().aria2Dir) + ..title = Uri.tryParse(url.trim())?.pathSegments.lastOrNull ?? url; + tasks.insert(0, t); + _added.add(t); + _changed(); + return t; + } if (backends[DownloadKind.video] == null) return null; final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: url, dir: settings().ytDlpDir); tasks.insert(0, t); diff --git a/lib/core/download_detect.dart b/lib/core/download_detect.dart index 9132b42..e54b471 100644 --- a/lib/core/download_detect.dart +++ b/lib/core/download_detect.dart @@ -4,10 +4,21 @@ enum DownloadKind { /// YouTube 등 동영상 사이트 → yt-dlp video, - /// 마그넷 링크 · .torrent 주소 → aria2 + /// 마그넷 링크 · .torrent 주소 · 직접 파일 주소 (184: zip 등 - 원래 이름 그대로) → aria2 torrent, } +/// 184: 동영상 · 소리가 아닌 직접 파일 주소 (…/100MB.zip 등). 동영상 받기 (yt-dlp) 로 받으면 다 받은 뒤 +/// 후처리에서 "실패" 가 되고 이름도 "100MB [100MB].unknown_video" 가 됐다 - 일반 파일 받기 (aria2) 로 원래 이름 그대로 받는다. +/// 클립보드 자동 감지에는 쓰지 않는다 (복사만 해도 받기 시작하지 않게) - 사용자가 넣은 주소 · 브라우저 [다운로드] 에만 +bool isDirectFileUrl(String url) => _directFile.hasMatch(url.trim()); + +final _directFile = RegExp( + r'^https?://[^\s<>"?#]+\.(?:zip|7z|rar|gz|tgz|tar|xz|bz2|zst|exe|msi|dmg|pkg|deb|rpm|appimage|iso|img|apk|apks|xapk|' + r'pdf|epub|docx?|xlsx?|pptx?|hwpx?|odt|ods|txt|csv|json|xml|bin|dat|ttf|otf|jpg|jpeg|png|gif|webp|bmp|svg|psd)' + r'(?:[?#][^\s<>"]*)?$', + caseSensitive: false); + class DetectedLink { final DownloadKind kind; final String url; diff --git a/lib/main.dart b/lib/main.dart index f321a7e..798b319 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -676,7 +676,7 @@ Future runAndroid(String dataDir) async { // 116: 다른 안내 · 분석 오류보다 먼저 (허용하고 돌아오면 실패한 분석을 다시 한다 - onResume) final ctxA = navigatorKey.currentContext; if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) { - await showAllFilesGuide(ctxA, AndroidAccess.request); + await showAllFilesGuide(ctxA, AndroidAccess.request, hasAccess: AndroidAccess.hasAllFiles); } // 124: "처음 시작 시" 면 마스터 비밀번호 final ctxM = navigatorKey.currentContext; diff --git a/lib/ui/all_files_guide.dart b/lib/ui/all_files_guide.dart index a5b57a3..cca3468 100644 --- a/lib/ui/all_files_guide.dart +++ b/lib/ui/all_files_guide.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../app/app_controller.dart'; @@ -17,7 +19,9 @@ Future allFilesGuideDue(AppController c, {required bool hasAccess}) async /// 116: 처음 켤 때 "모든 파일에 대한 접근" 안내 (분석 오류보다 먼저). [허용] 은 Android 설정 화면을 연다. /// 허용하고 돌아오면 앱이 실패한 분석을 저절로 다시 한다. -Future showAllFilesGuide(BuildContext context, Future Function() request) async { +/// 199: 기기에 따라 (노트 10+ · 삼성 Android 12) 이 앱 화면이 아니라 전체 앱 목록이 열린다 - 예외도 나지 않아 미리 알 수 없다. +/// 그래서 돌아왔을 때 [hasAccess] 가 아직 없으면 "목록에서 JJ_MKVMaker 를 찾아 켜세요" 와 [다시 열기] 를 보인다 +Future showAllFilesGuide(BuildContext context, Future Function() request, {Future Function()? hasAccess}) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -26,7 +30,8 @@ Future showAllFilesGuide(BuildContext context, Future Function() req content: SizedBox( width: 480, child: Text('${tr('동영상을 고르고 MKV 를 만들려면 "모든 파일에 대한 접근" 권한이 필요합니다.')}\n\n' - '${tr('[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.')}'), + '${tr('[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.')}\n\n' + '${tr('기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.')}'), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), @@ -34,5 +39,40 @@ Future showAllFilesGuide(BuildContext context, Future Function() req ], ), ); - if (ok == true) await request(); + if (ok != true) return; + for (var i = 0; i < 3; i++) { + final back = _resumed(); + await request(); + if (hasAccess == null) return; + await back; + if (await hasAccess() || !context.mounted) return; + // 199: 돌아왔는데 아직 꺼져 있음 - 앱 목록이 열렸거나 켜지 않고 돌아옴 + final again = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('아직 허용되지 않았습니다')), + content: SizedBox( + width: 480, + child: Text(tr('앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 "모든 파일에 대한 접근" 을 켜고 돌아오세요.')), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('다시 열기'))), + ], + ), + ); + if (again != true) return; + } +} + +/// 다른 화면 (Android 설정) 에서 이 앱으로 돌아올 때까지 +Future _resumed() { + final done = Completer(); + late final AppLifecycleListener l; + l = AppLifecycleListener(onResume: () { + l.dispose(); + if (!done.isCompleted) done.complete(); + }); + return done.future; } diff --git a/lib/ui/audio_now_playing.dart b/lib/ui/audio_now_playing.dart new file mode 100644 index 0000000..738fe96 --- /dev/null +++ b/lib/ui/audio_now_playing.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../services/media_player.dart'; +import 'theme.dart'; + +/// 182: 소리만 있는 파일 (mp3 등) 을 틀 때 영상 자리에 보일 것 - 예전에는 검은 화면뿐이었다. +/// 제목 (파일 이름) · 앨범 그림 (파일 안에 있으면) · 재생 위치 +class AudioNowPlaying extends StatelessWidget { + final String file; + final ValueListenable state; + + /// 앨범 그림 파일 (없으면 null) - 파일마다 한 번만 꺼낸다 + final Future cover; + const AudioNowPlaying({super.key, required this.file, required this.state, required this.cover}); + + static String _t(Duration d) { + final h = d.inHours, m = d.inMinutes % 60, s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$s' : '$m:$s'; + } + + @override + Widget build(BuildContext context) => Container( + color: Colors.black, + alignment: Alignment.center, + padding: const EdgeInsets.all(24), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Flexible( + child: FutureBuilder( + future: cover, + builder: (_, s) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320), + child: s.data != null + ? Image.file(File(s.data!), fit: BoxFit.contain, errorBuilder: (_, _, _) => const _Note()) + : const _Note(), + ), + ), + ), + const SizedBox(height: 16), + Text(p.basenameWithoutExtension(file), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: state, + builder: (_, s, _) => Text( + '${_t(s.position)} / ${_t(s.duration)}', + style: const TextStyle(color: JjColors.textDim, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]), + ), + ), + ]), + ); +} + +class _Note extends StatelessWidget { + const _Note(); + + @override + Widget build(BuildContext context) => const AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration(color: Color(0xFF1E1E1E), borderRadius: BorderRadius.all(Radius.circular(12))), + child: Center(child: Icon(Icons.music_note, size: 96, color: JjColors.textDim)), + ), + ); +} diff --git a/lib/ui/confirm.dart b/lib/ui/confirm.dart index 738b3b7..4de556d 100644 --- a/lib/ui/confirm.dart +++ b/lib/ui/confirm.dart @@ -3,13 +3,15 @@ import 'package:flutter/material.dart'; import '../l10n/tr.dart'; import 'theme.dart'; -/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true +/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true. +/// [cancel]: 하지 않는 쪽 버튼 이름 (처음 값 "취소" - 확인 버튼이 "… 취소" 면 헷갈리므로 바꾼다, 185) Future confirmAction( BuildContext context, { required String title, required String body, required String ok, bool danger = true, + String? cancel, }) async { final r = await showDialog( context: context, @@ -18,7 +20,7 @@ Future confirmAction( title: Text(title), content: Text(body), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(cancel ?? tr('취소'))), FilledButton( style: danger ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, onPressed: () => Navigator.pop(ctx, true), diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 7591601..4b8d885 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -215,6 +215,8 @@ class _DownloadsPageState extends State { title: tr('다운로드 취소'), body: trf('선택한 다운로드 {0}개를 취소할까요? 잠깐 멈추려면 [일시정지] 를 쓰세요.', [n]), ok: tr('다운로드 취소'), + // 185: 바로 옆 [다운로드 취소] 와 헷갈리지 않게 - 취소하지 않는 쪽은 [계속 받기] + cancel: tr('계속 받기'), ); if (ok) await widget.d.cancelSelected(); } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index e0b8670..fc1a415 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1405,7 +1405,8 @@ class _ExplorerPageState extends State { await _reloadOpen(); case ExplorerButton.play: final t = _targets(pane); - final videos = t.where((x) => !vIsDirSync(x) && isVideoFile(x)).toList(); + // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) + final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); if (videos.isNotEmpty) { await _play(videos, keepOrder: pane.marked.isNotEmpty); } else { @@ -3650,11 +3651,20 @@ class _Toolbar extends StatelessWidget { child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null), const SizedBox(height: 2), - Text(tr(_label(b)), - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)), + // 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게 + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: SizedBox( + width: 68, + child: Text(tr(_label(b)), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)), + ), + ), + ), ]), ), ), diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index f19296d..7da53d8 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -1014,8 +1014,18 @@ class _VideoDetail extends StatelessWidget { label: Text('${t?.name ?? tr('이동')}$count'), ), ); + // 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다 + if (isCompact(context)) { + final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [ + if (buttons.isEmpty) button(null), + for (final t in buttons) button(t), + ]); + return _list(context, info, video, audioCount, locked, moveButtons: row, bottom: 24); + } + // 넓은 화면: 떠 있는 버튼 높이만큼 목록 아래를 비워 끝까지 내리면 가리지 않게 (버튼이 여러 개면 그만큼) + final stacked = buttons.isEmpty ? 1 : buttons.length; return Stack(children: [ - _list(context, info, video, audioCount, locked), + _list(context, info, video, audioCount, locked, bottom: 16 + stacked * 66.0 + 16), Positioned( right: 16, bottom: 16, @@ -1035,10 +1045,11 @@ class _VideoDetail extends StatelessWidget { ]); } - Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked) { + Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked, + {Widget? moveButtons, double bottom = 88}) { final side = isCompact(context) ? 12.0 : 20.0; return ListView( - padding: EdgeInsets.fromLTRB(side, side, side, 88), + padding: EdgeInsets.fromLTRB(side, side, side, bottom), children: [ Row(children: [ Expanded( @@ -1072,6 +1083,10 @@ class _VideoDetail extends StatelessWidget { ], _Chip(trf('음성 {0}개', [audioCount])), ]), + if (moveButtons != null) ...[ + const SizedBox(height: 10), + moveButtons, + ], // 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게 if (v.aiDetected != null) ...[ const SizedBox(height: 8), diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 0e920e6..f9465b2 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -13,6 +13,7 @@ import '../core/playlist.dart'; import '../core/srt.dart' show formatSrtTime; import '../services/media_player.dart'; import 'app_actions.dart'; +import 'audio_now_playing.dart'; import 'dav_external.dart'; import 'theme.dart'; import '../l10n/tr.dart'; @@ -92,6 +93,12 @@ class _PlayerPageState extends State { if (_popup) c.services.shell.setPopup(false); pl.dispose(); _focus.dispose(); + // 182: 꺼낸 앨범 그림은 닫을 때 지운다 (임시 폴더에 쌓이지 않게) + for (final f in _covers.values) { + unawaited(f.then((path) async { + if (path != null) await File(path).delete(); + }).catchError((_) {})); + } super.dispose(); } @@ -313,6 +320,9 @@ class _PlayerPageState extends State { onLongPressStart: (d) => _menu.open(position: d.localPosition), child: Stack(fit: StackFit.expand, children: [ view, + // 182: 소리만 있는 파일은 검은 화면 대신 제목 · 앨범 그림 · 재생 위치 + if (isAudioFile(_currentFile)) + IgnorePointer(child: AudioNowPlaying(file: _currentFile, state: pl.state, cover: _cover(_currentFile))), if (_error != null) Positioned( top: 8, @@ -342,6 +352,23 @@ class _PlayerPageState extends State { ); } + /// 182: 소리 파일 안의 앨범 그림을 꺼낸다 (FFmpeg, 파일마다 한 번). 없거나 못 꺼내면 null - 음표를 보인다. + /// WebDAV 는 받지 않고 틀므로 꺼내지 않는다 + final _covers = >{}; + Future _cover(String file) => _covers[file] ??= () async { + if (file.startsWith('dav://') || file.startsWith('http')) return null; + try { + final dir = await c.services.storage.tempDirectory(); + final out = p.join(dir, 'jj_cover_${file.hashCode.toUnsigned(32)}.jpg'); + final f = File(out); + if (await f.exists()) await f.delete(); + await c.services.mediaTool.runFfmpeg(['-y', '-v', 'error', '-i', file, '-an', '-frames:v', '1', out]); + return await f.exists() && await f.length() > 0 ? out : null; + } catch (_) { + return null; + } + }(); + List _contextMenu() { final s = pl.state.value; final subs = _subtitleChoices(); diff --git a/test/audio_now_playing_test.dart b/test/audio_now_playing_test.dart new file mode 100644 index 0000000..8d7644e --- /dev/null +++ b/test/audio_now_playing_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/playlist.dart'; +import 'package:jj_mkvmaker/services/media_player.dart'; +import 'package:jj_mkvmaker/ui/audio_now_playing.dart'; + +/// 181 · 182: 소리 파일 (mp3) 재생 +void main() { + test('181: mp3 는 소리 파일 - 탐색기 [재생] 이 강조한 mp3 를 튼다 (동영상이 아니라고 빼지 않음)', () { + expect([isAudioFile(r'D:\m\song.mp3'), isVideoFile(r'D:\m\song.mp3')], [true, false]); + }); + + testWidgets('182: 소리 파일은 검은 화면 대신 제목 · 앨범 그림 (없으면 음표) · 재생 위치', (tester) async { + final state = ValueNotifier(const PlayerState(position: Duration(seconds: 83), duration: Duration(minutes: 4, seconds: 56))); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: AudioNowPlaying(file: r'D:\m\내 노래.mp3', state: state, cover: Future.value(null))), + )); + await tester.pump(); + expect(find.text('내 노래'), findsOneWidget); + expect(find.text('1:23 / 4:56'), findsOneWidget); + expect(find.byIcon(Icons.music_note), findsOneWidget); + state.value = const PlayerState(position: Duration(seconds: 84), duration: Duration(minutes: 4, seconds: 56)); + await tester.pump(); + expect(find.text('1:24 / 4:56'), findsOneWidget, reason: '재생 위치가 따라 바뀐다'); + }); +} diff --git a/test/batch_test.dart b/test/batch_test.dart index 04eea85..b1ddb06 100644 --- a/test/batch_test.dart +++ b/test/batch_test.dart @@ -394,6 +394,39 @@ void main() { expect(find.textContaining('감지한 언어'), findsNothing); }); + for (final (name, size, targets) in [ + ('폰 세로', const Size(412, 900), 0), + ('폰 세로 · 이동 버튼 3개', const Size(412, 900), 3), + ('넓은 화면 · 이동 버튼 3개', const Size(1600, 520), 3), + ]) { + testWidgets('206: MKV 자세히 칸의 [이동] 버튼이 칩 (음성 n개) 을 가리지 않는다 ($name)', (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')]; + c.videos.add(VideoItem(r'D:\v\a.mp4') + ..info = const MediaInfo(duration: Duration(minutes: 3), streams: [ + StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080), + StreamInfo(index: 1, type: 'audio', codec: 'aac'), + ])); + c.selected = c.videos.first; + await tester.pumpWidget(MaterialApp(home: HomePage(c: c))); + await tester.pump(); + final chip = find.text('음성 1개'); + // 끝까지 내려 본다 (넓은 화면은 떠 있는 버튼 - 끝에서도 가리지 않아야) + await tester.dragUntilVisible(chip, find.byType(ListView).last, const Offset(0, -80)); + await tester.drag(find.byType(ListView).last, const Offset(0, -2000)); + await tester.pumpAndSettle(); + expect(chip, findsOneWidget); + final r = tester.getRect(chip); + for (final b in find.byType(FloatingActionButton).evaluate()) { + final br = tester.getRect(find.byWidget(b.widget)); + expect(br.overlaps(r), isFalse, reason: '버튼 $br · 칩 $r'); + } + }); + } + testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async { tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비 tester.view.devicePixelRatio = 1; diff --git a/test/download_direct_file_test.dart b/test/download_direct_file_test.dart new file mode 100644 index 0000000..335c201 --- /dev/null +++ b/test/download_direct_file_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; + +class _Backend implements DownloadBackend { + @override + final DownloadKind kind; + _Backend(this.kind); + final started = []; + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async { + started.add(t.source); + t.state = DownloadState.downloading; + } + + @override + Future pause(DownloadTask t) async {} + @override + Future cancel(DownloadTask t) async {} + @override + Future shutdown() async {} +} + +/// 184 · 185: 직접 파일 주소는 일반 받기 · 다운로드 취소 창의 [계속 받기] +void main() { + test('184: 직접 파일 주소 알아보기 (동영상 · 소리 · 웹 페이지는 아님)', () { + expect(isDirectFileUrl('http://speedtest.tele2.net/100MB.zip'), isTrue); + expect(isDirectFileUrl('https://example.com/files/setup.EXE?v=2'), isTrue); + expect(isDirectFileUrl('https://example.com/doc.pdf#page=3'), isTrue); + expect(isDirectFileUrl('https://example.com/movie.mp4'), isFalse, reason: '동영상은 동영상 받기로'); + expect(isDirectFileUrl('https://www.youtube.com/watch?v=abc'), isFalse); + expect(isDirectFileUrl('https://example.com/page'), isFalse); + expect(isDirectFileUrl('https://example.com/download.php?file=a.zip'), isFalse, reason: '경로가 .zip 으로 끝나지 않으면 모름 (동영상 받기 그대로)'); + expect(detectLinks('http://speedtest.tele2.net/100MB.zip'), isEmpty, reason: '클립보드 자동 감지에는 쓰지 않음'); + }); + + test('184: 직접 파일 주소는 일반 파일 받기 (aria2) 로 · 원래 이름 · aria2 폴더', () { + final s = AppSettings(); + final video = _Backend(DownloadKind.video), files = _Backend(DownloadKind.torrent); + final d = DownloadManager(backends: [video, files], settings: () => s, readClipboard: () async => null); + addTearDown(d.dispose); + final t = d.addPage('http://speedtest.tele2.net/100MB.zip')!; + expect(t.kind, DownloadKind.torrent); + expect(t.title, '100MB.zip'); + expect(t.dir, s.aria2Dir); + final page = d.addPage('https://example.com/some/page')!; + expect(page.kind, DownloadKind.video, reason: '그 밖의 웹 페이지는 지금처럼 동영상 받기'); + }); + + testWidgets('185: 다운로드 취소 창 - 취소하지 않는 쪽은 [계속 받기] (옆의 [다운로드 취소] 와 헷갈리지 않게)', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final s = AppSettings(); + final d = DownloadManager(backends: [_Backend(DownloadKind.video)], settings: () => s, readClipboard: () async => null); + addTearDown(d.dispose); + final t = d.addPage('https://example.com/some/page')!; + d.toggle(t); + await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d))); + await tester.pump(); + await tester.tap(find.widgetWithText(OutlinedButton, '다운로드 취소')); + await tester.pump(const Duration(milliseconds: 500)); // 받는 중 진행 막대가 움직여 pumpAndSettle 은 끝나지 않는다 + expect(find.widgetWithText(TextButton, '계속 받기'), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, '계속 받기')); + await tester.pump(const Duration(milliseconds: 500)); // 받는 중 진행 막대가 움직여 pumpAndSettle 은 끝나지 않는다 + expect(t.state, isNot(DownloadState.cancelled)); + }); +} diff --git a/test/explorer_labels_test.dart b/test/explorer_labels_test.dart new file mode 100644 index 0000000..ee09bb4 --- /dev/null +++ b/test/explorer_labels_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; + +/// 188: 탐색기 가운데 버튼 줄의 글이 두 줄로 꺾여도 버튼 아래로 잘리지 않는다 (일본어 "新しいフォルダー" · 세로 화면) +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_labels_')); + tearDown(() { + setTranslations('ko', const {}); + tmp.deleteSync(recursive: true); + }); + + Map dict(String code) => + Map.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map); + + for (final lang in ['ko', 'en', 'ja', 'zh-Hans']) { + for (final size in [const Size(800, 1280), const Size(1280, 800), const Size(412, 900)]) { + for (final scale in [1.0, 1.6]) { + testWidgets('188: 버튼 글이 버튼 안에 다 보인다 ($lang, ${size.width.round()}×${size.height.round()}, 글자 $scale배)', (tester) async { + setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en')); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.explorerPaths = [tmp.path, tmp.path]; + // 기기 글꼴 · 글자 크기가 시험 글꼴보다 크다 (S10 일본어에서 잘림) - 글자 크기를 키운 경우도 + await tester.runAsync(() => tester.pumpWidget(MaterialApp( + builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!), + home: ExplorerPage(c: c)))); + for (var i = 0; i < 10; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await tester.pump(); + } + // 1.6배에서는 목록 줄 (높이 고정) 이 넘친다 - 따로 알림 (이 시험은 버튼 줄만 본다) + if (scale == 1.0) { + expect(tester.takeException(), isNull); + } else { + tester.takeException(); + } + final label = find.text(tr('새 폴더')); + if (label.evaluate().isEmpty) return; // 좁아서 [더 보기] 메뉴로 들어감 + final text = tester.getRect(label.first); + final button = tester.getRect(find.ancestor(of: label.first, matching: find.byType(InkWell)).first); + expect(text.bottom, lessThanOrEqualTo(button.bottom + 0.5), reason: '글 $text · 버튼 $button'); + expect(text.top, greaterThanOrEqualTo(button.top - 0.5)); + }); + } + } + } +} diff --git a/test/restore_access_test.dart b/test/restore_access_test.dart index 82a4cb2..046dd4a 100644 --- a/test/restore_access_test.dart +++ b/test/restore_access_test.dart @@ -202,5 +202,40 @@ void main() { await t.pumpAndSettle(); expect(requested, 1); }); + + testWidgets('199: 앱 목록이 열려 (노트 10+) 켜지 않고 돌아오면 "목록에서 찾아 켜세요" · [다시 열기], 켜고 돌아오면 끝', (t) async { + var requested = 0; + var allowed = false; + await t.pumpWidget(MaterialApp( + home: Builder( + builder: (ctx) => TextButton( + onPressed: () => showAllFilesGuide(ctx, () async => requested++, hasAccess: () async => allowed), + child: const Text('go'), + ), + ), + )); + Future goAndComeBack() async { + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await t.pumpAndSettle(); + } + + await t.tap(find.text('go')); + await t.pumpAndSettle(); + expect(find.textContaining('목록에서 JJ_MKVMaker 를 찾아 켜세요'), findsOneWidget, reason: '처음 창에도 한 줄'); + await t.tap(find.text('허용')); + await t.pumpAndSettle(); + expect(requested, 1); + await goAndComeBack(); // 켜지 않고 돌아옴 + expect(find.text('아직 허용되지 않았습니다'), findsOneWidget); + await t.tap(find.text('다시 열기')); + await t.pumpAndSettle(); + expect(requested, 2); + allowed = true; + await goAndComeBack(); // 켜고 돌아옴 + expect(find.text('아직 허용되지 않았습니다'), findsNothing); + }); }); }